Skip to content

Commit

Permalink
feat(koditsu): fetch all submissions from Koditsu
Browse files Browse the repository at this point in the history
- set scheduling towards end of assessment to fetch
- provide button to fetch all submissions from Koditsu
- if Koditsu, instead of Force Submit, provide fetch from Koditsu button
  • Loading branch information
bivanalhar committed Oct 4, 2024
1 parent 386b37e commit 8551a6b
Show file tree
Hide file tree
Showing 18 changed files with 411 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# frozen_string_literal: true
# rubocop:disable Metrics/ModuleLength
module Course::Assessment::Submission::KoditsuSubmissionsConcern
extend ActiveSupport::Concern
include Course::Assessment::Question::KoditsuQuestionConcern

def fetch_all_submissions_from_koditsu(assessment, user)
@assessment = assessment
@user = user
submission_service = Course::Assessment::Submission::KoditsuSubmissionService.new(@assessment)
status, response = submission_service.run_fetch_all_submissions

return [status, nil] if status != 200 && status != 207

@all_submissions = response
@questions = @assessment.questions.includes({ actable: :test_cases })

@test_cases_order = @questions.to_h do |question|
test_cases = question.actable.test_cases
[question.id, sort_for_koditsu(test_cases)]
end

@all_submissions.each do |submission|
next if submission['status'] == 'notStarted' || submission['status'] == 'error'

process_submission(submission)
end
end

private

def order_test_cases_type
{
'public' => 0,
'private' => 1,
'evaluation' => 2
}
end

def submission_status_hash
{
'inProgress' => 'attempting',
'submitted' => 'submitted'
}
end

def sort_for_koditsu(test_cases)
return [] if test_cases.empty?

mapped_test_cases = test_cases.map do |tc|
[tc.id, tc.identifier.split('/').last]
end

sorted_test_cases = mapped_test_cases.sort_by do |_, identifier|
parts = identifier.split('_')

[order_test_cases_type[parts[1]], parts[2].to_i]
end

sorted_test_cases.map { |id, _| id }.each_with_index.to_h { |id, index| [index + 1, id] }
end

# TODO: optimise this function to remove N+1 occasion
def process_submission(submission)
state = submission_status_hash[submission['status']]
submitted_at = calculate_submission_time(state, submission['questions'])
creator = find_creator(submission['user'])
course_user = find_course_user(creator)

cm_submission = find_or_create_submission(creator, course_user)

cm_submission.class.transaction do
update_submission(cm_submission, state, submitted_at)
process_submission_answers(submission, cm_submission)
end
end

def process_submission_answers(submission, cm_submission)
answers = Course::Assessment::Answer.
includes(:question).
where(submission_id: cm_submission.id)

@answer_hash = build_answer_hash(answers)

submission['questions'].each do |submission_answer|
next if ['notStarted', 'error'].include?(submission_answer['status'])

process_answer(submission_answer)
end
end

def process_answer(submission_answer)
question, answer = @answer_hash[submission_answer['questionId']]
update_files(submission_answer['files'], answer)
process_test_case_results(submission_answer, question, answer)
end

def calculate_submission_time(state, questions)
return nil unless state == 'submitted'

questions.map do |question|
DateTime.parse(question['filesSavedAt']).in_time_zone
end.max&.iso8601
end

def find_creator(user_info)
User.joins(:emails).
where(users: { name: user_info['name'] },
user_emails: { email: user_info['email'] }).
first
end

def find_course_user(creator)
CourseUser.find_by(course_id: current_course.id, user_id: creator.id)
end

def find_or_create_submission(creator, course_user)
cm_submission = Course::Assessment::Submission.find_by(assessment: @assessment, creator: creator)
return cm_submission if cm_submission

User.with_stamper(creator) do
new_submission = @assessment.submissions.new(creator: creator, course_user: course_user)
success = @assessment.create_new_submission(new_submission, course_user)

raise ActiveRecord::Rollback unless success

new_submission.create_new_answers
new_submission
end
end

def update_submission(cm_submission, state, submitted_at)
update_submission_object = { workflow_state: state, submitted_at: submitted_at }

User.with_stamper(@user) do
raise ActiveRecord::Rollback unless cm_submission.update!(update_submission_object)
end
end

def build_answer_hash(answers)
answers.to_h do |answer|
question = answer.question
koditsu_question_id = question.koditsu_question_id
[koditsu_question_id, [question, answer]]
end
end

def update_files(files, answer)
files&.each do |file|
programming_file = Course::Assessment::Answer::ProgrammingFile.
find_by(answer_id: answer.actable_id, filename: file['path'])

raise ActiveRecord::Rollback unless programming_file.update!(content: file['content'])
end
end

def process_test_case_results(submission_answer, question, answer)
test_case_results = submission_answer['exprTestCaseResults']
return if test_case_results&.empty?

autograding = recreate_autograding(answer)
auto_grading_id = autograding.id

is_answer_correct = test_case_results.all? { |tc| tc['result']['success'] }
update_answer_status(submission_answer, answer, is_answer_correct)
save_test_case_results(test_case_results, question, auto_grading_id)
end

def recreate_autograding(answer)
autograding_object = Course::Assessment::Answer::ProgrammingAutoGrading.find_by(answer: answer)
raise ActiveRecord::Rollback if autograding_object && !autograding_object&.destroy!

autograding = Course::Assessment::Answer::ProgrammingAutoGrading.new(answer: answer)
raise ActiveRecord::Rollback unless autograding.save!

autograding
end

def update_answer_status(submission_answer, answer, is_answer_correct)
raise ActiveRecord::Rollback unless submission_answer['status'] != 'submitted' || answer.update!(
workflow_state: 'submitted',
correct: is_answer_correct,
submitted_at: DateTime.parse(submission_answer['filesSavedAt']).in_time_zone&.iso8601 || Time.now.utc
)
end

def save_test_case_results(test_case_results, question, auto_grading_id)
index_id_hash = @test_cases_order[question.id]
test_case_results.each do |tc_result|
result = Course::Assessment::Answer::ProgrammingAutoGradingTestResult.new(
auto_grading_id: auto_grading_id,
test_case_id: index_id_hash[tc_result['testcase']['index']],
passed: tc_result['result']['success'],
messages: { output: tc_result['result']['display'] }
)
raise ActiveRecord::Rollback unless result.save!
end
end
end
# rubocop:enable Metrics/ModuleLength
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Course::Assessment::AssessmentsController < Course::Assessment::Controller

before_action :load_submissions, only: [:show]
after_action :create_koditsu_invitation_job, only: [:create, :update]
after_action :create_fetch_koditsu_submissions_job, only: [:create, :update]

include Course::Assessment::MonitoringConcern

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class Course::Assessment::Submission::SubmissionsController < \
include Signals::EmissionConcern
include Course::Assessment::Submission::MonitoringConcern
include Course::Assessment::SubmissionConcern
include Course::Assessment::Submission::KoditsuSubmissionsConcern

before_action :authorize_assessment!, only: :create
skip_authorize_resource :submission, only: [:edit, :update, :auto_grade]
Expand Down Expand Up @@ -151,6 +152,18 @@ def force_submit_all
end
end

def fetch_submissions_from_koditsu
authorize!(:fetch_submissions_from_koditsu, @assessment)

is_course_koditsu_enabled = current_course.component_enabled?(Course::KoditsuPlatformComponent)
is_assessment_koditsu_enabled = @assessment.koditsu_assessment_id && @assessment.is_koditsu_enabled
is_koditsu_enabled = is_course_koditsu_enabled && is_assessment_koditsu_enabled

fetch_all_submissions_from_koditsu(@assessment, current_user) if is_koditsu_enabled

head :ok
end

# Download either all of or a subset of submissions for an assessment.
def download_all
authorize!(:manage, @assessment)
Expand Down Expand Up @@ -248,7 +261,8 @@ def create_success_response(submission)
is_assessment_koditsu_enabled = @assessment.koditsu_assessment_id && @assessment.is_koditsu_enabled

if is_course_koditsu_enabled && is_assessment_koditsu_enabled
redirect_url = "https://code.codaveri.com?assessment=#{@assessment.koditsu_assessment_id}"
submission.create_new_answers
redirect_url = KoditsuAsyncApiService.assessment_url(@assessment.koditsu_assessment_id)
else
redirect_url = edit_course_assessment_submission_path(current_course, @assessment, submission)
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class Course::Assessment::Submission::FetchSubmissionsFromKoditsuJob <
ApplicationJob
include TrackableJob
include Rails.application.routes.url_helpers
include Course::Assessment::Submission::KoditsuSubmissionsConcern

protected

def perform_tracked(assessment_id, updated_at, user)
assessment = Course::Assessment.find_by(id: assessment_id)

is_koditsu = assessment.is_koditsu_enabled && assessment.koditsu_assessment_id
return unless is_koditsu && Time.zone.at(assessment.updated_at) == Time.zone.at(updated_at)

instance = Course.unscoped { assessment.course.instance }

ActsAsTenant.with_tenant(instance) do
fetch_all_submissions_from_koditsu(assessment, user)
end
end
end
5 changes: 5 additions & 0 deletions app/models/course/assessment/assessment_ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ def define_manager_assessment_permissions
allow_manager_publish_assessment_submission_grades
allow_manager_invite_users_to_koditsu
allow_manager_force_submit_assessment_submissions
allow_manager_fetch_submissions_from_koditsu
allow_manager_delete_assessment_submissions
allow_manager_update_assessment_answer
end
Expand All @@ -246,6 +247,10 @@ def allow_manager_force_submit_assessment_submissions
can :force_submit_assessment_submission, Course::Assessment, assessment_course_hash
end

def allow_manager_fetch_submissions_from_koditsu
can :fetch_submissions_from_koditsu, Course::Assessment, assessment_course_hash
end

# Only managers and above are allowed to delete assessment submissions
def allow_manager_delete_assessment_submissions
can :delete_all_submissions, Course::Assessment, assessment_course_hash
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true
class Course::Assessment::Submission::KoditsuSubmissionService
def initialize(assessment)
@assessment = assessment
end

def run_fetch_all_submissions
id = @assessment.koditsu_assessment_id
koditsu_api_service = KoditsuAsyncApiService.new("api/assessment/#{id}/submissions", nil)

response_status, response_body = koditsu_api_service.get

if [200, 207].include?(response_status)
[response_status, response_body['data']]
else
[response_status, nil]
end
end
end
8 changes: 7 additions & 1 deletion app/services/koditsu_async_api_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

class KoditsuAsyncApiService
def config
ENV.fetch('KODITSU_URL')
ENV.fetch('KODITSU_API_URL')
end

def initialize(api_namespace, payload)
Expand Down Expand Up @@ -78,6 +78,12 @@ def self.koditsu_language_whitelist
Coursemology::Polyglot::Language::Python::Python3Point12]
end

def self.assessment_url(assessment_id)
url = ENV['KODITSU_WEB_URL']

"#{url}?assessment=#{assessment_id}"
end

private

def parse_response(response)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ elsif cannot?(:access, assessment) && can_attempt
elsif attempting_submission.present?
status = 'attempting'
action_url = if is_course_koditsu_enabled && is_assessment_koditsu_enabled
"https://code.codaveri.com?assessment=#{assessment.koditsu_assessment_id}"
KoditsuAsyncApiService.assessment_url(assessment.koditsu_assessment_id)
else
edit_course_assessment_submission_path(current_course, assessment, attempting_submission)
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ json.assessment do
json.canForceSubmit can? :force_submit_assessment_submission, @assessment
json.canUnsubmitSubmission can? :update, @assessment
json.canDeleteAllSubmissions can? :delete_all_submissions, @assessment
json.isKoditsuEnabled current_course.component_enabled?(Course::KoditsuPlatformComponent) &&
@assessment.is_koditsu_enabled && @assessment.koditsu_assessment_id
end

my_students_set = Set.new(@my_students.map(&:id))
Expand Down
6 changes: 6 additions & 0 deletions client/app/api/course/Assessment/Submissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export default class SubmissionsAPI extends BaseAssessmentAPI {
});
}

fetchSubmissionsFromKoditsu() {
return this.client.patch(
`${this.#urlPrefix}/fetch_submissions_from_koditsu`,
);
}

unsubmit(submissionId) {
return this.client.patch(`${this.#urlPrefix}/${submissionId}/unsubmit`);
}
Expand Down
Loading

0 comments on commit 8551a6b

Please sign in to comment.