forked from WING-NUS/SSID
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request WING-NUS#352 from WING-NUS/feature/ssid_coursemolo…
…gy_integration Coursemology Integration Phase 1 Stage 2
- Loading branch information
Showing
22 changed files
with
1,363 additions
and
175 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -40,4 +40,4 @@ | |
/node_modules | ||
/yarn-error.log | ||
yarn-debug.log* | ||
.yarn-integrity | ||
.yarn-integrity |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
148
app/controllers/api/v1/submission_similarities_controller.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.