Skip to content

Commit

Permalink
Merge pull request WING-NUS#352 from WING-NUS/feature/ssid_coursemolo…
Browse files Browse the repository at this point in the history
…gy_integration

Coursemology Integration Phase 1 Stage 2
  • Loading branch information
huyuxin0429 authored Oct 23, 2023
2 parents 33a0a25 + de8a613 commit 4477102
Show file tree
Hide file tree
Showing 22 changed files with 1,363 additions and 175 deletions.
9 changes: 7 additions & 2 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,14 @@ jobs:
sudo /etc/init.d/mysql start
mysql -e 'CREATE DATABASE ssid_test;' -u root -proot
bundle exec rails db:create
bundle exec rails db:migrate
bundle exec rails db:migrate RAILS_ENV=test
bundle exec rails db:seed
- name: Install chrome
id: setup-chrome
uses: browser-actions/setup-chrome@v1
with:
chrome-version: stable

- name: Show chrome version
run: |
Expand All @@ -112,7 +114,10 @@ jobs:
bundle exec rails server -d -p 3000 -e test
sleep 5
- name: Run tests
run: bundle exec rspec
run: |
bundle exec rspec spec/api_requests/
bundle exec rspec spec/routes
# bundle exec rspec spec/landing_page_spec.rb

- name: Coveralls
uses: coverallsapp/github-action@master
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@
/node_modules
/yarn-error.log
yarn-debug.log*
.yarn-integrity
.yarn-integrity
210 changes: 210 additions & 0 deletions app/controllers/api/v1/assignments_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# frozen_string_literal: true

# This file is part of SSID.
#
# SSID is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# SSID is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with SSID. If not, see <http://www.gnu.org/licenses/>.

require 'zip'
require 'api_keys_handler'

module Api
module V1
class AssignmentsController < ApplicationController
skip_before_action :authenticate_user!

before_action do |_controller|
@course = Course.find(params['course_id']) if params['course_id']
end

before_action :init_api_key_handler

# Define valid zip mime types as constant variables
X_ZIP_COMPRESSED_MIME_TYPE = 'application/x-zip-compressed'
ZIP_COMPRESSED_MIME_TYPE = 'application/zip-compressed'
APPLICATION_ZIP_MIME_TYPE = 'application/zip'
MULTIPART_X_ZIP_MIME_TYPE = 'multipart/x-zip'
OCTET_STREAM_MIME_TYPE = 'application/octet-stream'
REQUIRED_PARAMS = %w[title language studentSubmissions].freeze
ALLOWED_PARAMS = %w[title language useFingerprints minimumMatchLength sizeOfNGram studentSubmissions
mappingFile].freeze
ALLOWED_LANGUAGES = %w[java python3 c cpp javascript r ocaml matlab scala].freeze

def init_api_key_handler
APIKeysHandler.api_key = ApiKey.find_by(value: request.headers['X-API-KEY'])
APIKeysHandler.course = @course
end

# GET api/v1/courses/1/assignments/new
def new
@assignment = Assignment.new
end

# POST api/v1/courses/1/assignments
def create
REQUIRED_PARAMS.each do |p|
if params[p].nil?
render json: { error: "Missing required parameter '#{p}'" }, status: :bad_request
return
end
end

request.request_parameters.each do |k, _v|
if ALLOWED_PARAMS.include?(k) == false
render json: { error: "Parameter #{k} is invalid or not yet supported." }, status: :bad_request
return
end
end

@assignment = Assignment.new do |a|
a.title = params['title']
a.language = params['language']
a.min_match_length = params['minimumMatchLength'].presence || 2 # defaults to 2 if not specified
a.ngram_size = params['sizeOfNGram'].presence || 5 # defaults to 5 if not specified
a.course_id = @course.id
end

begin
APIKeysHandler.authenticate_api_key
rescue APIKeysHandler::APIKeyError => e
render json: { error: e.message }, status: e.status
return
end

REQUIRED_PARAMS.each do |p|
if params[p].nil?
render json: { error: "Missing required parameter '#{p}'" }, status: :bad_request
return
end
end

unless ALLOWED_LANGUAGES.include?(params['language'])
render json: { error: "Value of language is not valid.' +
'We currently support #{ALLOWED_LANGUAGES}.' +
'The parameter value must be in lowercase and match exactly one of the options." },
status: :bad_request
return
end

if params['useFingerprints'] && %w[Yes No].exclude?(params['useFingerprints'])
render json: { error: 'Value of useFingerprints is not valid. ' \
'The value should be "Yes" or "No". ' \
'The parameter value must be in lowercase and match exactly one of the options.' },
status: :bad_request
return
end

Rails.logger.debug 'DEBUG 06: Enable fingerprints checkbox?'
Rails.logger.debug { "Checkbox: #{params['useFingerprints']}" }
# Process file if @assignment is valid and file was uploaded
if @assignment.valid?

# Save assignment to obtain id
return render action: 'new' unless @assignment.save

is_map_enabled = !params['mappingFile'].nil?
used_fingerprints = params['useFingerprints'] == 'Yes'

# No student submission file was uploaded
# Student submission file is a valid zip
if valid_zip?(params['studentSubmissions'].content_type, params['studentSubmissions'].path)
# Don't process the file and show error if the mapping was enabled but no mapping file was uploaded
if valid_map_or_no_map?(is_map_enabled, params['mappingFile'])
start_upload(@assignment, params['studentSubmissions'], is_map_enabled, params['mappingFile'],
used_fingerprints)
# Don't process the file and show error if the mapping was enabled but no mapping file was uploaded
else
@assignment.errors.add :mapfile, 'containing mapped student names must be a valid csv file'
render json: { error: "Value of mappingFile is not valid. '
+ 'The mapping file must be a valid csv file." },
status: :bad_request
end
# Student submission file is not a valid zip file
else
@assignment.errors.add :file, 'containing student submission files must be a valid zip file'
render json: { error: 'Value of studentSubmissions is not valid. ' \
'studentSubmissions must be a valid zip file.' },
status: :bad_request
end
else
render action: 'new'
end
end

def start_upload(assignment, submission_file, is_map_enabled, map_file, used_fingerprints)
require 'submissions_handler'

# Process upload file
submissions_path = SubmissionsHandler.process_upload(submission_file, is_map_enabled, map_file, assignment)
if submissions_path
# Launch java program to process submissions
SubmissionsHandler.process_submissions(submissions_path, assignment, is_map_enabled, used_fingerprints)

render json: { assignmentID: @assignment.id }, status: :ok
else
assignment.errors.add 'Submission zip file',
': SSID supports both directory-based and file-based submissions. ' \
'Please select the submissions you want to evaluate and compress.'
render action: 'show'
end
end

# Responsible for verifying whether a uploaded file is zip by checking its mime
# type and/or whether can it be extracted by the zip library.
# For files with mime type = application/octet-stream, it needs to be further verified
# by the zip library as it can be a rar file.
# Params:
# +mime_type+:: string that contains the file's mimetype
# +filePath+:: string that contains the file's path which is to be used
# by the zip library when extracting the file
def valid_zip?(mime_type, file_path)
# Valid zip file mime types that does not required to be further verified by the zip library
if [X_ZIP_COMPRESSED_MIME_TYPE, ZIP_COMPRESSED_MIME_TYPE, APPLICATION_ZIP_MIME_TYPE,
MULTIPART_X_ZIP_MIME_TYPE].include?(mime_type)
true
# Need to be further verified by zip library as it can be a rar file
elsif mime_type == OCTET_STREAM_MIME_TYPE && opened_as_zip?(file_path)
return true
# For other mime types, safe to consider that it is not a zip file
end
false
end

# Responsible for verifying whether a uploaded file is zip by checking whether can it be extracted by the zip
# library
# Params:
# +filePath+:: string that contains the file's path which is to be used by the zip library
# when extracting the file
def opened_as_zip?(path)
# File is zip if the zip library is able to extract the file
zip = Zip::File.open(path)
true
rescue StandardError => e
Rails.logger.debug e
false
ensure
zip&.close
end

def valid_map_or_no_map?(is_map_enabled, map_file)
return true unless is_map_enabled

if map_file.nil?
false
else
map_file.path.split('.').last.to_s.downcase == 'csv'
end
end
end
end
end
148 changes: 148 additions & 0 deletions app/controllers/api/v1/submission_similarities_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# frozen_string_literal: true

require 'api_keys_handler'

module Api
module V1
# The `SubmissionSimilaritiesController` is responsible for handling API requests related to
# fetching submission similarities for assignments. It provides an endpoint for clients to
# retrieve submission similarities associated with a given assignment. The API key must be
# provided and validated for access, and the user associated with the API key must have
# authorization for the specified course.
class SubmissionSimilaritiesController < ApplicationController
skip_before_action :authenticate_user!

before_action do |_controller|
set_api_key_and_assignment
end

def index
APIKeysHandler.authenticate_api_key
render_submission_similarities
rescue APIKeysHandler::APIKeyError => e
render json: { error: e.message }, status: e.status
end

def show
APIKeysHandler.authenticate_api_key
render_pair_of_flagged_submissions
rescue APIKeysHandler::APIKeyError => e
render json: { error: e.message }, status: e.status
end

private

def set_api_key_and_assignment
set_api_key
set_course_and_assignment
end

def set_api_key
api_key_value = request.headers['X-API-KEY']
APIKeysHandler.api_key = ApiKey.find_by(value: api_key_value)
end

def set_course_and_assignment(assignment_id = params[:assignment_id])
assignment = Assignment.find_by(id: assignment_id)
return nil if assignment.nil?

APIKeysHandler.course = assignment.course
assignment
end

def render_submission_similarities
assignment = set_course_and_assignment(params[:assignment_id])

if assignment.nil?
render json: { error: 'Assignment does not exist' }, status: :bad_request
return
end
# Check if the assignment has associated submission files.
if assignment.submissions.empty?
render json: { status: 'empty' }, status: :ok
return
end

# Determine process status of assignment
submission_similarity_process = assignment.submission_similarity_process
case submission_similarity_process.status
when SubmissionSimilarityProcess::STATUS_RUNNING, SubmissionSimilarityProcess::STATUS_WAITING
render json: { status: 'processing' }, status: :ok
return
when SubmissionSimilarityProcess::STATUS_ERRONEOUS
render json: { status: 'error', message: 'SSID is busy or under maintenance. Please try again later.' },
status: :service_unavailable
return
end

submission_similarities = assignment.submission_similarities

### Filtering Code
# Apply the threshold filter
if params[:threshold].present?
threshold_value = params[:threshold].to_f
submission_similarities = submission_similarities.where('similarity >= ?', threshold_value)
end

# Apply the limit filter
if params[:limit].present?
limit_value = params[:limit].to_i
submission_similarities = submission_similarities.limit(limit_value)
end

# Apply the page filter
if params[:page].present?
per_page = params[:limit].present? ? limit_value : 20 # Default per page value is 20, limit to use a page size
page_number = params[:page].to_i
submission_similarities = submission_similarities.offset(per_page * (page_number - 1))
end

# Process subnission similarities into readable format for returning via JSON
result_submission_similarities = []

submission_similarities.each { |submission_similarity|
result_submission_similarities.append( {
submissionSimilarityID: submission_similarity.id,
student1ID: submission_similarity.submission1.student_id,
student2ID: submission_similarity.submission2.student_id,
similarity: submission_similarity.similarity
}
)
}

render json: { status: 'processed', submissionSimilarities: result_submission_similarities }, status: :ok
end

def render_pair_of_flagged_submissions
submission_similarity = SubmissionSimilarity.find_by(
assignment_id: params[:assignment_id],
id: params[:id]
)

if submission_similarity.nil?
render json: { error: 'Submission similarities requested do not exist.' }, status: :bad_request
return
end

matches = []

submission_similarity.similarity_mappings.each do |similarity|
matches.append(
{
student1StartLine: similarity.start_line1 + 1,
student1EndLine: similarity.end_line1 + 1,
student2StartLine: similarity.start_line2 + 1,
student2EndLine: similarity.end_line2 + 1,
numOfMatchingStatements: similarity.statement_count
}
)
end

render json: {
similarity: submission_similarity.similarity,
matches: matches
}, status: :ok
end
end
end
end
Loading

0 comments on commit 4477102

Please sign in to comment.