From 7fc0f4cc12013ba10513917f8caec5cee6c83488 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Fri, 15 Sep 2023 01:59:51 +0800 Subject: [PATCH 01/42] Add files via upload --- config/config.exs | 222 +++++++++++++++++++++++----------------------- 1 file changed, 112 insertions(+), 110 deletions(-) diff --git a/config/config.exs b/config/config.exs index 244fe1e2e..e2cf1be6a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,110 +1,112 @@ -# This file is responsible for configuring your application -# and its dependencies with the aid of the Config module. -# -# This configuration file is loaded before any dependency and -# is restricted to this project. -import Config - -config :cadet, environment: Mix.env() - -# General application configuration -config :cadet, - ecto_repos: [Cadet.Repo] - -config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase - -# Scheduler, e.g. for CS1101S -config :cadet, Cadet.Jobs.Scheduler, - timezone: "Asia/Singapore", - overlap: false, - jobs: [ - # Grade assessments that close in the previous day at 00:01 - {"1 0 * * *", {Cadet.Autograder.GradingJob, :grade_all_due_yesterday, []}}, - # Compute contest leaderboard that close in the previous day at 00:01 - {"1 0 * * *", {Cadet.Assessments, :update_final_contest_leaderboards, []}}, - # Compute rolling leaderboard every 2 hours - {"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}} - ] - -# Configures the endpoint -config :cadet, CadetWeb.Endpoint, - url: [host: "localhost"], - secret_key_base: "ueV6EWi+7MCMcJH/WZZVKPZbQxFix7tF1Xv9ajD4AN4jLowHbdUX33rmKWPvEEgz", - render_errors: [view: CadetWeb.ErrorView, accepts: ~w(json)], - pubsub_server: Cadet.PubSub - -# Set Phoenix JSON library -config :phoenix, :json_library, Jason -config :phoenix_swagger, json_library: Jason - -# Configures Elixir's Logger -config :logger, :console, - format: "$time $metadata[$level] $message\n", - metadata: [:request_id] - -# Configure ExAWS -config :ex_aws, - access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, {:awscli, "default", 30}, :instance_role], - secret_access_key: [ - {:system, "AWS_SECRET_ACCESS_KEY"}, - {:awscli, "default", 30}, - :instance_role - ], - region: "ap-southeast-1", - s3: [ - scheme: "https://", - host: "s3.ap-southeast-1.amazonaws.com", - region: "ap-southeast-1" - ] - -config :ex_aws, :hackney_opts, recv_timeout: 660_000 - -# Configure Arc File Upload -config :arc, virtual_host: true -# Or uncomment below to use local storage -# config :arc, storage: Arc.Storage.Local - -# Configures Sentry -config :sentry, - included_environments: [:prod], - environment_name: Mix.env(), - enable_source_code_context: true, - root_source_code_path: File.cwd!(), - context_lines: 5 - -# Configure Phoenix Swagger -config :cadet, :phoenix_swagger, - swagger_files: %{ - "priv/static/swagger.json" => [ - router: CadetWeb.Router - ] - } - -# Configure GuardianDB -config :guardian, Guardian.DB, - repo: Cadet.Repo, - # default - schema_name: "guardian_tokens", - # store all token types if not set - token_types: ["refresh"], - # default: 60 minute - sweep_interval: 180 - -config :cadet, Oban, - repo: Cadet.Repo, - plugins: [ - # keep - {Oban.Plugins.Pruner, max_age: 60}, - {Oban.Plugins.Cron, - crontab: [ - {"@daily", Cadet.Workers.NotificationWorker, - args: %{"notification_type" => "avenger_backlog"}} - ]} - ], - queues: [default: 10, notifications: 1] - -config :cadet, Cadet.Mailer, adapter: Bamboo.LocalAdapter - -# Import environment specific config. This must remain at the bottom -# of this file so it overrides the configuration defined above. -import_config "#{Mix.env()}.exs" +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. +import Config + +config :cadet, environment: Mix.env() + +# General application configuration +config :cadet, + ecto_repos: [Cadet.Repo] + +config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase + +# Scheduler, e.g. for CS1101S +config :cadet, Cadet.Jobs.Scheduler, + timezone: "Asia/Singapore", + overlap: false, + jobs: [ + # Grade assessments that close in the previous day at 00:01 + {"1 0 * * *", {Cadet.Autograder.GradingJob, :grade_all_due_yesterday, []}}, + # Compute contest leaderboard that close in the previous day at 00:01 + {"1 0 * * *", {Cadet.Assessments, :update_final_contest_leaderboards, []}}, + # Compute rolling leaderboard every 2 hours + {"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}}, + # Collate contest entries that close in the previous day at 00:01 + {"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}} + ] + +# Configures the endpoint +config :cadet, CadetWeb.Endpoint, + url: [host: "localhost"], + secret_key_base: "ueV6EWi+7MCMcJH/WZZVKPZbQxFix7tF1Xv9ajD4AN4jLowHbdUX33rmKWPvEEgz", + render_errors: [view: CadetWeb.ErrorView, accepts: ~w(json)], + pubsub_server: Cadet.PubSub + +# Set Phoenix JSON library +config :phoenix, :json_library, Jason +config :phoenix_swagger, json_library: Jason + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Configure ExAWS +config :ex_aws, + access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, {:awscli, "default", 30}, :instance_role], + secret_access_key: [ + {:system, "AWS_SECRET_ACCESS_KEY"}, + {:awscli, "default", 30}, + :instance_role + ], + region: "ap-southeast-1", + s3: [ + scheme: "https://", + host: "s3.ap-southeast-1.amazonaws.com", + region: "ap-southeast-1" + ] + +config :ex_aws, :hackney_opts, recv_timeout: 660_000 + +# Configure Arc File Upload +config :arc, virtual_host: true +# Or uncomment below to use local storage +# config :arc, storage: Arc.Storage.Local + +# Configures Sentry +config :sentry, + included_environments: [:prod], + environment_name: Mix.env(), + enable_source_code_context: true, + root_source_code_path: File.cwd!(), + context_lines: 5 + +# Configure Phoenix Swagger +config :cadet, :phoenix_swagger, + swagger_files: %{ + "priv/static/swagger.json" => [ + router: CadetWeb.Router + ] + } + +# Configure GuardianDB +config :guardian, Guardian.DB, + repo: Cadet.Repo, + # default + schema_name: "guardian_tokens", + # store all token types if not set + token_types: ["refresh"], + # default: 60 minute + sweep_interval: 180 + +config :cadet, Oban, + repo: Cadet.Repo, + plugins: [ + # keep + {Oban.Plugins.Pruner, max_age: 60}, + {Oban.Plugins.Cron, + crontab: [ + {"@daily", Cadet.Workers.NotificationWorker, + args: %{"notification_type" => "avenger_backlog"}} + ]} + ], + queues: [default: 10, notifications: 1] + +config :cadet, Cadet.Mailer, adapter: Bamboo.LocalAdapter + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{Mix.env()}.exs" From cf813e1ec129af71a0628e00d02c43a45c4dd19e Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Fri, 15 Sep 2023 02:00:42 +0800 Subject: [PATCH 02/42] Add files via upload --- lib/cadet/assessments/assessments.ex | 3365 +++++++++++++------------- 1 file changed, 1691 insertions(+), 1674 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 1fa79ebc2..b160c5469 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1,1674 +1,1691 @@ -defmodule Cadet.Assessments do - @moduledoc """ - Assessments context contains domain logic for assessments management such as - missions, sidequests, paths, etc. - """ - use Cadet, [:context, :display] - import Ecto.Query - - require Logger - - alias Cadet.Accounts.{ - Notification, - Notifications, - User, - CourseRegistration, - CourseRegistrations - } - - alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission, SubmissionVotes} - alias Cadet.Autograder.GradingJob - alias Cadet.Courses.{Group, AssessmentConfig} - alias Cadet.Jobs.Log - alias Cadet.ProgramAnalysis.Lexer - alias Ecto.Multi - alias Cadet.Incentives.Achievements - - require Decimal - - @open_all_assessment_roles ~w(staff admin)a - - # These roles can save and finalise answers for closed assessments and - # submitted answers - @bypass_closed_roles ~w(staff admin)a - - def delete_assessment(id) do - assessment = Repo.get(Assessment, id) - - Submission - |> where(assessment_id: ^id) - |> delete_submission_assocation(id) - - Question - |> where(assessment_id: ^id) - |> Repo.all() - |> Enum.each(fn q -> - delete_submission_votes_association(q) - end) - - Repo.delete(assessment) - end - - defp delete_submission_votes_association(question) do - SubmissionVotes - |> where(question_id: ^question.id) - |> Repo.delete_all() - end - - defp delete_submission_assocation(submissions, assessment_id) do - submissions - |> Repo.all() - |> Enum.each(fn submission -> - Answer - |> where(submission_id: ^submission.id) - |> Repo.delete_all() - end) - - Notification - |> where(assessment_id: ^assessment_id) - |> Repo.delete_all() - - Repo.delete_all(submissions) - end - - @spec user_max_xp(CourseRegistration.t()) :: integer() - def user_max_xp(%CourseRegistration{id: cr_id}) do - Submission - |> where(status: ^:submitted) - |> where(student_id: ^cr_id) - |> join( - :inner, - [s], - a in subquery(Query.all_assessments_with_max_xp()), - on: s.assessment_id == a.id - ) - |> select([_, a], sum(a.max_xp)) - |> Repo.one() - |> decimal_to_integer() - end - - def assessments_total_xp(%CourseRegistration{id: cr_id}) do - submission_xp = - Submission - |> where(student_id: ^cr_id) - |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) - |> group_by([s], s.id) - |> select([s, a], %{ - # grouping by submission, so s.xp_bonus will be the same, but we need an - # aggregate function - total_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus) - }) - - total = - submission_xp - |> subquery - |> select([s], %{ - total_xp: sum(s.total_xp) - }) - |> Repo.one() - - # for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)} - decimal_to_integer(total.total_xp) - end - - def user_total_xp(course_id, user_id, course_reg_id) do - user_course = CourseRegistrations.get_user_course(user_id, course_id) - - total_achievement_xp = Achievements.achievements_total_xp(course_id, course_reg_id) - total_assessment_xp = assessments_total_xp(user_course) - - total_achievement_xp + total_assessment_xp - end - - defp decimal_to_integer(decimal) do - if Decimal.is_decimal(decimal) do - Decimal.to_integer(decimal) - else - 0 - end - end - - def user_current_story(cr = %CourseRegistration{}) do - {:ok, %{result: story}} = - Multi.new() - |> Multi.run(:unattempted, fn _repo, _ -> - {:ok, get_user_story_by_type(cr, :unattempted)} - end) - |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} -> - if unattempted_story do - {:ok, %{play_story?: true, story: unattempted_story}} - else - {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}} - end - end) - |> Repo.transaction() - - story - end - - @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) :: - String.t() | nil - def get_user_story_by_type(%CourseRegistration{id: cr_id}, type) - when is_atom(type) do - filter_and_sort = fn query -> - case type do - :unattempted -> - query - |> where([_, s], is_nil(s.id)) - |> order_by([a], asc: a.open_at) - - :attempted -> - query |> order_by([a], desc: a.close_at) - end - end - - Assessment - |> where(is_published: true) - |> where([a], not is_nil(a.story)) - |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second")) - |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id) - |> filter_and_sort.() - |> order_by([a], a.config_id) - |> select([a], a.story) - |> first() - |> Repo.one() - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: nil}, - cr = %CourseRegistration{}, - nil - ) do - assessment_with_questions_and_answers(assessment, cr) - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: nil}, - cr = %CourseRegistration{}, - _ - ) do - assessment_with_questions_and_answers(assessment, cr) - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: password}, - cr = %CourseRegistration{}, - given_password - ) do - cond do - Timex.compare(Timex.now(), assessment.close_at) >= 0 -> - assessment_with_questions_and_answers(assessment, cr) - - match?({:ok, _}, find_submission(cr, assessment)) -> - assessment_with_questions_and_answers(assessment, cr) - - given_password == nil -> - {:error, {:forbidden, "Missing Password."}} - - password == given_password -> - find_or_create_submission(cr, assessment) - assessment_with_questions_and_answers(assessment, cr) - - true -> - {:error, {:forbidden, "Invalid Password."}} - end - end - - def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password) - when is_ecto_id(id) do - role = cr.role - - assessment = - if role in @open_all_assessment_roles do - Assessment - |> where(id: ^id) - |> preload(:config) - |> Repo.one() - else - Assessment - |> where(id: ^id) - |> where(is_published: true) - |> preload(:config) - |> Repo.one() - end - - if assessment do - assessment_with_questions_and_answers(assessment, cr, password) - else - {:error, {:bad_request, "Assessment not found"}} - end - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{id: id}, - course_reg = %CourseRegistration{role: role} - ) do - if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do - answer_query = - Answer - |> join(:inner, [a], s in assoc(a, :submission)) - |> where([_, s], s.student_id == ^course_reg.id) - - questions = - Question - |> where(assessment_id: ^id) - |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id) - |> join(:left, [_, a], g in assoc(a, :grader)) - |> join(:left, [_, _, g], u in assoc(g, :user)) - |> select([q, a, g, u], {q, a, g, u}) - |> order_by(:display_order) - |> Repo.all() - |> Enum.map(fn - {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}} - {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}} - {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}} - end) - |> load_contest_voting_entries(course_reg, assessment) - - assessment = assessment |> Map.put(:questions, questions) - {:ok, assessment} - else - {:error, {:forbidden, "Assessment not open"}} - end - end - - def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do - assessment_with_questions_and_answers(id, cr, nil) - end - - @doc """ - Returns a list of assessments with all fields and an indicator showing whether it has been attempted - by the supplied user - """ - def all_assessments(cr = %CourseRegistration{}) do - submission_aggregates = - Submission - |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) - |> where([s], s.student_id == ^cr.id) - |> group_by([s], s.assessment_id) - |> select([s, ans], %{ - assessment_id: s.assessment_id, - # s.xp_bonus should be the same across the group, but we need an aggregate function here - xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)), - graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id)) - }) - - submission_status = - Submission - |> where([s], s.student_id == ^cr.id) - |> select([s], [:assessment_id, :status]) - - assessments = - cr.course_id - |> Query.all_assessments_with_aggregates() - |> subquery() - |> join( - :left, - [a], - sa in subquery(submission_aggregates), - on: a.id == sa.assessment_id - ) - |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id) - |> select([a, sa, s], %{ - a - | xp: sa.xp, - graded_count: sa.graded_count, - user_status: s.status - }) - |> filter_published_assessments(cr) - |> order_by(:open_at) - |> preload(:config) - |> Repo.all() - - {:ok, assessments} - end - - def filter_published_assessments(assessments, cr) do - role = cr.role - - case role do - :student -> where(assessments, is_published: true) - _ -> assessments - end - end - - def create_assessment(params) do - %Assessment{} - |> Assessment.changeset(params) - |> Repo.insert() - end - - @doc """ - The main function that inserts or updates assessments from the XML Parser - """ - @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) :: - {:ok, any()} - | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}} - def insert_or_update_assessments_and_questions( - assessment_params, - questions_params, - force_update - ) do - assessment_multi = - Multi.insert_or_update( - Multi.new(), - :assessment, - insert_or_update_assessment_changeset(assessment_params, force_update) - ) - - if force_update and invalid_force_update(assessment_multi, questions_params) do - {:error, "Question count is different"} - else - questions_params - |> Enum.with_index(1) - |> Enum.reduce(assessment_multi, fn {question_params, index}, multi -> - Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} -> - question = - Question - |> where([q], q.display_order == ^index and q.assessment_id == ^id) - |> Repo.one() - - # the is_nil(question) check allows for force updating of brand new assessments - if !force_update or is_nil(question) do - {status, new_question} = - question_params - |> Map.put(:display_order, index) - |> build_question_changeset_for_assessment_id(id) - |> Repo.insert() - - if status == :ok and new_question.type == :voting do - insert_voting( - assessment_params.course_id, - question_params.question.contest_number, - new_question.id - ) - else - {status, new_question} - end - else - params = - question_params - |> Map.put_new(:max_xp, 0) - |> Map.put(:display_order, index) - - if question_params.type != Atom.to_string(question.type) do - {:error, - create_invalid_changeset_with_error( - :question, - "Question types should remain the same" - )} - else - question - |> Question.changeset(params) - |> Repo.update() - end - end - end) - end) - |> Repo.transaction() - end - end - - # Function that checks if the force update is invalid. The force update is only invalid - # if the new question count is different from the old question count. - defp invalid_force_update(assessment_multi, questions_params) do - assessment_id = - (assessment_multi.operations - |> List.first() - |> elem(1) - |> elem(1)).data.id - - if assessment_id do - open_date = Repo.get(Assessment, assessment_id).open_at - # check if assessment is already opened - if Timex.compare(open_date, Timex.now()) >= 0 do - false - else - existing_questions_count = - Question - |> where([q], q.assessment_id == ^assessment_id) - |> Repo.all() - |> Enum.count() - - new_questions_count = Enum.count(questions_params) - existing_questions_count != new_questions_count - end - else - false - end - end - - @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t() - defp insert_or_update_assessment_changeset( - params = %{number: number, course_id: course_id}, - force_update - ) do - Assessment - |> where(number: ^number) - |> where(course_id: ^course_id) - |> Repo.one() - |> case do - nil -> - Assessment.changeset(%Assessment{}, params) - - %{id: assessment_id} = assessment -> - answers_exist = - Answer - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [a, q], asst in assoc(q, :assessment)) - |> where([a, q, asst], asst.id == ^assessment_id) - |> Repo.exists?() - - # Maintain the same open/close date when updating an assessment - params = - params - |> Map.delete(:open_at) - |> Map.delete(:close_at) - |> Map.delete(:is_published) - - cond do - not answers_exist -> - # Delete all realted submission_votes - SubmissionVotes - |> join(:inner, [sv, q], q in assoc(sv, :question)) - |> where([sv, q], q.assessment_id == ^assessment_id) - |> Repo.delete_all() - - # Delete all existing questions - Question - |> where(assessment_id: ^assessment_id) - |> Repo.delete_all() - - Assessment.changeset(assessment, params) - - force_update -> - Assessment.changeset(assessment, params) - - true -> - # if the assessment has submissions, don't edit - create_invalid_changeset_with_error(:assessment, "has submissions") - end - end - end - - @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) :: - Ecto.Changeset.t() - defp build_question_changeset_for_assessment_id(params, assessment_id) - when is_ecto_id(assessment_id) do - params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id) - - Question.changeset(%Question{}, params_with_assessment_id) - end - - @doc """ - Generates and assigns contest entries for users with given usernames. - """ - def insert_voting( - course_id, - contest_number, - question_id - ) do - contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id) - - if is_nil(contest_assessment) do - changeset = change(%Assessment{}, %{number: ""}) - - error_changeset = - Ecto.Changeset.add_error( - changeset, - :number, - "invalid contest number" - ) - - {:error, error_changeset} - else - # Returns contest submission ids with answers that contain "return" - contest_submission_ids = - Submission - |> join(:inner, [s], ans in assoc(s, :answers)) - |> join(:inner, [s, ans], cr in assoc(s, :student)) - |> where([s, ans, cr], cr.role == "student") - |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") - |> where( - [_, ans, cr], - fragment( - "?->>'code' like ?", - ans.answer, - "%return%" - ) - ) - |> select([s, _ans], {s.student_id, s.id}) - |> Repo.all() - |> Enum.into(%{}) - - contest_submission_ids_length = Enum.count(contest_submission_ids) - - voter_ids = - CourseRegistration - |> where(role: "student", course_id: ^course_id) - |> select([cr], cr.id) - |> Repo.all() - - votes_per_user = min(contest_submission_ids_length, 10) - - votes_per_submission = - if Enum.empty?(contest_submission_ids) do - 0 - else - trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) - end - - submission_id_list = - contest_submission_ids - |> Enum.map(fn {_, s_id} -> s_id end) - |> Enum.shuffle() - |> List.duplicate(votes_per_submission) - |> List.flatten() - - {_submission_map, submission_votes_changesets} = - voter_ids - |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> - {submission_list, submission_votes} = acc - - user_contest_submission_id = Map.get(contest_submission_ids, voter_id) - - {votes, rest} = - submission_list - |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> - {user_votes, submissions} = acc - - max_votes = - if votes_per_user == contest_submission_ids_length and - not is_nil(user_contest_submission_id) do - # no. of submssions is less than 10. Unable to find - votes_per_user - 1 - else - votes_per_user - end - - if MapSet.size(user_votes) < max_votes do - if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do - new_user_votes = MapSet.put(user_votes, s_id) - new_submissions = List.delete(submissions, s_id) - {:cont, {new_user_votes, new_submissions}} - else - {:cont, {user_votes, submissions}} - end - else - {:halt, acc} - end - end) - - votes = MapSet.to_list(votes) - - new_submission_votes = - votes - |> Enum.map(fn s_id -> - %SubmissionVotes{voter_id: voter_id, submission_id: s_id, question_id: question_id} - end) - |> Enum.concat(submission_votes) - - {rest, new_submission_votes} - end) - - submission_votes_changesets - |> Enum.with_index() - |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> - Multi.insert(multi, Integer.to_string(index), changeset) - end) - |> Repo.transaction() - end - end - - def update_assessment(id, params) when is_ecto_id(id) do - simple_update( - Assessment, - id, - using: &Assessment.changeset/2, - params: params - ) - end - - def update_question(id, params) when is_ecto_id(id) do - simple_update( - Question, - id, - using: &Question.changeset/2, - params: params - ) - end - - def publish_assessment(id) when is_ecto_id(id) do - update_assessment(id, %{is_published: true}) - end - - def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do - assessment = - Assessment - |> where(id: ^assessment_id) - |> join(:left, [a], q in assoc(a, :questions)) - |> preload([_, q], questions: q) - |> Repo.one() - - if assessment do - params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id) - - %Question{} - |> Question.changeset(params_with_assessment_id) - |> put_display_order(assessment.questions) - |> Repo.insert() - else - {:error, "Assessment not found"} - end - end - - def get_question(id) when is_ecto_id(id) do - Question - |> where(id: ^id) - |> join(:inner, [q], assessment in assoc(q, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def delete_question(id) when is_ecto_id(id) do - question = Repo.get(Question, id) - Repo.delete(question) - end - - @doc """ - Public internal api to submit new answers for a question. Possible return values are: - `{:ok, nil}` -> success - `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` - - Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: - `{:bad_request, "Missing or invalid parameter(s)"}` - - """ - def answer_question( - question = %Question{}, - cr = %CourseRegistration{id: cr_id}, - raw_answer, - force_submit - ) do - with {:ok, submission} <- find_or_create_submission(cr, question.assessment), - {:status, true} <- {:status, force_submit or submission.status != :submitted}, - {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do - update_submission_status_router(submission, question) - - {:ok, nil} - else - {:status, _} -> - {:error, {:forbidden, "Assessment submission already finalised"}} - - {:error, :race_condition} -> - {:error, {:internal_server_error, "Please try again later."}} - - {:error, :invalid_vote} -> - {:error, {:bad_request, "Invalid vote! Vote is not saved."}} - - _ -> - {:error, {:bad_request, "Missing or invalid parameter(s)"}} - end - end - - def get_submission(assessment_id, %CourseRegistration{id: cr_id}) - when is_ecto_id(assessment_id) do - Submission - |> where(assessment_id: ^assessment_id) - |> where(student_id: ^cr_id) - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do - Submission - |> where(id: ^submission_id) - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def finalise_submission(submission = %Submission{}) do - with {:status, :attempted} <- {:status, submission.status}, - {:ok, updated_submission} <- update_submission_status_and_xp_bonus(submission) do - # Couple with update_submission_status_and_xp_bonus to ensure notification is sent - Notifications.write_notification_when_student_submits(submission) - # Send email notification to avenger - %{notification_type: "assessment_submission", submission_id: updated_submission.id} - |> Cadet.Workers.NotificationWorker.new() - |> Oban.insert() - - # Begin autograding job - GradingJob.force_grade_individual_submission(updated_submission) - - {:ok, nil} - else - {:status, :attempting} -> - {:error, {:bad_request, "Some questions have not been attempted"}} - - {:status, :submitted} -> - {:error, {:forbidden, "Assessment has already been submitted"}} - - _ -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - def unsubmit_submission( - submission_id, - cr = %CourseRegistration{id: course_reg_id, role: role} - ) - when is_ecto_id(submission_id) do - submission = - Submission - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.get(submission_id) - - # allows staff to unsubmit own assessment - bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id - - with {:submission_found?, true} <- {:submission_found?, is_map(submission)}, - {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)}, - {:status, :submitted} <- {:status, submission.status}, - {:allowed_to_unsubmit?, true} <- - {:allowed_to_unsubmit?, - role == :admin or bypass or - Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do - Multi.new() - |> Multi.run( - :rollback_submission, - fn _repo, _ -> - submission - |> Submission.changeset(%{ - status: :attempted, - xp_bonus: 0, - unsubmitted_by_id: course_reg_id, - unsubmitted_at: Timex.now() - }) - |> Repo.update() - end - ) - |> Multi.run(:rollback_answers, fn _repo, _ -> - Answer - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [a, _], s in assoc(a, :submission)) - |> preload([_, q, s], question: q, submission: s) - |> where(submission_id: ^submission.id) - |> Repo.all() - |> Enum.reduce_while({:ok, nil}, fn answer, acc -> - case acc do - {:error, _} -> - {:halt, acc} - - {:ok, _} -> - {:cont, - answer - |> Answer.grading_changeset(%{ - xp: 0, - xp_adjustment: 0, - autograding_status: :none, - autograding_results: [] - }) - |> Repo.update()} - end - end) - end) - |> Repo.transaction() - - Cadet.Accounts.Notifications.handle_unsubmit_notifications( - submission.assessment.id, - Repo.get(CourseRegistration, submission.student_id) - ) - - {:ok, nil} - else - {:submission_found?, false} -> - {:error, {:not_found, "Submission not found"}} - - {:is_open?, false} -> - {:error, {:forbidden, "Assessment not open"}} - - {:status, :attempting} -> - {:error, {:bad_request, "Some questions have not been attempted"}} - - {:status, :attempted} -> - {:error, {:bad_request, "Assessment has not been submitted"}} - - {:allowed_to_unsubmit?, false} -> - {:error, {:forbidden, "Only Avenger of student or Admin is permitted to unsubmit"}} - - _ -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - @spec update_submission_status_and_xp_bonus(Submission.t()) :: - {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} - defp update_submission_status_and_xp_bonus(submission = %Submission{}) do - assessment = submission.assessment - assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id) - - max_bonus_xp = assessment_conifg.early_submission_xp - early_hours = assessment_conifg.hours_before_early_xp_decay - - xp_bonus = - if Timex.before?(Timex.now(), Timex.shift(assessment.open_at, hours: early_hours)) do - max_bonus_xp - else - # This logic interpolates from max bonus at early hour to 0 bonus at close time - decaying_hours = Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours - remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, Timex.now(), :hours)]) - proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1) - bonus_xp = round(max_bonus_xp * proportion) - Enum.max([0, bonus_xp]) - end - - submission - |> Submission.changeset(%{status: :submitted, xp_bonus: xp_bonus}) - |> Repo.update() - end - - defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do - case question.type do - :voting -> update_contest_voting_submission_status(submission, question) - :mcq -> update_submission_status(submission, question.assessment) - :programming -> update_submission_status(submission, question.assessment) - end - end - - defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do - model_assoc_count = fn model, assoc, id -> - model - |> where(id: ^id) - |> join(:inner, [m], a in assoc(m, ^assoc)) - |> select([_, a], count(a.id)) - |> Repo.one() - end - - Multi.new() - |> Multi.run(:assessment, fn _repo, _ -> - {:ok, model_assoc_count.(Assessment, :questions, assessment.id)} - end) - |> Multi.run(:submission, fn _repo, _ -> - {:ok, model_assoc_count.(Submission, :answers, submission.id)} - end) - |> Multi.run(:update, fn _repo, %{submission: s_count, assessment: a_count} -> - if s_count == a_count do - submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() - else - {:ok, nil} - end - end) - |> Repo.transaction() - end - - defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do - has_nil_entries = - SubmissionVotes - |> where(question_id: ^question.id) - |> where(voter_id: ^submission.student_id) - |> where([sv], is_nil(sv.score)) - |> Repo.exists?() - - unless has_nil_entries do - submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() - end - end - - defp load_contest_voting_entries( - questions, - %CourseRegistration{role: role, course_id: course_id, id: voter_id}, - assessment - ) do - Enum.map( - questions, - fn q -> - if q.type == :voting do - submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id) - # fetch top 10 contest voting entries with the contest question id - question_id = fetch_associated_contest_question_id(course_id, q) - - leaderboard_results = - if is_nil(question_id) do - [] - else - if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do - fetch_top_relative_score_answers(question_id, 10) - else - [] - end - end - - # populate entries to vote for and leaderboard data into the question - voting_question = - q.question - |> Map.put(:contest_entries, submission_votes) - |> Map.put( - :contest_leaderboard, - leaderboard_results - ) - - Map.put(q, :question, voting_question) - else - q - end - end - ) - end - - defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do - SubmissionVotes - |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id) - |> join(:inner, [v], s in assoc(v, :submission)) - |> join(:inner, [v, s], a in assoc(s, :answers)) - |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score}) - |> Repo.all() - end - - # Finds the contest_question_id associated with the given voting_question id - defp fetch_associated_contest_question_id(course_id, voting_question) do - contest_number = voting_question.question["contest_number"] - - if is_nil(contest_number) do - nil - else - Assessment - |> where(number: ^contest_number, course_id: ^course_id) - |> join(:inner, [a], q in assoc(a, :questions)) - |> order_by([a, q], q.display_order) - |> select([a, q], q.id) - |> Repo.one() - end - end - - defp leaderboard_open?(assessment, voting_question) do - Timex.before?( - Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]), - Timex.now() - ) - end - - @doc """ - Fetches top answers for the given question, based on the contest relative_score - - Used for contest leaderboard fetching - """ - def fetch_top_relative_score_answers(question_id, number_of_answers) do - Answer - |> where(question_id: ^question_id) - |> where( - [a], - fragment( - "?->>'code' like ?", - a.answer, - "%return%" - ) - ) - |> order_by(desc: :relative_score) - |> join(:left, [a], s in assoc(a, :submission)) - |> join(:left, [a, s], student in assoc(s, :student)) - |> join(:inner, [a, s, student], student_user in assoc(student, :user)) - |> where([a, s, student], student.role == "student") - |> select([a, s, student, student_user], %{ - submission_id: a.submission_id, - answer: a.answer, - relative_score: a.relative_score, - student_name: student_user.name - }) - |> limit(^number_of_answers) - |> Repo.all() - end - - @doc """ - Computes rolling leaderboard for contest votes that are still open. - """ - def update_rolling_contest_leaderboards do - # 115 = 2 hours - 5 minutes is default. - if Log.log_execution("update_rolling_contest_leaderboards", Timex.Duration.from_minutes(115)) do - Logger.info("Started update_rolling_contest_leaderboards") - - voting_questions_to_update = fetch_active_voting_questions() - - _ = - voting_questions_to_update - |> Enum.map(fn qn -> compute_relative_score(qn.id) end) - - Logger.info("Successfully update_rolling_contest_leaderboards") - end - end - - def fetch_active_voting_questions do - Question - |> join(:left, [q], a in assoc(q, :assessment)) - |> where([q, a], q.type == "voting") - |> where([q, a], a.is_published) - |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now()) - |> Repo.all() - end - - @doc """ - Computes final leaderboard for contest votes that have closed. - """ - def update_final_contest_leaderboards do - # 1435 = 24 hours - 5 minutes - if Log.log_execution("update_final_contest_leaderboards", Timex.Duration.from_minutes(1435)) do - Logger.info("Started update_final_contest_leaderboards") - - voting_questions_to_update = fetch_voting_questions_due_yesterday() - - _ = - voting_questions_to_update - |> Enum.map(fn qn -> compute_relative_score(qn.id) end) - - Logger.info("Successfully update_final_contest_leaderboards") - end - end - - def fetch_voting_questions_due_yesterday do - Question - |> join(:left, [q], a in assoc(q, :assessment)) - |> where([q, a], q.type == "voting") - |> where([q, a], a.is_published) - |> where([q, a], a.open_at <= ^Timex.now()) - |> where( - [q, a], - a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1) - ) - |> Repo.all() - end - - @doc """ - Computes the current relative_score of each voting submission answer - based on current submitted votes. - """ - def compute_relative_score(contest_voting_question_id) do - # query all records from submission votes tied to the question id -> - # map score to user id -> - # store as grade -> - # query grade for contest question id. - eligible_votes = - SubmissionVotes - |> where(question_id: ^contest_voting_question_id) - |> where([sv], not is_nil(sv.score)) - |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id) - |> select( - [sv, ans], - %{ans_id: ans.id, score: sv.score, ans: ans.answer["code"]} - ) - |> Repo.all() - - entry_scores = map_eligible_votes_to_entry_score(eligible_votes) - - entry_scores - |> Enum.map(fn {ans_id, relative_score} -> - %Answer{id: ans_id} - |> Answer.contest_score_update_changeset(%{ - relative_score: relative_score - }) - end) - |> Enum.map(fn changeset -> - op_key = "answer_#{changeset.data.id}" - Multi.update(Multi.new(), op_key, changeset) - end) - |> Enum.reduce(Multi.new(), &Multi.append/2) - |> Repo.transaction() - end - - defp map_eligible_votes_to_entry_score(eligible_votes) do - # converts eligible votes to the {total cumulative score, number of votes, tokens} - entry_vote_data = - Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> - {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0}) - - Map.put( - tracker, - ans_id, - # assume each voter is assigned 10 entries which will make it fair. - {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)} - ) - end) - - # calculate the score based on formula {ans_id, score} - Enum.map( - entry_vote_data, - fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> - {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens)} - end - ) - end - - # Calculate the score based on formula - # score(v,t) = v - 2^(t/50) where v is the normalized_voting_score - # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do - normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - normalized_voting_score - :math.pow(2, min(1023.5, tokens / 50)) - end - - @doc """ - Function returning submissions under a grader. This function returns only the - fields that are exposed in the /grading endpoint. The reason we select only - those fields is to reduce the memory usage especially when the number of - submissions is large i.e. > 25000 submissions. - - The input parameters are the user and group_only. group_only is used to check - whether only the groups under the grader should be returned. The parameter is - a boolean which is false by default. - - The return value is {:ok, submissions} if no errors, else it is {:error, - {:forbidden, "Forbidden."}} - """ - @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: - {:ok, String.t()} - def all_submissions_by_grader_for_index( - grader = %CourseRegistration{course_id: course_id}, - group_only \\ false, - ungraded_only \\ false - ) do - show_all = not group_only - - group_where = - if show_all, - do: "", - else: - "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" - - ungraded_where = - if ungraded_only, - do: "where s.\"gradedCount\" < assts.\"questionCount\"", - else: "" - - params = if show_all, do: [course_id], else: [course_id, grader.id] - - # We bypass Ecto here and use a raw query to generate JSON directly from - # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. - - case Repo.query( - """ - select json_agg(q)::TEXT from - ( - select - s.id, - s.status, - s."unsubmittedAt", - s.xp, - s."xpAdjustment", - s."xpBonus", - s."gradedCount", - assts.jsn as assessment, - students.jsn as student, - unsubmitters.jsn as "unsubmittedBy" - from - (select - s.id, - s.student_id, - s.assessment_id, - s.status, - s.unsubmitted_at as "unsubmittedAt", - s.unsubmitted_by_id, - sum(ans.xp) as xp, - sum(ans.xp_adjustment) as "xpAdjustment", - s.xp_bonus as "xpBonus", - count(ans.id) filter (where ans.grader_id is not null) as "gradedCount" - from submissions s - left join - answers ans on s.id = ans.submission_id - #{group_where} - group by s.id) s - inner join - (select - a.id, a."questionCount", to_json(a) as jsn - from - (select - a.id, - a.title, - a.number as "assessmentNumber", - bool_or(ac.is_manually_graded) as "isManuallyGraded", - max(ac.type) as "type", - sum(q.max_xp) as "maxXp", - count(q.id) as "questionCount" - from assessments a - left join - questions q on a.id = q.assessment_id - inner join - assessment_configs ac on ac.id = a.config_id - where a.course_id = $1 - group by a.id) a) assts on assts.id = s.assessment_id - inner join - (select - cr.id, to_json(cr) as jsn - from - (select - cr.id, - u.name as "name", - u.username as "username", - g.name as "groupName", - g.leader_id as "groupLeaderId" - from course_registrations cr - left join - groups g on g.id = cr.group_id - inner join - users u on u.id = cr.user_id) cr) students on students.id = s.student_id - left join - (select - cr.id, to_json(cr) as jsn - from - (select - cr.id, - u.name - from course_registrations cr - inner join - users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id - #{ungraded_where} - ) q - """, - params - ) do - {:ok, %{rows: [[nil]]}} -> {:ok, "[]"} - {:ok, %{rows: [[json]]}} -> {:ok, json} - end - end - - @spec get_answers_in_submission(integer() | String.t()) :: - {:ok, [Answer.t()]} | {:error, {:bad_request, String.t()}} - def get_answers_in_submission(id) when is_ecto_id(id) do - answer_query = - Answer - |> where(submission_id: ^id) - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [_, q], ast in assoc(q, :assessment)) - |> join(:inner, [a, ..., ast], ac in assoc(ast, :config)) - |> join(:left, [a, ...], g in assoc(a, :grader)) - |> join(:left, [a, ..., g], gu in assoc(g, :user)) - |> join(:inner, [a, ...], s in assoc(a, :submission)) - |> join(:inner, [a, ..., s], st in assoc(s, :student)) - |> join(:inner, [a, ..., st], u in assoc(st, :user)) - |> preload([_, q, ast, ac, g, gu, s, st, u], - question: {q, assessment: {ast, config: ac}}, - grader: {g, user: gu}, - submission: {s, student: {st, user: u}} - ) - - answers = - answer_query - |> Repo.all() - |> Enum.sort_by(& &1.question.display_order) - |> Enum.map(fn ans -> - if ans.question.type == :voting do - empty_contest_entries = Map.put(ans.question.question, :contest_entries, []) - empty_contest_leaderboard = Map.put(empty_contest_entries, :contest_leaderboard, []) - question = Map.put(ans.question, :question, empty_contest_leaderboard) - Map.put(ans, :question, question) - else - ans - end - end) - - if answers == [] do - {:error, {:bad_request, "Submission is not found."}} - else - {:ok, answers} - end - end - - defp is_fully_graded?(%Answer{submission_id: submission_id}) do - submission = - Submission - |> Repo.get_by(id: submission_id) - - question_count = - Question - |> where(assessment_id: ^submission.assessment_id) - |> select([q], count(q.id)) - |> Repo.one() - - graded_count = - Answer - |> where([a], submission_id: ^submission_id) - |> where([a], not is_nil(a.grader_id)) - |> select([a], count(a.id)) - |> Repo.one() - - question_count == graded_count - end - - @spec update_grading_info( - %{submission_id: integer() | String.t(), question_id: integer() | String.t()}, - %{}, - CourseRegistration.t() - ) :: - {:ok, nil} - | {:error, {:forbidden | :bad_request | :internal_server_error, String.t()}} - def update_grading_info( - %{submission_id: submission_id, question_id: question_id}, - attrs, - %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) and is_ecto_id(question_id) do - attrs = Map.put(attrs, "grader_id", grader_id) - - answer_query = - Answer - |> where(submission_id: ^submission_id) - |> where(question_id: ^question_id) - - answer_query = - answer_query - |> join(:inner, [a], s in assoc(a, :submission)) - |> preload([_, s], submission: s) - - answer = Repo.one(answer_query) - - is_own_submission = grader_id == answer.submission.student_id - - with {:answer_found?, true} <- {:answer_found?, is_map(answer)}, - {:status, true} <- - {:status, answer.submission.status == :submitted or is_own_submission}, - {:valid, changeset = %Ecto.Changeset{valid?: true}} <- - {:valid, Answer.grading_changeset(answer, attrs)}, - {:ok, _} <- Repo.update(changeset) do - if is_fully_graded?(answer) and not is_own_submission do - # Every answer in this submission has been graded manually - Notifications.write_notification_when_graded(submission_id, :graded) - else - {:ok, nil} - end - else - {:answer_found?, false} -> - {:error, {:bad_request, "Answer not found or user not permitted to grade."}} - - {:valid, changeset} -> - {:error, {:bad_request, full_error_messages(changeset)}} - - {:status, _} -> - {:error, {:method_not_allowed, "Submission is not submitted yet."}} - - {:error, _} -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - def update_grading_info( - _, - _, - _ - ) do - {:error, {:forbidden, "User is not permitted to grade."}} - end - - @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) :: - {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} - def force_regrade_submission( - submission_id, - _requesting_user = %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) do - with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)}, - {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do - GradingJob.force_grade_individual_submission(sub, true) - {:ok, nil} - else - {:get, nil} -> - {:error, {:not_found, "Submission not found"}} - - {:status, false} -> - {:error, {:bad_request, "Submission not submitted yet"}} - end - end - - def force_regrade_submission(_, _) do - {:error, {:forbidden, "User is not permitted to grade."}} - end - - @spec force_regrade_answer( - integer() | String.t(), - integer() | String.t(), - CourseRegistration.t() - ) :: - {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} - def force_regrade_answer( - submission_id, - question_id, - _requesting_user = %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) and is_ecto_id(question_id) do - answer = - Answer - |> where(submission_id: ^submission_id, question_id: ^question_id) - |> preload([:question, :submission]) - |> Repo.one() - - with {:get, answer} when not is_nil(answer) <- {:get, answer}, - {:status, true} <- - {:status, - answer.submission.student_id == grader_id or answer.submission.status == :submitted} do - GradingJob.grade_answer(answer, answer.question, true) - {:ok, nil} - else - {:get, nil} -> - {:error, {:not_found, "Answer not found"}} - - {:status, false} -> - {:error, {:bad_request, "Submission not submitted yet"}} - end - end - - def force_regrade_answer(_, _, _) do - {:error, {:forbidden, "User is not permitted to grade."}} - end - - defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - submission = - Submission - |> where(student_id: ^cr.id) - |> where(assessment_id: ^assessment.id) - |> Repo.one() - - if submission do - {:ok, submission} - else - {:error, nil} - end - end - - # Checks if an assessment is open and published. - @spec is_open?(Assessment.t()) :: boolean() - def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do - Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published - end - - @spec get_group_grading_summary(integer()) :: - {:ok, [String.t(), ...], []} - def get_group_grading_summary(course_id) do - subs = - Answer - |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id) - |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id) - |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id) - |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id) - |> where( - [ans, s, st, a, ac], - not is_nil(st.group_id) and s.status == ^:submitted and - ac.show_grading_summary and a.course_id == ^course_id - ) - |> group_by([ans, s, st, a, ac], s.id) - |> select([ans, s, st, a, ac], %{ - group_id: max(st.group_id), - config_id: max(ac.id), - config_type: max(ac.type), - num_submitted: count(), - num_ungraded: filter(count(), is_nil(ans.grader_id)) - }) - - raw_data = - subs - |> subquery() - |> join(:left, [t], g in Group, on: t.group_id == g.id) - |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id) - |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id) - |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name]) - |> select([t, g, l, lu], %{ - group_name: g.name, - leader_name: lu.name, - config_id: t.config_id, - config_type: t.config_type, - ungraded: filter(count(), t.num_ungraded > 0), - submitted: count() - }) - |> Repo.all() - - showing_configs = - AssessmentConfig - |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary) - |> order_by(:order) - |> group_by([ac], ac.id) - |> select([ac], %{ - id: ac.id, - type: ac.type - }) - |> Repo.all() - - data_by_groups = - raw_data - |> Enum.reduce(%{}, fn raw, acc -> - if Map.has_key?(acc, raw.group_name) do - acc - |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) - |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) - else - acc - |> put_in([raw.group_name], %{}) - |> put_in([raw.group_name, "groupName"], raw.group_name) - |> put_in([raw.group_name, "leaderName"], raw.leader_name) - |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) - |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) - end - end) - - headings = - showing_configs - |> Enum.reduce([], fn config, acc -> - acc ++ ["submitted" <> config.type, "ungraded" <> config.type] - end) - - default_row_data = - headings - |> Enum.reduce(%{}, fn heading, acc -> - put_in(acc, [heading], 0) - end) - - rows = data_by_groups |> Enum.map(fn {_k, row} -> Map.merge(default_row_data, row) end) - cols = ["groupName", "leaderName"] ++ headings - - {:ok, cols, rows} - end - - defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - %Submission{} - |> Submission.changeset(%{student: cr, assessment: assessment}) - |> Repo.insert() - |> case do - {:ok, submission} -> {:ok, submission} - {:error, _} -> {:error, :race_condition} - end - end - - defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - case find_submission(cr, assessment) do - {:ok, submission} -> {:ok, submission} - {:error, _} -> create_empty_submission(cr, assessment) - end - end - - defp insert_or_update_answer( - submission = %Submission{}, - question = %Question{}, - raw_answer, - course_reg_id - ) do - answer_content = build_answer_content(raw_answer, question.type) - - if question.type == :voting do - insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content) - else - answer_changeset = - %Answer{} - |> Answer.changeset(%{ - answer: answer_content, - question_id: question.id, - submission_id: submission.id, - type: question.type - }) - - Repo.insert( - answer_changeset, - on_conflict: [set: [answer: get_change(answer_changeset, :answer)]], - conflict_target: [:submission_id, :question_id] - ) - end - end - - def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do - set_score_to_nil = - SubmissionVotes - |> where(voter_id: ^course_reg_id, question_id: ^question_id) - - voting_multi = - Multi.new() - |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil]) - - answer_content - |> Enum.with_index(1) - |> Enum.reduce(voting_multi, fn {entry, index}, multi -> - multi - |> Multi.run("update#{index}", fn _repo, _ -> - SubmissionVotes - |> Repo.get_by( - voter_id: course_reg_id, - submission_id: entry.submission_id - ) - |> SubmissionVotes.changeset(%{score: entry.score}) - |> Repo.insert_or_update() - end) - end) - |> Multi.run("insert into answer table", fn _repo, _ -> - Answer - |> Repo.get_by(submission_id: submission_id, question_id: question_id) - |> case do - nil -> - Repo.insert(%Answer{ - answer: %{completed: true}, - submission_id: submission_id, - question_id: question_id, - type: :voting - }) - - _ -> - {:ok, nil} - end - end) - |> Repo.transaction() - |> case do - {:ok, _result} -> {:ok, nil} - {:error, _name, _changeset, _error} -> {:error, :invalid_vote} - end - end - - defp build_answer_content(raw_answer, question_type) do - case question_type do - :mcq -> - %{choice_id: raw_answer} - - :programming -> - %{code: raw_answer} - - :voting -> - raw_answer - |> Enum.map(fn ans -> - for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value} - end) - end - end -end +defmodule Cadet.Assessments do + @moduledoc """ + Assessments context contains domain logic for assessments management such as + missions, sidequests, paths, etc. + """ + use Cadet, [:context, :display] + import Ecto.Query + + require Logger + + alias Cadet.Accounts.{ + Notification, + Notifications, + User, + CourseRegistration, + CourseRegistrations + } + + alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission, SubmissionVotes} + alias Cadet.Autograder.GradingJob + alias Cadet.Courses.{Group, AssessmentConfig} + alias Cadet.Jobs.Log + alias Cadet.ProgramAnalysis.Lexer + alias Ecto.Multi + alias Cadet.Incentives.Achievements + + require Decimal + + @open_all_assessment_roles ~w(staff admin)a + + # These roles can save and finalise answers for closed assessments and + # submitted answers + @bypass_closed_roles ~w(staff admin)a + + def delete_assessment(id) do + assessment = Repo.get(Assessment, id) + + Submission + |> where(assessment_id: ^id) + |> delete_submission_assocation(id) + + Question + |> where(assessment_id: ^id) + |> Repo.all() + |> Enum.each(fn q -> + delete_submission_votes_association(q) + end) + + Repo.delete(assessment) + end + + defp delete_submission_votes_association(question) do + SubmissionVotes + |> where(question_id: ^question.id) + |> Repo.delete_all() + end + + defp delete_submission_assocation(submissions, assessment_id) do + submissions + |> Repo.all() + |> Enum.each(fn submission -> + Answer + |> where(submission_id: ^submission.id) + |> Repo.delete_all() + end) + + Notification + |> where(assessment_id: ^assessment_id) + |> Repo.delete_all() + + Repo.delete_all(submissions) + end + + @spec user_max_xp(CourseRegistration.t()) :: integer() + def user_max_xp(%CourseRegistration{id: cr_id}) do + Submission + |> where(status: ^:submitted) + |> where(student_id: ^cr_id) + |> join( + :inner, + [s], + a in subquery(Query.all_assessments_with_max_xp()), + on: s.assessment_id == a.id + ) + |> select([_, a], sum(a.max_xp)) + |> Repo.one() + |> decimal_to_integer() + end + + def assessments_total_xp(%CourseRegistration{id: cr_id}) do + submission_xp = + Submission + |> where(student_id: ^cr_id) + |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) + |> group_by([s], s.id) + |> select([s, a], %{ + # grouping by submission, so s.xp_bonus will be the same, but we need an + # aggregate function + total_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus) + }) + + total = + submission_xp + |> subquery + |> select([s], %{ + total_xp: sum(s.total_xp) + }) + |> Repo.one() + + # for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)} + decimal_to_integer(total.total_xp) + end + + def user_total_xp(course_id, user_id, course_reg_id) do + user_course = CourseRegistrations.get_user_course(user_id, course_id) + + total_achievement_xp = Achievements.achievements_total_xp(course_id, course_reg_id) + total_assessment_xp = assessments_total_xp(user_course) + + total_achievement_xp + total_assessment_xp + end + + defp decimal_to_integer(decimal) do + if Decimal.is_decimal(decimal) do + Decimal.to_integer(decimal) + else + 0 + end + end + + def user_current_story(cr = %CourseRegistration{}) do + {:ok, %{result: story}} = + Multi.new() + |> Multi.run(:unattempted, fn _repo, _ -> + {:ok, get_user_story_by_type(cr, :unattempted)} + end) + |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} -> + if unattempted_story do + {:ok, %{play_story?: true, story: unattempted_story}} + else + {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}} + end + end) + |> Repo.transaction() + + story + end + + @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) :: + String.t() | nil + def get_user_story_by_type(%CourseRegistration{id: cr_id}, type) + when is_atom(type) do + filter_and_sort = fn query -> + case type do + :unattempted -> + query + |> where([_, s], is_nil(s.id)) + |> order_by([a], asc: a.open_at) + + :attempted -> + query |> order_by([a], desc: a.close_at) + end + end + + Assessment + |> where(is_published: true) + |> where([a], not is_nil(a.story)) + |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second")) + |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id) + |> filter_and_sort.() + |> order_by([a], a.config_id) + |> select([a], a.story) + |> first() + |> Repo.one() + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: nil}, + cr = %CourseRegistration{}, + nil + ) do + assessment_with_questions_and_answers(assessment, cr) + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: nil}, + cr = %CourseRegistration{}, + _ + ) do + assessment_with_questions_and_answers(assessment, cr) + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: password}, + cr = %CourseRegistration{}, + given_password + ) do + cond do + Timex.compare(Timex.now(), assessment.close_at) >= 0 -> + assessment_with_questions_and_answers(assessment, cr) + + match?({:ok, _}, find_submission(cr, assessment)) -> + assessment_with_questions_and_answers(assessment, cr) + + given_password == nil -> + {:error, {:forbidden, "Missing Password."}} + + password == given_password -> + find_or_create_submission(cr, assessment) + assessment_with_questions_and_answers(assessment, cr) + + true -> + {:error, {:forbidden, "Invalid Password."}} + end + end + + def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password) + when is_ecto_id(id) do + role = cr.role + + assessment = + if role in @open_all_assessment_roles do + Assessment + |> where(id: ^id) + |> preload(:config) + |> Repo.one() + else + Assessment + |> where(id: ^id) + |> where(is_published: true) + |> preload(:config) + |> Repo.one() + end + + if assessment do + assessment_with_questions_and_answers(assessment, cr, password) + else + {:error, {:bad_request, "Assessment not found"}} + end + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{id: id}, + course_reg = %CourseRegistration{role: role} + ) do + if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do + answer_query = + Answer + |> join(:inner, [a], s in assoc(a, :submission)) + |> where([_, s], s.student_id == ^course_reg.id) + + questions = + Question + |> where(assessment_id: ^id) + |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id) + |> join(:left, [_, a], g in assoc(a, :grader)) + |> join(:left, [_, _, g], u in assoc(g, :user)) + |> select([q, a, g, u], {q, a, g, u}) + |> order_by(:display_order) + |> Repo.all() + |> Enum.map(fn + {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}} + {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}} + {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}} + end) + |> load_contest_voting_entries(course_reg, assessment) + + assessment = assessment |> Map.put(:questions, questions) + {:ok, assessment} + else + {:error, {:forbidden, "Assessment not open"}} + end + end + + def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do + assessment_with_questions_and_answers(id, cr, nil) + end + + @doc """ + Returns a list of assessments with all fields and an indicator showing whether it has been attempted + by the supplied user + """ + def all_assessments(cr = %CourseRegistration{}) do + submission_aggregates = + Submission + |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) + |> where([s], s.student_id == ^cr.id) + |> group_by([s], s.assessment_id) + |> select([s, ans], %{ + assessment_id: s.assessment_id, + # s.xp_bonus should be the same across the group, but we need an aggregate function here + xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)), + graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id)) + }) + + submission_status = + Submission + |> where([s], s.student_id == ^cr.id) + |> select([s], [:assessment_id, :status]) + + assessments = + cr.course_id + |> Query.all_assessments_with_aggregates() + |> subquery() + |> join( + :left, + [a], + sa in subquery(submission_aggregates), + on: a.id == sa.assessment_id + ) + |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id) + |> select([a, sa, s], %{ + a + | xp: sa.xp, + graded_count: sa.graded_count, + user_status: s.status + }) + |> filter_published_assessments(cr) + |> order_by(:open_at) + |> preload(:config) + |> Repo.all() + + {:ok, assessments} + end + + def filter_published_assessments(assessments, cr) do + role = cr.role + + case role do + :student -> where(assessments, is_published: true) + _ -> assessments + end + end + + def create_assessment(params) do + %Assessment{} + |> Assessment.changeset(params) + |> Repo.insert() + end + + @doc """ + The main function that inserts or updates assessments from the XML Parser + """ + @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) :: + {:ok, any()} + | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}} + def insert_or_update_assessments_and_questions( + assessment_params, + questions_params, + force_update + ) do + assessment_multi = + Multi.insert_or_update( + Multi.new(), + :assessment, + insert_or_update_assessment_changeset(assessment_params, force_update) + ) + + if force_update and invalid_force_update(assessment_multi, questions_params) do + {:error, "Question count is different"} + else + questions_params + |> Enum.with_index(1) + |> Enum.reduce(assessment_multi, fn {question_params, index}, multi -> + Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} -> + question = + Question + |> where([q], q.display_order == ^index and q.assessment_id == ^id) + |> Repo.one() + + # the is_nil(question) check allows for force updating of brand new assessments + if !force_update or is_nil(question) do + {status, new_question} = + question_params + |> Map.put(:display_order, index) + |> build_question_changeset_for_assessment_id(id) + |> Repo.insert() + + if status == :ok and new_question.type == :voting do + insert_voting( + assessment_params.course_id, + question_params.question.contest_number, + new_question.id + ) + else + {status, new_question} + end + else + params = + question_params + |> Map.put_new(:max_xp, 0) + |> Map.put(:display_order, index) + + if question_params.type != Atom.to_string(question.type) do + {:error, + create_invalid_changeset_with_error( + :question, + "Question types should remain the same" + )} + else + question + |> Question.changeset(params) + |> Repo.update() + end + end + end) + end) + |> Repo.transaction() + end + end + + # Function that checks if the force update is invalid. The force update is only invalid + # if the new question count is different from the old question count. + defp invalid_force_update(assessment_multi, questions_params) do + assessment_id = + (assessment_multi.operations + |> List.first() + |> elem(1) + |> elem(1)).data.id + + if assessment_id do + open_date = Repo.get(Assessment, assessment_id).open_at + # check if assessment is already opened + if Timex.compare(open_date, Timex.now()) >= 0 do + false + else + existing_questions_count = + Question + |> where([q], q.assessment_id == ^assessment_id) + |> Repo.all() + |> Enum.count() + + new_questions_count = Enum.count(questions_params) + existing_questions_count != new_questions_count + end + else + false + end + end + + @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t() + defp insert_or_update_assessment_changeset( + params = %{number: number, course_id: course_id}, + force_update + ) do + Assessment + |> where(number: ^number) + |> where(course_id: ^course_id) + |> Repo.one() + |> case do + nil -> + Assessment.changeset(%Assessment{}, params) + + %{id: assessment_id} = assessment -> + answers_exist = + Answer + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [a, q], asst in assoc(q, :assessment)) + |> where([a, q, asst], asst.id == ^assessment_id) + |> Repo.exists?() + + # Maintain the same open/close date when updating an assessment + params = + params + |> Map.delete(:open_at) + |> Map.delete(:close_at) + |> Map.delete(:is_published) + + cond do + not answers_exist -> + # Delete all realted submission_votes + SubmissionVotes + |> join(:inner, [sv, q], q in assoc(sv, :question)) + |> where([sv, q], q.assessment_id == ^assessment_id) + |> Repo.delete_all() + + # Delete all existing questions + Question + |> where(assessment_id: ^assessment_id) + |> Repo.delete_all() + + Assessment.changeset(assessment, params) + + force_update -> + Assessment.changeset(assessment, params) + + true -> + # if the assessment has submissions, don't edit + create_invalid_changeset_with_error(:assessment, "has submissions") + end + end + end + + @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) :: + Ecto.Changeset.t() + defp build_question_changeset_for_assessment_id(params, assessment_id) + when is_ecto_id(assessment_id) do + params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id) + + Question.changeset(%Question{}, params_with_assessment_id) + end + + def update_final_contest_entries do + # 1435 = 1 day - 5 minutes + if Log.log_execution("update_final_contest_entries", Timex.Duration.from_minutes(1435)) do + Logger.info("Started update of contest entry pools") + questions = Utilities.fetch_voting_questions() + for q <- questions do + insert_voting(q.course_id, q.question.contest_number, q.question_id) + end + Logger.info("Successfully update contest entry pools") + end + end + + @doc """ + Generates and assigns contest entries for users with given usernames. + """ + def insert_voting( + course_id, + contest_number, + question_id + ) do + contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id) + + if is_nil(contest_assessment) do + changeset = change(%Assessment{}, %{number: ""}) + + error_changeset = + Ecto.Changeset.add_error( + changeset, + :number, + "invalid contest number" + ) + + {:error, error_changeset} + else + if contest_assessment.close_at < Timex.now() do + # Returns contest submission ids with answers that contain "return" + contest_submission_ids = + Submission + |> join(:inner, [s], ans in assoc(s, :answers)) + |> join(:inner, [s, ans], cr in assoc(s, :student)) + |> where([s, ans, cr], cr.role == "student") + |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") + |> where( + [_, ans, cr], + fragment( + "?->>'code' like ?", + ans.answer, + "%return%" + ) + ) + |> select([s, _ans], {s.student_id, s.id}) + |> Repo.all() + |> Enum.into(%{}) + + contest_submission_ids_length = Enum.count(contest_submission_ids) + + voter_ids = + CourseRegistration + |> where(role: "student", course_id: ^course_id) + |> select([cr], cr.id) + |> Repo.all() + + votes_per_user = min(contest_submission_ids_length, 10) + + votes_per_submission = + if Enum.empty?(contest_submission_ids) do + 0 + else + trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) + end + + submission_id_list = + contest_submission_ids + |> Enum.map(fn {_, s_id} -> s_id end) + |> Enum.shuffle() + |> List.duplicate(votes_per_submission) + |> List.flatten() + + {_submission_map, submission_votes_changesets} = + voter_ids + |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> + {submission_list, submission_votes} = acc + + user_contest_submission_id = Map.get(contest_submission_ids, voter_id) + + {votes, rest} = + submission_list + |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> + {user_votes, submissions} = acc + + max_votes = + if votes_per_user == contest_submission_ids_length and + not is_nil(user_contest_submission_id) do + # no. of submssions is less than 10. Unable to find + votes_per_user - 1 + else + votes_per_user + end + + if MapSet.size(user_votes) < max_votes do + if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do + new_user_votes = MapSet.put(user_votes, s_id) + new_submissions = List.delete(submissions, s_id) + {:cont, {new_user_votes, new_submissions}} + else + {:cont, {user_votes, submissions}} + end + else + {:halt, acc} + end + end) + + votes = MapSet.to_list(votes) + + new_submission_votes = + votes + |> Enum.map(fn s_id -> + %SubmissionVotes{voter_id: voter_id, submission_id: s_id, question_id: question_id} + end) + |> Enum.concat(submission_votes) + + {rest, new_submission_votes} + end) + + submission_votes_changesets + |> Enum.with_index() + |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> + Multi.insert(multi, Integer.to_string(index), changeset) + end) + |> Repo.transaction() + else + # contest has not closed, do nothing + {:ok, nil} + end + end + end + + def update_assessment(id, params) when is_ecto_id(id) do + simple_update( + Assessment, + id, + using: &Assessment.changeset/2, + params: params + ) + end + + def update_question(id, params) when is_ecto_id(id) do + simple_update( + Question, + id, + using: &Question.changeset/2, + params: params + ) + end + + def publish_assessment(id) when is_ecto_id(id) do + update_assessment(id, %{is_published: true}) + end + + def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do + assessment = + Assessment + |> where(id: ^assessment_id) + |> join(:left, [a], q in assoc(a, :questions)) + |> preload([_, q], questions: q) + |> Repo.one() + + if assessment do + params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id) + + %Question{} + |> Question.changeset(params_with_assessment_id) + |> put_display_order(assessment.questions) + |> Repo.insert() + else + {:error, "Assessment not found"} + end + end + + def get_question(id) when is_ecto_id(id) do + Question + |> where(id: ^id) + |> join(:inner, [q], assessment in assoc(q, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def delete_question(id) when is_ecto_id(id) do + question = Repo.get(Question, id) + Repo.delete(question) + end + + @doc """ + Public internal api to submit new answers for a question. Possible return values are: + `{:ok, nil}` -> success + `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` + + Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: + `{:bad_request, "Missing or invalid parameter(s)"}` + + """ + def answer_question( + question = %Question{}, + cr = %CourseRegistration{id: cr_id}, + raw_answer, + force_submit + ) do + with {:ok, submission} <- find_or_create_submission(cr, question.assessment), + {:status, true} <- {:status, force_submit or submission.status != :submitted}, + {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do + update_submission_status_router(submission, question) + + {:ok, nil} + else + {:status, _} -> + {:error, {:forbidden, "Assessment submission already finalised"}} + + {:error, :race_condition} -> + {:error, {:internal_server_error, "Please try again later."}} + + {:error, :invalid_vote} -> + {:error, {:bad_request, "Invalid vote! Vote is not saved."}} + + _ -> + {:error, {:bad_request, "Missing or invalid parameter(s)"}} + end + end + + def get_submission(assessment_id, %CourseRegistration{id: cr_id}) + when is_ecto_id(assessment_id) do + Submission + |> where(assessment_id: ^assessment_id) + |> where(student_id: ^cr_id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do + Submission + |> where(id: ^submission_id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def finalise_submission(submission = %Submission{}) do + with {:status, :attempted} <- {:status, submission.status}, + {:ok, updated_submission} <- update_submission_status_and_xp_bonus(submission) do + # Couple with update_submission_status_and_xp_bonus to ensure notification is sent + Notifications.write_notification_when_student_submits(submission) + # Send email notification to avenger + %{notification_type: "assessment_submission", submission_id: updated_submission.id} + |> Cadet.Workers.NotificationWorker.new() + |> Oban.insert() + + # Begin autograding job + GradingJob.force_grade_individual_submission(updated_submission) + + {:ok, nil} + else + {:status, :attempting} -> + {:error, {:bad_request, "Some questions have not been attempted"}} + + {:status, :submitted} -> + {:error, {:forbidden, "Assessment has already been submitted"}} + + _ -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + def unsubmit_submission( + submission_id, + cr = %CourseRegistration{id: course_reg_id, role: role} + ) + when is_ecto_id(submission_id) do + submission = + Submission + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.get(submission_id) + + # allows staff to unsubmit own assessment + bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id + + with {:submission_found?, true} <- {:submission_found?, is_map(submission)}, + {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)}, + {:status, :submitted} <- {:status, submission.status}, + {:allowed_to_unsubmit?, true} <- + {:allowed_to_unsubmit?, + role == :admin or bypass or + Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do + Multi.new() + |> Multi.run( + :rollback_submission, + fn _repo, _ -> + submission + |> Submission.changeset(%{ + status: :attempted, + xp_bonus: 0, + unsubmitted_by_id: course_reg_id, + unsubmitted_at: Timex.now() + }) + |> Repo.update() + end + ) + |> Multi.run(:rollback_answers, fn _repo, _ -> + Answer + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [a, _], s in assoc(a, :submission)) + |> preload([_, q, s], question: q, submission: s) + |> where(submission_id: ^submission.id) + |> Repo.all() + |> Enum.reduce_while({:ok, nil}, fn answer, acc -> + case acc do + {:error, _} -> + {:halt, acc} + + {:ok, _} -> + {:cont, + answer + |> Answer.grading_changeset(%{ + xp: 0, + xp_adjustment: 0, + autograding_status: :none, + autograding_results: [] + }) + |> Repo.update()} + end + end) + end) + |> Repo.transaction() + + Cadet.Accounts.Notifications.handle_unsubmit_notifications( + submission.assessment.id, + Repo.get(CourseRegistration, submission.student_id) + ) + + {:ok, nil} + else + {:submission_found?, false} -> + {:error, {:not_found, "Submission not found"}} + + {:is_open?, false} -> + {:error, {:forbidden, "Assessment not open"}} + + {:status, :attempting} -> + {:error, {:bad_request, "Some questions have not been attempted"}} + + {:status, :attempted} -> + {:error, {:bad_request, "Assessment has not been submitted"}} + + {:allowed_to_unsubmit?, false} -> + {:error, {:forbidden, "Only Avenger of student or Admin is permitted to unsubmit"}} + + _ -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + @spec update_submission_status_and_xp_bonus(Submission.t()) :: + {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} + defp update_submission_status_and_xp_bonus(submission = %Submission{}) do + assessment = submission.assessment + assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id) + + max_bonus_xp = assessment_conifg.early_submission_xp + early_hours = assessment_conifg.hours_before_early_xp_decay + + xp_bonus = + if Timex.before?(Timex.now(), Timex.shift(assessment.open_at, hours: early_hours)) do + max_bonus_xp + else + # This logic interpolates from max bonus at early hour to 0 bonus at close time + decaying_hours = Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours + remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, Timex.now(), :hours)]) + proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1) + bonus_xp = round(max_bonus_xp * proportion) + Enum.max([0, bonus_xp]) + end + + submission + |> Submission.changeset(%{status: :submitted, xp_bonus: xp_bonus}) + |> Repo.update() + end + + defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do + case question.type do + :voting -> update_contest_voting_submission_status(submission, question) + :mcq -> update_submission_status(submission, question.assessment) + :programming -> update_submission_status(submission, question.assessment) + end + end + + defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do + model_assoc_count = fn model, assoc, id -> + model + |> where(id: ^id) + |> join(:inner, [m], a in assoc(m, ^assoc)) + |> select([_, a], count(a.id)) + |> Repo.one() + end + + Multi.new() + |> Multi.run(:assessment, fn _repo, _ -> + {:ok, model_assoc_count.(Assessment, :questions, assessment.id)} + end) + |> Multi.run(:submission, fn _repo, _ -> + {:ok, model_assoc_count.(Submission, :answers, submission.id)} + end) + |> Multi.run(:update, fn _repo, %{submission: s_count, assessment: a_count} -> + if s_count == a_count do + submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() + else + {:ok, nil} + end + end) + |> Repo.transaction() + end + + defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do + has_nil_entries = + SubmissionVotes + |> where(question_id: ^question.id) + |> where(voter_id: ^submission.student_id) + |> where([sv], is_nil(sv.score)) + |> Repo.exists?() + + unless has_nil_entries do + submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() + end + end + + defp load_contest_voting_entries( + questions, + %CourseRegistration{role: role, course_id: course_id, id: voter_id}, + assessment + ) do + Enum.map( + questions, + fn q -> + if q.type == :voting do + submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id) + # fetch top 10 contest voting entries with the contest question id + question_id = fetch_associated_contest_question_id(course_id, q) + + leaderboard_results = + if is_nil(question_id) do + [] + else + if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do + fetch_top_relative_score_answers(question_id, 10) + else + [] + end + end + + # populate entries to vote for and leaderboard data into the question + voting_question = + q.question + |> Map.put(:contest_entries, submission_votes) + |> Map.put( + :contest_leaderboard, + leaderboard_results + ) + + Map.put(q, :question, voting_question) + else + q + end + end + ) + end + + defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do + SubmissionVotes + |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id) + |> join(:inner, [v], s in assoc(v, :submission)) + |> join(:inner, [v, s], a in assoc(s, :answers)) + |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score}) + |> Repo.all() + end + + # Finds the contest_question_id associated with the given voting_question id + defp fetch_associated_contest_question_id(course_id, voting_question) do + contest_number = voting_question.question["contest_number"] + + if is_nil(contest_number) do + nil + else + Assessment + |> where(number: ^contest_number, course_id: ^course_id) + |> join(:inner, [a], q in assoc(a, :questions)) + |> order_by([a, q], q.display_order) + |> select([a, q], q.id) + |> Repo.one() + end + end + + defp leaderboard_open?(assessment, voting_question) do + Timex.before?( + Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]), + Timex.now() + ) + end + + @doc """ + Fetches top answers for the given question, based on the contest relative_score + + Used for contest leaderboard fetching + """ + def fetch_top_relative_score_answers(question_id, number_of_answers) do + Answer + |> where(question_id: ^question_id) + |> where( + [a], + fragment( + "?->>'code' like ?", + a.answer, + "%return%" + ) + ) + |> order_by(desc: :relative_score) + |> join(:left, [a], s in assoc(a, :submission)) + |> join(:left, [a, s], student in assoc(s, :student)) + |> join(:inner, [a, s, student], student_user in assoc(student, :user)) + |> where([a, s, student], student.role == "student") + |> select([a, s, student, student_user], %{ + submission_id: a.submission_id, + answer: a.answer, + relative_score: a.relative_score, + student_name: student_user.name + }) + |> limit(^number_of_answers) + |> Repo.all() + end + + @doc """ + Computes rolling leaderboard for contest votes that are still open. + """ + def update_rolling_contest_leaderboards do + # 115 = 2 hours - 5 minutes is default. + if Log.log_execution("update_rolling_contest_leaderboards", Timex.Duration.from_minutes(115)) do + Logger.info("Started update_rolling_contest_leaderboards") + + voting_questions_to_update = fetch_active_voting_questions() + + _ = + voting_questions_to_update + |> Enum.map(fn qn -> compute_relative_score(qn.id) end) + + Logger.info("Successfully update_rolling_contest_leaderboards") + end + end + + def fetch_active_voting_questions do + Question + |> join(:left, [q], a in assoc(q, :assessment)) + |> where([q, a], q.type == "voting") + |> where([q, a], a.is_published) + |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now()) + |> Repo.all() + end + + @doc """ + Computes final leaderboard for contest votes that have closed. + """ + def update_final_contest_leaderboards do + # 1435 = 24 hours - 5 minutes + if Log.log_execution("update_final_contest_leaderboards", Timex.Duration.from_minutes(1435)) do + Logger.info("Started update_final_contest_leaderboards") + + voting_questions_to_update = fetch_voting_questions_due_yesterday() + + _ = + voting_questions_to_update + |> Enum.map(fn qn -> compute_relative_score(qn.id) end) + + Logger.info("Successfully update_final_contest_leaderboards") + end + end + + def fetch_voting_questions_due_yesterday do + Question + |> join(:left, [q], a in assoc(q, :assessment)) + |> where([q, a], q.type == "voting") + |> where([q, a], a.is_published) + |> where([q, a], a.open_at <= ^Timex.now()) + |> where( + [q, a], + a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1) + ) + |> Repo.all() + end + + @doc """ + Computes the current relative_score of each voting submission answer + based on current submitted votes. + """ + def compute_relative_score(contest_voting_question_id) do + # query all records from submission votes tied to the question id -> + # map score to user id -> + # store as grade -> + # query grade for contest question id. + eligible_votes = + SubmissionVotes + |> where(question_id: ^contest_voting_question_id) + |> where([sv], not is_nil(sv.score)) + |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id) + |> select( + [sv, ans], + %{ans_id: ans.id, score: sv.score, ans: ans.answer["code"]} + ) + |> Repo.all() + + entry_scores = map_eligible_votes_to_entry_score(eligible_votes) + + entry_scores + |> Enum.map(fn {ans_id, relative_score} -> + %Answer{id: ans_id} + |> Answer.contest_score_update_changeset(%{ + relative_score: relative_score + }) + end) + |> Enum.map(fn changeset -> + op_key = "answer_#{changeset.data.id}" + Multi.update(Multi.new(), op_key, changeset) + end) + |> Enum.reduce(Multi.new(), &Multi.append/2) + |> Repo.transaction() + end + + defp map_eligible_votes_to_entry_score(eligible_votes) do + # converts eligible votes to the {total cumulative score, number of votes, tokens} + entry_vote_data = + Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> + {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0}) + + Map.put( + tracker, + ans_id, + # assume each voter is assigned 10 entries which will make it fair. + {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)} + ) + end) + + # calculate the score based on formula {ans_id, score} + Enum.map( + entry_vote_data, + fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> + {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens)} + end + ) + end + + # Calculate the score based on formula + # score(v,t) = v - 2^(t/50) where v is the normalized_voting_score + # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 + defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do + normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 + normalized_voting_score - :math.pow(2, min(1023.5, tokens / 50)) + end + + @doc """ + Function returning submissions under a grader. This function returns only the + fields that are exposed in the /grading endpoint. The reason we select only + those fields is to reduce the memory usage especially when the number of + submissions is large i.e. > 25000 submissions. + + The input parameters are the user and group_only. group_only is used to check + whether only the groups under the grader should be returned. The parameter is + a boolean which is false by default. + + The return value is {:ok, submissions} if no errors, else it is {:error, + {:forbidden, "Forbidden."}} + """ + @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: + {:ok, String.t()} + def all_submissions_by_grader_for_index( + grader = %CourseRegistration{course_id: course_id}, + group_only \\ false, + ungraded_only \\ false + ) do + show_all = not group_only + + group_where = + if show_all, + do: "", + else: + "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" + + ungraded_where = + if ungraded_only, + do: "where s.\"gradedCount\" < assts.\"questionCount\"", + else: "" + + params = if show_all, do: [course_id], else: [course_id, grader.id] + + # We bypass Ecto here and use a raw query to generate JSON directly from + # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. + + case Repo.query( + """ + select json_agg(q)::TEXT from + ( + select + s.id, + s.status, + s."unsubmittedAt", + s.xp, + s."xpAdjustment", + s."xpBonus", + s."gradedCount", + assts.jsn as assessment, + students.jsn as student, + unsubmitters.jsn as "unsubmittedBy" + from + (select + s.id, + s.student_id, + s.assessment_id, + s.status, + s.unsubmitted_at as "unsubmittedAt", + s.unsubmitted_by_id, + sum(ans.xp) as xp, + sum(ans.xp_adjustment) as "xpAdjustment", + s.xp_bonus as "xpBonus", + count(ans.id) filter (where ans.grader_id is not null) as "gradedCount" + from submissions s + left join + answers ans on s.id = ans.submission_id + #{group_where} + group by s.id) s + inner join + (select + a.id, a."questionCount", to_json(a) as jsn + from + (select + a.id, + a.title, + a.number as "assessmentNumber", + bool_or(ac.is_manually_graded) as "isManuallyGraded", + max(ac.type) as "type", + sum(q.max_xp) as "maxXp", + count(q.id) as "questionCount" + from assessments a + left join + questions q on a.id = q.assessment_id + inner join + assessment_configs ac on ac.id = a.config_id + where a.course_id = $1 + group by a.id) a) assts on assts.id = s.assessment_id + inner join + (select + cr.id, to_json(cr) as jsn + from + (select + cr.id, + u.name as "name", + u.username as "username", + g.name as "groupName", + g.leader_id as "groupLeaderId" + from course_registrations cr + left join + groups g on g.id = cr.group_id + inner join + users u on u.id = cr.user_id) cr) students on students.id = s.student_id + left join + (select + cr.id, to_json(cr) as jsn + from + (select + cr.id, + u.name + from course_registrations cr + inner join + users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id + #{ungraded_where} + ) q + """, + params + ) do + {:ok, %{rows: [[nil]]}} -> {:ok, "[]"} + {:ok, %{rows: [[json]]}} -> {:ok, json} + end + end + + @spec get_answers_in_submission(integer() | String.t()) :: + {:ok, [Answer.t()]} | {:error, {:bad_request, String.t()}} + def get_answers_in_submission(id) when is_ecto_id(id) do + answer_query = + Answer + |> where(submission_id: ^id) + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [_, q], ast in assoc(q, :assessment)) + |> join(:inner, [a, ..., ast], ac in assoc(ast, :config)) + |> join(:left, [a, ...], g in assoc(a, :grader)) + |> join(:left, [a, ..., g], gu in assoc(g, :user)) + |> join(:inner, [a, ...], s in assoc(a, :submission)) + |> join(:inner, [a, ..., s], st in assoc(s, :student)) + |> join(:inner, [a, ..., st], u in assoc(st, :user)) + |> preload([_, q, ast, ac, g, gu, s, st, u], + question: {q, assessment: {ast, config: ac}}, + grader: {g, user: gu}, + submission: {s, student: {st, user: u}} + ) + + answers = + answer_query + |> Repo.all() + |> Enum.sort_by(& &1.question.display_order) + |> Enum.map(fn ans -> + if ans.question.type == :voting do + empty_contest_entries = Map.put(ans.question.question, :contest_entries, []) + empty_contest_leaderboard = Map.put(empty_contest_entries, :contest_leaderboard, []) + question = Map.put(ans.question, :question, empty_contest_leaderboard) + Map.put(ans, :question, question) + else + ans + end + end) + + if answers == [] do + {:error, {:bad_request, "Submission is not found."}} + else + {:ok, answers} + end + end + + defp is_fully_graded?(%Answer{submission_id: submission_id}) do + submission = + Submission + |> Repo.get_by(id: submission_id) + + question_count = + Question + |> where(assessment_id: ^submission.assessment_id) + |> select([q], count(q.id)) + |> Repo.one() + + graded_count = + Answer + |> where([a], submission_id: ^submission_id) + |> where([a], not is_nil(a.grader_id)) + |> select([a], count(a.id)) + |> Repo.one() + + question_count == graded_count + end + + @spec update_grading_info( + %{submission_id: integer() | String.t(), question_id: integer() | String.t()}, + %{}, + CourseRegistration.t() + ) :: + {:ok, nil} + | {:error, {:forbidden | :bad_request | :internal_server_error, String.t()}} + def update_grading_info( + %{submission_id: submission_id, question_id: question_id}, + attrs, + %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) and is_ecto_id(question_id) do + attrs = Map.put(attrs, "grader_id", grader_id) + + answer_query = + Answer + |> where(submission_id: ^submission_id) + |> where(question_id: ^question_id) + + answer_query = + answer_query + |> join(:inner, [a], s in assoc(a, :submission)) + |> preload([_, s], submission: s) + + answer = Repo.one(answer_query) + + is_own_submission = grader_id == answer.submission.student_id + + with {:answer_found?, true} <- {:answer_found?, is_map(answer)}, + {:status, true} <- + {:status, answer.submission.status == :submitted or is_own_submission}, + {:valid, changeset = %Ecto.Changeset{valid?: true}} <- + {:valid, Answer.grading_changeset(answer, attrs)}, + {:ok, _} <- Repo.update(changeset) do + if is_fully_graded?(answer) and not is_own_submission do + # Every answer in this submission has been graded manually + Notifications.write_notification_when_graded(submission_id, :graded) + else + {:ok, nil} + end + else + {:answer_found?, false} -> + {:error, {:bad_request, "Answer not found or user not permitted to grade."}} + + {:valid, changeset} -> + {:error, {:bad_request, full_error_messages(changeset)}} + + {:status, _} -> + {:error, {:method_not_allowed, "Submission is not submitted yet."}} + + {:error, _} -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + def update_grading_info( + _, + _, + _ + ) do + {:error, {:forbidden, "User is not permitted to grade."}} + end + + @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) :: + {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} + def force_regrade_submission( + submission_id, + _requesting_user = %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) do + with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)}, + {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do + GradingJob.force_grade_individual_submission(sub, true) + {:ok, nil} + else + {:get, nil} -> + {:error, {:not_found, "Submission not found"}} + + {:status, false} -> + {:error, {:bad_request, "Submission not submitted yet"}} + end + end + + def force_regrade_submission(_, _) do + {:error, {:forbidden, "User is not permitted to grade."}} + end + + @spec force_regrade_answer( + integer() | String.t(), + integer() | String.t(), + CourseRegistration.t() + ) :: + {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} + def force_regrade_answer( + submission_id, + question_id, + _requesting_user = %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) and is_ecto_id(question_id) do + answer = + Answer + |> where(submission_id: ^submission_id, question_id: ^question_id) + |> preload([:question, :submission]) + |> Repo.one() + + with {:get, answer} when not is_nil(answer) <- {:get, answer}, + {:status, true} <- + {:status, + answer.submission.student_id == grader_id or answer.submission.status == :submitted} do + GradingJob.grade_answer(answer, answer.question, true) + {:ok, nil} + else + {:get, nil} -> + {:error, {:not_found, "Answer not found"}} + + {:status, false} -> + {:error, {:bad_request, "Submission not submitted yet"}} + end + end + + def force_regrade_answer(_, _, _) do + {:error, {:forbidden, "User is not permitted to grade."}} + end + + defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + submission = + Submission + |> where(student_id: ^cr.id) + |> where(assessment_id: ^assessment.id) + |> Repo.one() + + if submission do + {:ok, submission} + else + {:error, nil} + end + end + + # Checks if an assessment is open and published. + @spec is_open?(Assessment.t()) :: boolean() + def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do + Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published + end + + @spec get_group_grading_summary(integer()) :: + {:ok, [String.t(), ...], []} + def get_group_grading_summary(course_id) do + subs = + Answer + |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id) + |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id) + |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id) + |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id) + |> where( + [ans, s, st, a, ac], + not is_nil(st.group_id) and s.status == ^:submitted and + ac.show_grading_summary and a.course_id == ^course_id + ) + |> group_by([ans, s, st, a, ac], s.id) + |> select([ans, s, st, a, ac], %{ + group_id: max(st.group_id), + config_id: max(ac.id), + config_type: max(ac.type), + num_submitted: count(), + num_ungraded: filter(count(), is_nil(ans.grader_id)) + }) + + raw_data = + subs + |> subquery() + |> join(:left, [t], g in Group, on: t.group_id == g.id) + |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id) + |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id) + |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name]) + |> select([t, g, l, lu], %{ + group_name: g.name, + leader_name: lu.name, + config_id: t.config_id, + config_type: t.config_type, + ungraded: filter(count(), t.num_ungraded > 0), + submitted: count() + }) + |> Repo.all() + + showing_configs = + AssessmentConfig + |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary) + |> order_by(:order) + |> group_by([ac], ac.id) + |> select([ac], %{ + id: ac.id, + type: ac.type + }) + |> Repo.all() + + data_by_groups = + raw_data + |> Enum.reduce(%{}, fn raw, acc -> + if Map.has_key?(acc, raw.group_name) do + acc + |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) + |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) + else + acc + |> put_in([raw.group_name], %{}) + |> put_in([raw.group_name, "groupName"], raw.group_name) + |> put_in([raw.group_name, "leaderName"], raw.leader_name) + |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) + |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) + end + end) + + headings = + showing_configs + |> Enum.reduce([], fn config, acc -> + acc ++ ["submitted" <> config.type, "ungraded" <> config.type] + end) + + default_row_data = + headings + |> Enum.reduce(%{}, fn heading, acc -> + put_in(acc, [heading], 0) + end) + + rows = data_by_groups |> Enum.map(fn {_k, row} -> Map.merge(default_row_data, row) end) + cols = ["groupName", "leaderName"] ++ headings + + {:ok, cols, rows} + end + + defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + %Submission{} + |> Submission.changeset(%{student: cr, assessment: assessment}) + |> Repo.insert() + |> case do + {:ok, submission} -> {:ok, submission} + {:error, _} -> {:error, :race_condition} + end + end + + defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + case find_submission(cr, assessment) do + {:ok, submission} -> {:ok, submission} + {:error, _} -> create_empty_submission(cr, assessment) + end + end + + defp insert_or_update_answer( + submission = %Submission{}, + question = %Question{}, + raw_answer, + course_reg_id + ) do + answer_content = build_answer_content(raw_answer, question.type) + + if question.type == :voting do + insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content) + else + answer_changeset = + %Answer{} + |> Answer.changeset(%{ + answer: answer_content, + question_id: question.id, + submission_id: submission.id, + type: question.type + }) + + Repo.insert( + answer_changeset, + on_conflict: [set: [answer: get_change(answer_changeset, :answer)]], + conflict_target: [:submission_id, :question_id] + ) + end + end + + def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do + set_score_to_nil = + SubmissionVotes + |> where(voter_id: ^course_reg_id, question_id: ^question_id) + + voting_multi = + Multi.new() + |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil]) + + answer_content + |> Enum.with_index(1) + |> Enum.reduce(voting_multi, fn {entry, index}, multi -> + multi + |> Multi.run("update#{index}", fn _repo, _ -> + SubmissionVotes + |> Repo.get_by( + voter_id: course_reg_id, + submission_id: entry.submission_id + ) + |> SubmissionVotes.changeset(%{score: entry.score}) + |> Repo.insert_or_update() + end) + end) + |> Multi.run("insert into answer table", fn _repo, _ -> + Answer + |> Repo.get_by(submission_id: submission_id, question_id: question_id) + |> case do + nil -> + Repo.insert(%Answer{ + answer: %{completed: true}, + submission_id: submission_id, + question_id: question_id, + type: :voting + }) + + _ -> + {:ok, nil} + end + end) + |> Repo.transaction() + |> case do + {:ok, _result} -> {:ok, nil} + {:error, _name, _changeset, _error} -> {:error, :invalid_vote} + end + end + + defp build_answer_content(raw_answer, question_type) do + case question_type do + :mcq -> + %{choice_id: raw_answer} + + :programming -> + %{code: raw_answer} + + :voting -> + raw_answer + |> Enum.map(fn ans -> + for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value} + end) + end + end +end From dd00f88936c9c8e4071ec0b3f1f079d7875cb13d Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Fri, 15 Sep 2023 02:01:54 +0800 Subject: [PATCH 03/42] Add files via upload --- lib/cadet/jobs/autograder/utilities.ex | 120 +++++++++++++------------ 1 file changed, 65 insertions(+), 55 deletions(-) diff --git a/lib/cadet/jobs/autograder/utilities.ex b/lib/cadet/jobs/autograder/utilities.ex index 32208e0cf..fd91904ac 100644 --- a/lib/cadet/jobs/autograder/utilities.ex +++ b/lib/cadet/jobs/autograder/utilities.ex @@ -1,55 +1,65 @@ -defmodule Cadet.Autograder.Utilities do - @moduledoc """ - This module defines functions that support the autograder functionality. - """ - use Cadet, :context - - require Logger - - import Ecto.Query - - alias Cadet.Accounts.CourseRegistration - alias Cadet.Assessments.{Answer, Assessment, Question, Submission} - - def dispatch_programming_answer(answer = %Answer{}, question = %Question{}, overwrite \\ false) do - # This should never fail - answer = - answer - |> Answer.autograding_changeset(%{autograding_status: :processing}) - |> Repo.update!() - - Que.add(Cadet.Autograder.LambdaWorker, %{ - question: question, - answer: answer, - overwrite: overwrite - }) - end - - def fetch_submissions(assessment_id, course_id) when is_ecto_id(assessment_id) do - CourseRegistration - |> where(role: "student", course_id: ^course_id) - |> join( - :left, - [cr], - s in Submission, - on: cr.id == s.student_id and s.assessment_id == ^assessment_id - ) - |> select([cr, s], %{student_id: cr.id, submission: s}) - |> Repo.all() - end - - def fetch_assessments_due_yesterday do - Assessment - |> where(is_published: true) - |> where([a], a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1)) - |> join(:inner, [a, c], q in assoc(a, :questions)) - |> preload([_, q], questions: q) - |> Repo.all() - |> Enum.map(&sort_assessment_questions(&1)) - end - - def sort_assessment_questions(assessment = %Assessment{}) do - sorted_questions = Enum.sort_by(assessment.questions, & &1.id) - Map.put(assessment, :questions, sorted_questions) - end -end +defmodule Cadet.Autograder.Utilities do + @moduledoc """ + This module defines functions that support the autograder functionality. + """ + use Cadet, :context + + require Logger + + import Ecto.Query + + alias Cadet.Accounts.CourseRegistration + alias Cadet.Assessments.{Answer, Assessment, Question, Submission} + + def dispatch_programming_answer(answer = %Answer{}, question = %Question{}, overwrite \\ false) do + # This should never fail + answer = + answer + |> Answer.autograding_changeset(%{autograding_status: :processing}) + |> Repo.update!() + + Que.add(Cadet.Autograder.LambdaWorker, %{ + question: question, + answer: answer, + overwrite: overwrite + }) + end + + def fetch_submissions(assessment_id, course_id) when is_ecto_id(assessment_id) do + CourseRegistration + |> where(role: "student", course_id: ^course_id) + |> join( + :left, + [cr], + s in Submission, + on: cr.id == s.student_id and s.assessment_id == ^assessment_id + ) + |> select([cr, s], %{student_id: cr.id, submission: s}) + |> Repo.all() + end + + def fetch_assessments_due_yesterday do + Assessment + |> where(is_published: true) + |> where([a], a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1)) + |> join(:inner, [a, c], q in assoc(a, :questions)) + |> preload([_, q], questions: q) + |> Repo.all() + |> Enum.map(&sort_assessment_questions(&1)) + end + + # fetch voting questions that are about to open the next day + def fetch_voting_questions do + Question + |> where(type: :voting) + |> join(:inner, [q], asst in assoc(q, :assessment)) + |> where([q, asst], asst.open_at > ^Timex.now() and asst.open_at <= ^Timex.shift(Timex.now(), days: 1)) + |> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id}) + |> Repo.all() + end + + def sort_assessment_questions(assessment = %Assessment{}) do + sorted_questions = Enum.sort_by(assessment.questions, & &1.id) + Map.put(assessment, :questions, sorted_questions) + end +end From 4420b05fd92319f8b498b410ac0e87929b939002 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Fri, 15 Sep 2023 09:37:00 +0800 Subject: [PATCH 04/42] Add files via upload --- config/config.exs | 224 +++++++++++++++++++++++----------------------- 1 file changed, 112 insertions(+), 112 deletions(-) diff --git a/config/config.exs b/config/config.exs index e2cf1be6a..c998adfe2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,112 +1,112 @@ -# This file is responsible for configuring your application -# and its dependencies with the aid of the Config module. -# -# This configuration file is loaded before any dependency and -# is restricted to this project. -import Config - -config :cadet, environment: Mix.env() - -# General application configuration -config :cadet, - ecto_repos: [Cadet.Repo] - -config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase - -# Scheduler, e.g. for CS1101S -config :cadet, Cadet.Jobs.Scheduler, - timezone: "Asia/Singapore", - overlap: false, - jobs: [ - # Grade assessments that close in the previous day at 00:01 - {"1 0 * * *", {Cadet.Autograder.GradingJob, :grade_all_due_yesterday, []}}, - # Compute contest leaderboard that close in the previous day at 00:01 - {"1 0 * * *", {Cadet.Assessments, :update_final_contest_leaderboards, []}}, - # Compute rolling leaderboard every 2 hours - {"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}}, - # Collate contest entries that close in the previous day at 00:01 - {"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}} - ] - -# Configures the endpoint -config :cadet, CadetWeb.Endpoint, - url: [host: "localhost"], - secret_key_base: "ueV6EWi+7MCMcJH/WZZVKPZbQxFix7tF1Xv9ajD4AN4jLowHbdUX33rmKWPvEEgz", - render_errors: [view: CadetWeb.ErrorView, accepts: ~w(json)], - pubsub_server: Cadet.PubSub - -# Set Phoenix JSON library -config :phoenix, :json_library, Jason -config :phoenix_swagger, json_library: Jason - -# Configures Elixir's Logger -config :logger, :console, - format: "$time $metadata[$level] $message\n", - metadata: [:request_id] - -# Configure ExAWS -config :ex_aws, - access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, {:awscli, "default", 30}, :instance_role], - secret_access_key: [ - {:system, "AWS_SECRET_ACCESS_KEY"}, - {:awscli, "default", 30}, - :instance_role - ], - region: "ap-southeast-1", - s3: [ - scheme: "https://", - host: "s3.ap-southeast-1.amazonaws.com", - region: "ap-southeast-1" - ] - -config :ex_aws, :hackney_opts, recv_timeout: 660_000 - -# Configure Arc File Upload -config :arc, virtual_host: true -# Or uncomment below to use local storage -# config :arc, storage: Arc.Storage.Local - -# Configures Sentry -config :sentry, - included_environments: [:prod], - environment_name: Mix.env(), - enable_source_code_context: true, - root_source_code_path: File.cwd!(), - context_lines: 5 - -# Configure Phoenix Swagger -config :cadet, :phoenix_swagger, - swagger_files: %{ - "priv/static/swagger.json" => [ - router: CadetWeb.Router - ] - } - -# Configure GuardianDB -config :guardian, Guardian.DB, - repo: Cadet.Repo, - # default - schema_name: "guardian_tokens", - # store all token types if not set - token_types: ["refresh"], - # default: 60 minute - sweep_interval: 180 - -config :cadet, Oban, - repo: Cadet.Repo, - plugins: [ - # keep - {Oban.Plugins.Pruner, max_age: 60}, - {Oban.Plugins.Cron, - crontab: [ - {"@daily", Cadet.Workers.NotificationWorker, - args: %{"notification_type" => "avenger_backlog"}} - ]} - ], - queues: [default: 10, notifications: 1] - -config :cadet, Cadet.Mailer, adapter: Bamboo.LocalAdapter - -# Import environment specific config. This must remain at the bottom -# of this file so it overrides the configuration defined above. -import_config "#{Mix.env()}.exs" +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. +import Config + +config :cadet, environment: Mix.env() + +# General application configuration +config :cadet, + ecto_repos: [Cadet.Repo] + +config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase + +# Scheduler, e.g. for CS1101S +config :cadet, Cadet.Jobs.Scheduler, + timezone: "Asia/Singapore", + overlap: false, + jobs: [ + # Grade assessments that close in the previous day at 00:01 + {"1 0 * * *", {Cadet.Autograder.GradingJob, :grade_all_due_yesterday, []}}, + # Compute contest leaderboard that close in the previous day at 00:01 + {"1 0 * * *", {Cadet.Assessments, :update_final_contest_leaderboards, []}}, + # Compute rolling leaderboard every 2 hours + {"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}}, + # Collate contest entries that close in the previous day at 00:01 + {"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}} + ] + +# Configures the endpoint +config :cadet, CadetWeb.Endpoint, + url: [host: "localhost"], + secret_key_base: "ueV6EWi+7MCMcJH/WZZVKPZbQxFix7tF1Xv9ajD4AN4jLowHbdUX33rmKWPvEEgz", + render_errors: [view: CadetWeb.ErrorView, accepts: ~w(json)], + pubsub_server: Cadet.PubSub + +# Set Phoenix JSON library +config :phoenix, :json_library, Jason +config :phoenix_swagger, json_library: Jason + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Configure ExAWS +config :ex_aws, + access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, {:awscli, "default", 30}, :instance_role], + secret_access_key: [ + {:system, "AWS_SECRET_ACCESS_KEY"}, + {:awscli, "default", 30}, + :instance_role + ], + region: "ap-southeast-1", + s3: [ + scheme: "https://", + host: "s3.ap-southeast-1.amazonaws.com", + region: "ap-southeast-1" + ] + +config :ex_aws, :hackney_opts, recv_timeout: 660_000 + +# Configure Arc File Upload +config :arc, virtual_host: true +# Or uncomment below to use local storage +# config :arc, storage: Arc.Storage.Local + +# Configures Sentry +config :sentry, + included_environments: [:prod], + environment_name: Mix.env(), + enable_source_code_context: true, + root_source_code_path: File.cwd!(), + context_lines: 5 + +# Configure Phoenix Swagger +config :cadet, :phoenix_swagger, + swagger_files: %{ + "priv/static/swagger.json" => [ + router: CadetWeb.Router + ] + } + +# Configure GuardianDB +config :guardian, Guardian.DB, + repo: Cadet.Repo, + # default + schema_name: "guardian_tokens", + # store all token types if not set + token_types: ["refresh"], + # default: 60 minute + sweep_interval: 180 + +config :cadet, Oban, + repo: Cadet.Repo, + plugins: [ + # keep + {Oban.Plugins.Pruner, max_age: 60}, + {Oban.Plugins.Cron, + crontab: [ + {"@daily", Cadet.Workers.NotificationWorker, + args: %{"notification_type" => "avenger_backlog"}} + ]} + ], + queues: [default: 10, notifications: 1] + +config :cadet, Cadet.Mailer, adapter: Bamboo.LocalAdapter + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{Mix.env()}.exs" From bc05fa940ba51f474867fc447060c37f011e1509 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Fri, 15 Sep 2023 09:37:32 +0800 Subject: [PATCH 05/42] Add files via upload --- lib/cadet/assessments/assessments.ex | 3184 +++++++++++++------------- 1 file changed, 1595 insertions(+), 1589 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index b160c5469..0fa59bda2 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1,1209 +1,1215 @@ -defmodule Cadet.Assessments do - @moduledoc """ +defmodule Cadet.Assessments do + @moduledoc """ Assessments context contains domain logic for assessments management such as missions, sidequests, paths, etc. - """ - use Cadet, [:context, :display] - import Ecto.Query - - require Logger - - alias Cadet.Accounts.{ - Notification, - Notifications, - User, - CourseRegistration, - CourseRegistrations - } - - alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission, SubmissionVotes} - alias Cadet.Autograder.GradingJob - alias Cadet.Courses.{Group, AssessmentConfig} - alias Cadet.Jobs.Log - alias Cadet.ProgramAnalysis.Lexer - alias Ecto.Multi - alias Cadet.Incentives.Achievements - - require Decimal - - @open_all_assessment_roles ~w(staff admin)a - - # These roles can save and finalise answers for closed assessments and - # submitted answers - @bypass_closed_roles ~w(staff admin)a - - def delete_assessment(id) do - assessment = Repo.get(Assessment, id) - - Submission - |> where(assessment_id: ^id) - |> delete_submission_assocation(id) - - Question - |> where(assessment_id: ^id) - |> Repo.all() - |> Enum.each(fn q -> - delete_submission_votes_association(q) - end) - - Repo.delete(assessment) - end - - defp delete_submission_votes_association(question) do - SubmissionVotes - |> where(question_id: ^question.id) - |> Repo.delete_all() - end - - defp delete_submission_assocation(submissions, assessment_id) do - submissions - |> Repo.all() - |> Enum.each(fn submission -> - Answer - |> where(submission_id: ^submission.id) - |> Repo.delete_all() - end) - - Notification - |> where(assessment_id: ^assessment_id) - |> Repo.delete_all() - - Repo.delete_all(submissions) - end - - @spec user_max_xp(CourseRegistration.t()) :: integer() - def user_max_xp(%CourseRegistration{id: cr_id}) do - Submission - |> where(status: ^:submitted) - |> where(student_id: ^cr_id) - |> join( - :inner, - [s], - a in subquery(Query.all_assessments_with_max_xp()), - on: s.assessment_id == a.id - ) - |> select([_, a], sum(a.max_xp)) - |> Repo.one() - |> decimal_to_integer() - end - - def assessments_total_xp(%CourseRegistration{id: cr_id}) do - submission_xp = - Submission - |> where(student_id: ^cr_id) - |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) - |> group_by([s], s.id) - |> select([s, a], %{ - # grouping by submission, so s.xp_bonus will be the same, but we need an - # aggregate function - total_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus) - }) - - total = - submission_xp - |> subquery - |> select([s], %{ - total_xp: sum(s.total_xp) - }) - |> Repo.one() - - # for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)} - decimal_to_integer(total.total_xp) - end - - def user_total_xp(course_id, user_id, course_reg_id) do - user_course = CourseRegistrations.get_user_course(user_id, course_id) - - total_achievement_xp = Achievements.achievements_total_xp(course_id, course_reg_id) - total_assessment_xp = assessments_total_xp(user_course) - - total_achievement_xp + total_assessment_xp - end - - defp decimal_to_integer(decimal) do - if Decimal.is_decimal(decimal) do - Decimal.to_integer(decimal) - else - 0 - end - end - - def user_current_story(cr = %CourseRegistration{}) do - {:ok, %{result: story}} = - Multi.new() - |> Multi.run(:unattempted, fn _repo, _ -> - {:ok, get_user_story_by_type(cr, :unattempted)} - end) - |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} -> - if unattempted_story do - {:ok, %{play_story?: true, story: unattempted_story}} - else - {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}} - end - end) - |> Repo.transaction() - - story - end - - @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) :: - String.t() | nil - def get_user_story_by_type(%CourseRegistration{id: cr_id}, type) - when is_atom(type) do - filter_and_sort = fn query -> - case type do - :unattempted -> - query - |> where([_, s], is_nil(s.id)) - |> order_by([a], asc: a.open_at) - - :attempted -> - query |> order_by([a], desc: a.close_at) - end - end - - Assessment - |> where(is_published: true) - |> where([a], not is_nil(a.story)) - |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second")) - |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id) - |> filter_and_sort.() - |> order_by([a], a.config_id) - |> select([a], a.story) - |> first() - |> Repo.one() - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: nil}, - cr = %CourseRegistration{}, - nil - ) do - assessment_with_questions_and_answers(assessment, cr) - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: nil}, - cr = %CourseRegistration{}, - _ - ) do - assessment_with_questions_and_answers(assessment, cr) - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{password: password}, - cr = %CourseRegistration{}, - given_password - ) do - cond do - Timex.compare(Timex.now(), assessment.close_at) >= 0 -> - assessment_with_questions_and_answers(assessment, cr) - - match?({:ok, _}, find_submission(cr, assessment)) -> - assessment_with_questions_and_answers(assessment, cr) - - given_password == nil -> - {:error, {:forbidden, "Missing Password."}} - - password == given_password -> - find_or_create_submission(cr, assessment) - assessment_with_questions_and_answers(assessment, cr) - - true -> - {:error, {:forbidden, "Invalid Password."}} - end - end - - def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password) - when is_ecto_id(id) do - role = cr.role - - assessment = - if role in @open_all_assessment_roles do - Assessment - |> where(id: ^id) - |> preload(:config) - |> Repo.one() - else - Assessment - |> where(id: ^id) - |> where(is_published: true) - |> preload(:config) - |> Repo.one() - end - - if assessment do - assessment_with_questions_and_answers(assessment, cr, password) - else - {:error, {:bad_request, "Assessment not found"}} - end - end - - def assessment_with_questions_and_answers( - assessment = %Assessment{id: id}, - course_reg = %CourseRegistration{role: role} - ) do - if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do - answer_query = - Answer - |> join(:inner, [a], s in assoc(a, :submission)) - |> where([_, s], s.student_id == ^course_reg.id) - - questions = - Question - |> where(assessment_id: ^id) - |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id) - |> join(:left, [_, a], g in assoc(a, :grader)) - |> join(:left, [_, _, g], u in assoc(g, :user)) - |> select([q, a, g, u], {q, a, g, u}) - |> order_by(:display_order) - |> Repo.all() - |> Enum.map(fn - {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}} - {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}} - {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}} - end) - |> load_contest_voting_entries(course_reg, assessment) - - assessment = assessment |> Map.put(:questions, questions) - {:ok, assessment} - else - {:error, {:forbidden, "Assessment not open"}} - end - end - - def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do - assessment_with_questions_and_answers(id, cr, nil) - end - - @doc """ + """ + use Cadet, [:context, :display] + import Ecto.Query + + require Logger + + alias Cadet.Accounts.{ + Notification, + Notifications, + User, + CourseRegistration, + CourseRegistrations + } + + alias Cadet.Assessments.{Answer, Assessment, Query, Question, Submission, SubmissionVotes} + alias Cadet.Autograder.GradingJob + alias Cadet.Courses.{Group, AssessmentConfig} + alias Cadet.Jobs.Log + alias Cadet.ProgramAnalysis.Lexer + alias Ecto.Multi + alias Cadet.Incentives.Achievements + + require Decimal + + @open_all_assessment_roles ~w(staff admin)a + + # These roles can save and finalise answers for closed assessments and + # submitted answers + @bypass_closed_roles ~w(staff admin)a + + def delete_assessment(id) do + assessment = Repo.get(Assessment, id) + + Submission + |> where(assessment_id: ^id) + |> delete_submission_assocation(id) + + Question + |> where(assessment_id: ^id) + |> Repo.all() + |> Enum.each(fn q -> + delete_submission_votes_association(q) + end) + + Repo.delete(assessment) + end + + defp delete_submission_votes_association(question) do + SubmissionVotes + |> where(question_id: ^question.id) + |> Repo.delete_all() + end + + defp delete_submission_assocation(submissions, assessment_id) do + submissions + |> Repo.all() + |> Enum.each(fn submission -> + Answer + |> where(submission_id: ^submission.id) + |> Repo.delete_all() + end) + + Notification + |> where(assessment_id: ^assessment_id) + |> Repo.delete_all() + + Repo.delete_all(submissions) + end + + @spec user_max_xp(CourseRegistration.t()) :: integer() + def user_max_xp(%CourseRegistration{id: cr_id}) do + Submission + |> where(status: ^:submitted) + |> where(student_id: ^cr_id) + |> join( + :inner, + [s], + a in subquery(Query.all_assessments_with_max_xp()), + on: s.assessment_id == a.id + ) + |> select([_, a], sum(a.max_xp)) + |> Repo.one() + |> decimal_to_integer() + end + + def assessments_total_xp(%CourseRegistration{id: cr_id}) do + submission_xp = + Submission + |> where(student_id: ^cr_id) + |> join(:inner, [s], a in Answer, on: s.id == a.submission_id) + |> group_by([s], s.id) + |> select([s, a], %{ + # grouping by submission, so s.xp_bonus will be the same, but we need an + # aggregate function + total_xp: sum(a.xp) + sum(a.xp_adjustment) + max(s.xp_bonus) + }) + + total = + submission_xp + |> subquery + |> select([s], %{ + total_xp: sum(s.total_xp) + }) + |> Repo.one() + + # for {key, val} <- total, into: %{}, do: {key, decimal_to_integer(val)} + decimal_to_integer(total.total_xp) + end + + def user_total_xp(course_id, user_id, course_reg_id) do + user_course = CourseRegistrations.get_user_course(user_id, course_id) + + total_achievement_xp = Achievements.achievements_total_xp(course_id, course_reg_id) + total_assessment_xp = assessments_total_xp(user_course) + + total_achievement_xp + total_assessment_xp + end + + defp decimal_to_integer(decimal) do + if Decimal.is_decimal(decimal) do + Decimal.to_integer(decimal) + else + 0 + end + end + + def user_current_story(cr = %CourseRegistration{}) do + {:ok, %{result: story}} = + Multi.new() + |> Multi.run(:unattempted, fn _repo, _ -> + {:ok, get_user_story_by_type(cr, :unattempted)} + end) + |> Multi.run(:result, fn _repo, %{unattempted: unattempted_story} -> + if unattempted_story do + {:ok, %{play_story?: true, story: unattempted_story}} + else + {:ok, %{play_story?: false, story: get_user_story_by_type(cr, :attempted)}} + end + end) + |> Repo.transaction() + + story + end + + @spec get_user_story_by_type(CourseRegistration.t(), :unattempted | :attempted) :: + String.t() | nil + def get_user_story_by_type(%CourseRegistration{id: cr_id}, type) + when is_atom(type) do + filter_and_sort = fn query -> + case type do + :unattempted -> + query + |> where([_, s], is_nil(s.id)) + |> order_by([a], asc: a.open_at) + + :attempted -> + query |> order_by([a], desc: a.close_at) + end + end + + Assessment + |> where(is_published: true) + |> where([a], not is_nil(a.story)) + |> where([a], a.open_at <= from_now(0, "second") and a.close_at >= from_now(0, "second")) + |> join(:left, [a], s in Submission, on: s.assessment_id == a.id and s.student_id == ^cr_id) + |> filter_and_sort.() + |> order_by([a], a.config_id) + |> select([a], a.story) + |> first() + |> Repo.one() + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: nil}, + cr = %CourseRegistration{}, + nil + ) do + assessment_with_questions_and_answers(assessment, cr) + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: nil}, + cr = %CourseRegistration{}, + _ + ) do + assessment_with_questions_and_answers(assessment, cr) + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{password: password}, + cr = %CourseRegistration{}, + given_password + ) do + cond do + Timex.compare(Timex.now(), assessment.close_at) >= 0 -> + assessment_with_questions_and_answers(assessment, cr) + + match?({:ok, _}, find_submission(cr, assessment)) -> + assessment_with_questions_and_answers(assessment, cr) + + given_password == nil -> + {:error, {:forbidden, "Missing Password."}} + + password == given_password -> + find_or_create_submission(cr, assessment) + assessment_with_questions_and_answers(assessment, cr) + + true -> + {:error, {:forbidden, "Invalid Password."}} + end + end + + def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}, password) + when is_ecto_id(id) do + role = cr.role + + assessment = + if role in @open_all_assessment_roles do + Assessment + |> where(id: ^id) + |> preload(:config) + |> Repo.one() + else + Assessment + |> where(id: ^id) + |> where(is_published: true) + |> preload(:config) + |> Repo.one() + end + + if assessment do + assessment_with_questions_and_answers(assessment, cr, password) + else + {:error, {:bad_request, "Assessment not found"}} + end + end + + def assessment_with_questions_and_answers( + assessment = %Assessment{id: id}, + course_reg = %CourseRegistration{role: role} + ) do + if Timex.compare(Timex.now(), assessment.open_at) >= 0 or role in @open_all_assessment_roles do + answer_query = + Answer + |> join(:inner, [a], s in assoc(a, :submission)) + |> where([_, s], s.student_id == ^course_reg.id) + + questions = + Question + |> where(assessment_id: ^id) + |> join(:left, [q], a in subquery(answer_query), on: q.id == a.question_id) + |> join(:left, [_, a], g in assoc(a, :grader)) + |> join(:left, [_, _, g], u in assoc(g, :user)) + |> select([q, a, g, u], {q, a, g, u}) + |> order_by(:display_order) + |> Repo.all() + |> Enum.map(fn + {q, nil, _, _} -> %{q | answer: %Answer{grader: nil}} + {q, a, nil, _} -> %{q | answer: %Answer{a | grader: nil}} + {q, a, g, u} -> %{q | answer: %Answer{a | grader: %CourseRegistration{g | user: u}}} + end) + |> load_contest_voting_entries(course_reg, assessment) + + assessment = assessment |> Map.put(:questions, questions) + {:ok, assessment} + else + {:error, {:forbidden, "Assessment not open"}} + end + end + + def assessment_with_questions_and_answers(id, cr = %CourseRegistration{}) do + assessment_with_questions_and_answers(id, cr, nil) + end + + @doc """ Returns a list of assessments with all fields and an indicator showing whether it has been attempted by the supplied user - """ - def all_assessments(cr = %CourseRegistration{}) do - submission_aggregates = - Submission - |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) - |> where([s], s.student_id == ^cr.id) - |> group_by([s], s.assessment_id) - |> select([s, ans], %{ - assessment_id: s.assessment_id, - # s.xp_bonus should be the same across the group, but we need an aggregate function here - xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)), - graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id)) - }) - - submission_status = - Submission - |> where([s], s.student_id == ^cr.id) - |> select([s], [:assessment_id, :status]) - - assessments = - cr.course_id - |> Query.all_assessments_with_aggregates() - |> subquery() - |> join( - :left, - [a], - sa in subquery(submission_aggregates), - on: a.id == sa.assessment_id - ) - |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id) - |> select([a, sa, s], %{ - a - | xp: sa.xp, - graded_count: sa.graded_count, - user_status: s.status - }) - |> filter_published_assessments(cr) - |> order_by(:open_at) - |> preload(:config) - |> Repo.all() - - {:ok, assessments} - end - - def filter_published_assessments(assessments, cr) do - role = cr.role - - case role do - :student -> where(assessments, is_published: true) - _ -> assessments - end - end - - def create_assessment(params) do - %Assessment{} - |> Assessment.changeset(params) - |> Repo.insert() - end - - @doc """ + """ + def all_assessments(cr = %CourseRegistration{}) do + submission_aggregates = + Submission + |> join(:left, [s], ans in Answer, on: ans.submission_id == s.id) + |> where([s], s.student_id == ^cr.id) + |> group_by([s], s.assessment_id) + |> select([s, ans], %{ + assessment_id: s.assessment_id, + # s.xp_bonus should be the same across the group, but we need an aggregate function here + xp: fragment("? + ? + ?", sum(ans.xp), sum(ans.xp_adjustment), max(s.xp_bonus)), + graded_count: ans.id |> count() |> filter(not is_nil(ans.grader_id)) + }) + + submission_status = + Submission + |> where([s], s.student_id == ^cr.id) + |> select([s], [:assessment_id, :status]) + + assessments = + cr.course_id + |> Query.all_assessments_with_aggregates() + |> subquery() + |> join( + :left, + [a], + sa in subquery(submission_aggregates), + on: a.id == sa.assessment_id + ) + |> join(:left, [a, _], s in subquery(submission_status), on: a.id == s.assessment_id) + |> select([a, sa, s], %{ + a + | xp: sa.xp, + graded_count: sa.graded_count, + user_status: s.status + }) + |> filter_published_assessments(cr) + |> order_by(:open_at) + |> preload(:config) + |> Repo.all() + + {:ok, assessments} + end + + def filter_published_assessments(assessments, cr) do + role = cr.role + + case role do + :student -> where(assessments, is_published: true) + _ -> assessments + end + end + + def create_assessment(params) do + %Assessment{} + |> Assessment.changeset(params) + |> Repo.insert() + end + + @doc """ The main function that inserts or updates assessments from the XML Parser - """ - @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) :: - {:ok, any()} - | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}} - def insert_or_update_assessments_and_questions( - assessment_params, - questions_params, - force_update - ) do - assessment_multi = - Multi.insert_or_update( - Multi.new(), - :assessment, - insert_or_update_assessment_changeset(assessment_params, force_update) - ) - - if force_update and invalid_force_update(assessment_multi, questions_params) do - {:error, "Question count is different"} - else - questions_params - |> Enum.with_index(1) - |> Enum.reduce(assessment_multi, fn {question_params, index}, multi -> - Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} -> - question = - Question - |> where([q], q.display_order == ^index and q.assessment_id == ^id) - |> Repo.one() - - # the is_nil(question) check allows for force updating of brand new assessments - if !force_update or is_nil(question) do - {status, new_question} = - question_params - |> Map.put(:display_order, index) - |> build_question_changeset_for_assessment_id(id) - |> Repo.insert() - - if status == :ok and new_question.type == :voting do - insert_voting( - assessment_params.course_id, - question_params.question.contest_number, - new_question.id - ) - else - {status, new_question} - end - else - params = - question_params - |> Map.put_new(:max_xp, 0) - |> Map.put(:display_order, index) - - if question_params.type != Atom.to_string(question.type) do - {:error, - create_invalid_changeset_with_error( - :question, - "Question types should remain the same" - )} - else - question - |> Question.changeset(params) - |> Repo.update() - end - end - end) - end) - |> Repo.transaction() - end - end - - # Function that checks if the force update is invalid. The force update is only invalid - # if the new question count is different from the old question count. - defp invalid_force_update(assessment_multi, questions_params) do - assessment_id = - (assessment_multi.operations - |> List.first() - |> elem(1) - |> elem(1)).data.id - - if assessment_id do - open_date = Repo.get(Assessment, assessment_id).open_at - # check if assessment is already opened - if Timex.compare(open_date, Timex.now()) >= 0 do - false - else - existing_questions_count = - Question - |> where([q], q.assessment_id == ^assessment_id) - |> Repo.all() - |> Enum.count() - - new_questions_count = Enum.count(questions_params) - existing_questions_count != new_questions_count - end - else - false - end - end - - @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t() - defp insert_or_update_assessment_changeset( - params = %{number: number, course_id: course_id}, - force_update - ) do - Assessment - |> where(number: ^number) - |> where(course_id: ^course_id) - |> Repo.one() - |> case do - nil -> - Assessment.changeset(%Assessment{}, params) - - %{id: assessment_id} = assessment -> - answers_exist = - Answer - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [a, q], asst in assoc(q, :assessment)) - |> where([a, q, asst], asst.id == ^assessment_id) - |> Repo.exists?() - - # Maintain the same open/close date when updating an assessment - params = - params - |> Map.delete(:open_at) - |> Map.delete(:close_at) - |> Map.delete(:is_published) - - cond do - not answers_exist -> - # Delete all realted submission_votes - SubmissionVotes - |> join(:inner, [sv, q], q in assoc(sv, :question)) - |> where([sv, q], q.assessment_id == ^assessment_id) - |> Repo.delete_all() - - # Delete all existing questions - Question - |> where(assessment_id: ^assessment_id) - |> Repo.delete_all() - - Assessment.changeset(assessment, params) - - force_update -> - Assessment.changeset(assessment, params) - - true -> - # if the assessment has submissions, don't edit - create_invalid_changeset_with_error(:assessment, "has submissions") - end - end - end - - @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) :: - Ecto.Changeset.t() - defp build_question_changeset_for_assessment_id(params, assessment_id) - when is_ecto_id(assessment_id) do - params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id) - - Question.changeset(%Question{}, params_with_assessment_id) - end - - def update_final_contest_entries do - # 1435 = 1 day - 5 minutes - if Log.log_execution("update_final_contest_entries", Timex.Duration.from_minutes(1435)) do - Logger.info("Started update of contest entry pools") - questions = Utilities.fetch_voting_questions() - for q <- questions do - insert_voting(q.course_id, q.question.contest_number, q.question_id) - end - Logger.info("Successfully update contest entry pools") - end - end - - @doc """ + """ + @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) :: + {:ok, any()} + | {:error, Ecto.Multi.name(), any(), %{optional(Ecto.Multi.name()) => any()}} + def insert_or_update_assessments_and_questions( + assessment_params, + questions_params, + force_update + ) do + assessment_multi = + Multi.insert_or_update( + Multi.new(), + :assessment, + insert_or_update_assessment_changeset(assessment_params, force_update) + ) + + if force_update and invalid_force_update(assessment_multi, questions_params) do + {:error, "Question count is different"} + else + questions_params + |> Enum.with_index(1) + |> Enum.reduce(assessment_multi, fn {question_params, index}, multi -> + Multi.run(multi, "question#{index}", fn _repo, %{assessment: %Assessment{id: id}} -> + question = + Question + |> where([q], q.display_order == ^index and q.assessment_id == ^id) + |> Repo.one() + + # the is_nil(question) check allows for force updating of brand new assessments + if !force_update or is_nil(question) do + {status, new_question} = + question_params + |> Map.put(:display_order, index) + |> build_question_changeset_for_assessment_id(id) + |> Repo.insert() + + if status == :ok and new_question.type == :voting do + insert_voting( + assessment_params.course_id, + question_params.question.contest_number, + new_question.id + ) + else + {status, new_question} + end + else + params = + question_params + |> Map.put_new(:max_xp, 0) + |> Map.put(:display_order, index) + + if question_params.type != Atom.to_string(question.type) do + {:error, + create_invalid_changeset_with_error( + :question, + "Question types should remain the same" + )} + else + question + |> Question.changeset(params) + |> Repo.update() + end + end + end) + end) + |> Repo.transaction() + end + end + + # Function that checks if the force update is invalid. The force update is only invalid + # if the new question count is different from the old question count. + defp invalid_force_update(assessment_multi, questions_params) do + assessment_id = + (assessment_multi.operations + |> List.first() + |> elem(1) + |> elem(1)).data.id + + if assessment_id do + open_date = Repo.get(Assessment, assessment_id).open_at + # check if assessment is already opened + if Timex.compare(open_date, Timex.now()) >= 0 do + false + else + existing_questions_count = + Question + |> where([q], q.assessment_id == ^assessment_id) + |> Repo.all() + |> Enum.count() + + new_questions_count = Enum.count(questions_params) + existing_questions_count != new_questions_count + end + else + false + end + end + + @spec insert_or_update_assessment_changeset(map(), boolean()) :: Ecto.Changeset.t() + defp insert_or_update_assessment_changeset( + params = %{number: number, course_id: course_id}, + force_update + ) do + Assessment + |> where(number: ^number) + |> where(course_id: ^course_id) + |> Repo.one() + |> case do + nil -> + Assessment.changeset(%Assessment{}, params) + + %{id: assessment_id} = assessment -> + answers_exist = + Answer + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [a, q], asst in assoc(q, :assessment)) + |> where([a, q, asst], asst.id == ^assessment_id) + |> Repo.exists?() + + # Maintain the same open/close date when updating an assessment + params = + params + |> Map.delete(:open_at) + |> Map.delete(:close_at) + |> Map.delete(:is_published) + + cond do + not answers_exist -> + # Delete all realted submission_votes + SubmissionVotes + |> join(:inner, [sv, q], q in assoc(sv, :question)) + |> where([sv, q], q.assessment_id == ^assessment_id) + |> Repo.delete_all() + + # Delete all existing questions + Question + |> where(assessment_id: ^assessment_id) + |> Repo.delete_all() + + Assessment.changeset(assessment, params) + + force_update -> + Assessment.changeset(assessment, params) + + true -> + # if the assessment has submissions, don't edit + create_invalid_changeset_with_error(:assessment, "has submissions") + end + end + end + + @spec build_question_changeset_for_assessment_id(map(), number() | String.t()) :: + Ecto.Changeset.t() + defp build_question_changeset_for_assessment_id(params, assessment_id) + when is_ecto_id(assessment_id) do + params_with_assessment_id = Map.put_new(params, :assessment_id, assessment_id) + + Question.changeset(%Question{}, params_with_assessment_id) + end + + def update_final_contest_entries do + # 1435 = 1 day - 5 minutes + if Log.log_execution("update_final_contest_entries", Timex.Duration.from_minutes(1435)) do + Logger.info("Started update of contest entry pools") + questions = Utilities.fetch_voting_questions() + + for q <- questions do + insert_voting(q.course_id, q.question.contest_number, q.question_id) + end + + Logger.info("Successfully update contest entry pools") + end + end + + @doc """ Generates and assigns contest entries for users with given usernames. - """ - def insert_voting( - course_id, - contest_number, - question_id - ) do - contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id) - - if is_nil(contest_assessment) do - changeset = change(%Assessment{}, %{number: ""}) - - error_changeset = - Ecto.Changeset.add_error( - changeset, - :number, - "invalid contest number" - ) - - {:error, error_changeset} - else - if contest_assessment.close_at < Timex.now() do - # Returns contest submission ids with answers that contain "return" - contest_submission_ids = - Submission - |> join(:inner, [s], ans in assoc(s, :answers)) - |> join(:inner, [s, ans], cr in assoc(s, :student)) - |> where([s, ans, cr], cr.role == "student") - |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") - |> where( - [_, ans, cr], - fragment( - "?->>'code' like ?", - ans.answer, - "%return%" - ) - ) - |> select([s, _ans], {s.student_id, s.id}) - |> Repo.all() - |> Enum.into(%{}) - - contest_submission_ids_length = Enum.count(contest_submission_ids) - - voter_ids = - CourseRegistration - |> where(role: "student", course_id: ^course_id) - |> select([cr], cr.id) - |> Repo.all() - - votes_per_user = min(contest_submission_ids_length, 10) - - votes_per_submission = - if Enum.empty?(contest_submission_ids) do - 0 - else - trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) - end - - submission_id_list = - contest_submission_ids - |> Enum.map(fn {_, s_id} -> s_id end) - |> Enum.shuffle() - |> List.duplicate(votes_per_submission) - |> List.flatten() - - {_submission_map, submission_votes_changesets} = - voter_ids - |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> - {submission_list, submission_votes} = acc - - user_contest_submission_id = Map.get(contest_submission_ids, voter_id) - - {votes, rest} = - submission_list - |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> - {user_votes, submissions} = acc - - max_votes = - if votes_per_user == contest_submission_ids_length and - not is_nil(user_contest_submission_id) do - # no. of submssions is less than 10. Unable to find - votes_per_user - 1 - else - votes_per_user - end - - if MapSet.size(user_votes) < max_votes do - if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do - new_user_votes = MapSet.put(user_votes, s_id) - new_submissions = List.delete(submissions, s_id) - {:cont, {new_user_votes, new_submissions}} - else - {:cont, {user_votes, submissions}} - end - else - {:halt, acc} - end - end) - - votes = MapSet.to_list(votes) - - new_submission_votes = - votes - |> Enum.map(fn s_id -> - %SubmissionVotes{voter_id: voter_id, submission_id: s_id, question_id: question_id} - end) - |> Enum.concat(submission_votes) - - {rest, new_submission_votes} - end) - - submission_votes_changesets - |> Enum.with_index() - |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> - Multi.insert(multi, Integer.to_string(index), changeset) - end) - |> Repo.transaction() - else - # contest has not closed, do nothing - {:ok, nil} - end - end - end - - def update_assessment(id, params) when is_ecto_id(id) do - simple_update( - Assessment, - id, - using: &Assessment.changeset/2, - params: params - ) - end - - def update_question(id, params) when is_ecto_id(id) do - simple_update( - Question, - id, - using: &Question.changeset/2, - params: params - ) - end - - def publish_assessment(id) when is_ecto_id(id) do - update_assessment(id, %{is_published: true}) - end - - def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do - assessment = - Assessment - |> where(id: ^assessment_id) - |> join(:left, [a], q in assoc(a, :questions)) - |> preload([_, q], questions: q) - |> Repo.one() - - if assessment do - params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id) - - %Question{} - |> Question.changeset(params_with_assessment_id) - |> put_display_order(assessment.questions) - |> Repo.insert() - else - {:error, "Assessment not found"} - end - end - - def get_question(id) when is_ecto_id(id) do - Question - |> where(id: ^id) - |> join(:inner, [q], assessment in assoc(q, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def delete_question(id) when is_ecto_id(id) do - question = Repo.get(Question, id) - Repo.delete(question) - end - - @doc """ + """ + def insert_voting( + course_id, + contest_number, + question_id + ) do + contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id) + + if is_nil(contest_assessment) do + changeset = change(%Assessment{}, %{number: ""}) + + error_changeset = + Ecto.Changeset.add_error( + changeset, + :number, + "invalid contest number" + ) + + {:error, error_changeset} + else + if contest_assessment.close_at < Timex.now() do + # Returns contest submission ids with answers that contain "return" + contest_submission_ids = + Submission + |> join(:inner, [s], ans in assoc(s, :answers)) + |> join(:inner, [s, ans], cr in assoc(s, :student)) + |> where([s, ans, cr], cr.role == "student") + |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") + |> where( + [_, ans, cr], + fragment( + "?->>'code' like ?", + ans.answer, + "%return%" + ) + ) + |> select([s, _ans], {s.student_id, s.id}) + |> Repo.all() + |> Enum.into(%{}) + + contest_submission_ids_length = Enum.count(contest_submission_ids) + + voter_ids = + CourseRegistration + |> where(role: "student", course_id: ^course_id) + |> select([cr], cr.id) + |> Repo.all() + + votes_per_user = min(contest_submission_ids_length, 10) + + votes_per_submission = + if Enum.empty?(contest_submission_ids) do + 0 + else + trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) + end + + submission_id_list = + contest_submission_ids + |> Enum.map(fn {_, s_id} -> s_id end) + |> Enum.shuffle() + |> List.duplicate(votes_per_submission) + |> List.flatten() + + {_submission_map, submission_votes_changesets} = + voter_ids + |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> + {submission_list, submission_votes} = acc + + user_contest_submission_id = Map.get(contest_submission_ids, voter_id) + + {votes, rest} = + submission_list + |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> + {user_votes, submissions} = acc + + max_votes = + if votes_per_user == contest_submission_ids_length and + not is_nil(user_contest_submission_id) do + # no. of submssions is less than 10. Unable to find + votes_per_user - 1 + else + votes_per_user + end + + if MapSet.size(user_votes) < max_votes do + if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do + new_user_votes = MapSet.put(user_votes, s_id) + new_submissions = List.delete(submissions, s_id) + {:cont, {new_user_votes, new_submissions}} + else + {:cont, {user_votes, submissions}} + end + else + {:halt, acc} + end + end) + + votes = MapSet.to_list(votes) + + new_submission_votes = + votes + |> Enum.map(fn s_id -> + %SubmissionVotes{ + voter_id: voter_id, + submission_id: s_id, + question_id: question_id + } + end) + |> Enum.concat(submission_votes) + + {rest, new_submission_votes} + end) + + submission_votes_changesets + |> Enum.with_index() + |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> + Multi.insert(multi, Integer.to_string(index), changeset) + end) + |> Repo.transaction() + else + # contest has not closed, do nothing + {:ok, nil} + end + end + end + + def update_assessment(id, params) when is_ecto_id(id) do + simple_update( + Assessment, + id, + using: &Assessment.changeset/2, + params: params + ) + end + + def update_question(id, params) when is_ecto_id(id) do + simple_update( + Question, + id, + using: &Question.changeset/2, + params: params + ) + end + + def publish_assessment(id) when is_ecto_id(id) do + update_assessment(id, %{is_published: true}) + end + + def create_question_for_assessment(params, assessment_id) when is_ecto_id(assessment_id) do + assessment = + Assessment + |> where(id: ^assessment_id) + |> join(:left, [a], q in assoc(a, :questions)) + |> preload([_, q], questions: q) + |> Repo.one() + + if assessment do + params_with_assessment_id = Map.put_new(params, :assessment_id, assessment.id) + + %Question{} + |> Question.changeset(params_with_assessment_id) + |> put_display_order(assessment.questions) + |> Repo.insert() + else + {:error, "Assessment not found"} + end + end + + def get_question(id) when is_ecto_id(id) do + Question + |> where(id: ^id) + |> join(:inner, [q], assessment in assoc(q, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def delete_question(id) when is_ecto_id(id) do + question = Repo.get(Question, id) + Repo.delete(question) + end + + @doc """ Public internal api to submit new answers for a question. Possible return values are: `{:ok, nil}` -> success `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` - + Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: `{:bad_request, "Missing or invalid parameter(s)"}` - - """ - def answer_question( - question = %Question{}, - cr = %CourseRegistration{id: cr_id}, - raw_answer, - force_submit - ) do - with {:ok, submission} <- find_or_create_submission(cr, question.assessment), - {:status, true} <- {:status, force_submit or submission.status != :submitted}, - {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do - update_submission_status_router(submission, question) - - {:ok, nil} - else - {:status, _} -> - {:error, {:forbidden, "Assessment submission already finalised"}} - - {:error, :race_condition} -> - {:error, {:internal_server_error, "Please try again later."}} - - {:error, :invalid_vote} -> - {:error, {:bad_request, "Invalid vote! Vote is not saved."}} - - _ -> - {:error, {:bad_request, "Missing or invalid parameter(s)"}} - end - end - - def get_submission(assessment_id, %CourseRegistration{id: cr_id}) - when is_ecto_id(assessment_id) do - Submission - |> where(assessment_id: ^assessment_id) - |> where(student_id: ^cr_id) - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do - Submission - |> where(id: ^submission_id) - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.one() - end - - def finalise_submission(submission = %Submission{}) do - with {:status, :attempted} <- {:status, submission.status}, - {:ok, updated_submission} <- update_submission_status_and_xp_bonus(submission) do - # Couple with update_submission_status_and_xp_bonus to ensure notification is sent - Notifications.write_notification_when_student_submits(submission) - # Send email notification to avenger - %{notification_type: "assessment_submission", submission_id: updated_submission.id} - |> Cadet.Workers.NotificationWorker.new() - |> Oban.insert() - - # Begin autograding job - GradingJob.force_grade_individual_submission(updated_submission) - - {:ok, nil} - else - {:status, :attempting} -> - {:error, {:bad_request, "Some questions have not been attempted"}} - - {:status, :submitted} -> - {:error, {:forbidden, "Assessment has already been submitted"}} - - _ -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - def unsubmit_submission( - submission_id, - cr = %CourseRegistration{id: course_reg_id, role: role} - ) - when is_ecto_id(submission_id) do - submission = - Submission - |> join(:inner, [s], a in assoc(s, :assessment)) - |> preload([_, a], assessment: a) - |> Repo.get(submission_id) - - # allows staff to unsubmit own assessment - bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id - - with {:submission_found?, true} <- {:submission_found?, is_map(submission)}, - {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)}, - {:status, :submitted} <- {:status, submission.status}, - {:allowed_to_unsubmit?, true} <- - {:allowed_to_unsubmit?, - role == :admin or bypass or - Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do - Multi.new() - |> Multi.run( - :rollback_submission, - fn _repo, _ -> - submission - |> Submission.changeset(%{ - status: :attempted, - xp_bonus: 0, - unsubmitted_by_id: course_reg_id, - unsubmitted_at: Timex.now() - }) - |> Repo.update() - end - ) - |> Multi.run(:rollback_answers, fn _repo, _ -> - Answer - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [a, _], s in assoc(a, :submission)) - |> preload([_, q, s], question: q, submission: s) - |> where(submission_id: ^submission.id) - |> Repo.all() - |> Enum.reduce_while({:ok, nil}, fn answer, acc -> - case acc do - {:error, _} -> - {:halt, acc} - - {:ok, _} -> - {:cont, - answer - |> Answer.grading_changeset(%{ - xp: 0, - xp_adjustment: 0, - autograding_status: :none, - autograding_results: [] - }) - |> Repo.update()} - end - end) - end) - |> Repo.transaction() - - Cadet.Accounts.Notifications.handle_unsubmit_notifications( - submission.assessment.id, - Repo.get(CourseRegistration, submission.student_id) - ) - - {:ok, nil} - else - {:submission_found?, false} -> - {:error, {:not_found, "Submission not found"}} - - {:is_open?, false} -> - {:error, {:forbidden, "Assessment not open"}} - - {:status, :attempting} -> - {:error, {:bad_request, "Some questions have not been attempted"}} - - {:status, :attempted} -> - {:error, {:bad_request, "Assessment has not been submitted"}} - - {:allowed_to_unsubmit?, false} -> - {:error, {:forbidden, "Only Avenger of student or Admin is permitted to unsubmit"}} - - _ -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - @spec update_submission_status_and_xp_bonus(Submission.t()) :: - {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} - defp update_submission_status_and_xp_bonus(submission = %Submission{}) do - assessment = submission.assessment - assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id) - - max_bonus_xp = assessment_conifg.early_submission_xp - early_hours = assessment_conifg.hours_before_early_xp_decay - - xp_bonus = - if Timex.before?(Timex.now(), Timex.shift(assessment.open_at, hours: early_hours)) do - max_bonus_xp - else - # This logic interpolates from max bonus at early hour to 0 bonus at close time - decaying_hours = Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours - remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, Timex.now(), :hours)]) - proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1) - bonus_xp = round(max_bonus_xp * proportion) - Enum.max([0, bonus_xp]) - end - - submission - |> Submission.changeset(%{status: :submitted, xp_bonus: xp_bonus}) - |> Repo.update() - end - - defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do - case question.type do - :voting -> update_contest_voting_submission_status(submission, question) - :mcq -> update_submission_status(submission, question.assessment) - :programming -> update_submission_status(submission, question.assessment) - end - end - - defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do - model_assoc_count = fn model, assoc, id -> - model - |> where(id: ^id) - |> join(:inner, [m], a in assoc(m, ^assoc)) - |> select([_, a], count(a.id)) - |> Repo.one() - end - - Multi.new() - |> Multi.run(:assessment, fn _repo, _ -> - {:ok, model_assoc_count.(Assessment, :questions, assessment.id)} - end) - |> Multi.run(:submission, fn _repo, _ -> - {:ok, model_assoc_count.(Submission, :answers, submission.id)} - end) - |> Multi.run(:update, fn _repo, %{submission: s_count, assessment: a_count} -> - if s_count == a_count do - submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() - else - {:ok, nil} - end - end) - |> Repo.transaction() - end - - defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do - has_nil_entries = - SubmissionVotes - |> where(question_id: ^question.id) - |> where(voter_id: ^submission.student_id) - |> where([sv], is_nil(sv.score)) - |> Repo.exists?() - - unless has_nil_entries do - submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() - end - end - - defp load_contest_voting_entries( - questions, - %CourseRegistration{role: role, course_id: course_id, id: voter_id}, - assessment - ) do - Enum.map( - questions, - fn q -> - if q.type == :voting do - submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id) - # fetch top 10 contest voting entries with the contest question id - question_id = fetch_associated_contest_question_id(course_id, q) - - leaderboard_results = - if is_nil(question_id) do - [] - else - if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do - fetch_top_relative_score_answers(question_id, 10) - else - [] - end - end - - # populate entries to vote for and leaderboard data into the question - voting_question = - q.question - |> Map.put(:contest_entries, submission_votes) - |> Map.put( - :contest_leaderboard, - leaderboard_results - ) - - Map.put(q, :question, voting_question) - else - q - end - end - ) - end - - defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do - SubmissionVotes - |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id) - |> join(:inner, [v], s in assoc(v, :submission)) - |> join(:inner, [v, s], a in assoc(s, :answers)) - |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score}) - |> Repo.all() - end - - # Finds the contest_question_id associated with the given voting_question id - defp fetch_associated_contest_question_id(course_id, voting_question) do - contest_number = voting_question.question["contest_number"] - - if is_nil(contest_number) do - nil - else - Assessment - |> where(number: ^contest_number, course_id: ^course_id) - |> join(:inner, [a], q in assoc(a, :questions)) - |> order_by([a, q], q.display_order) - |> select([a, q], q.id) - |> Repo.one() - end - end - - defp leaderboard_open?(assessment, voting_question) do - Timex.before?( - Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]), - Timex.now() - ) - end - - @doc """ + + """ + def answer_question( + question = %Question{}, + cr = %CourseRegistration{id: cr_id}, + raw_answer, + force_submit + ) do + with {:ok, submission} <- find_or_create_submission(cr, question.assessment), + {:status, true} <- {:status, force_submit or submission.status != :submitted}, + {:ok, _answer} <- insert_or_update_answer(submission, question, raw_answer, cr_id) do + update_submission_status_router(submission, question) + + {:ok, nil} + else + {:status, _} -> + {:error, {:forbidden, "Assessment submission already finalised"}} + + {:error, :race_condition} -> + {:error, {:internal_server_error, "Please try again later."}} + + {:error, :invalid_vote} -> + {:error, {:bad_request, "Invalid vote! Vote is not saved."}} + + _ -> + {:error, {:bad_request, "Missing or invalid parameter(s)"}} + end + end + + def get_submission(assessment_id, %CourseRegistration{id: cr_id}) + when is_ecto_id(assessment_id) do + Submission + |> where(assessment_id: ^assessment_id) + |> where(student_id: ^cr_id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def get_submission_by_id(submission_id) when is_ecto_id(submission_id) do + Submission + |> where(id: ^submission_id) + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.one() + end + + def finalise_submission(submission = %Submission{}) do + with {:status, :attempted} <- {:status, submission.status}, + {:ok, updated_submission} <- update_submission_status_and_xp_bonus(submission) do + # Couple with update_submission_status_and_xp_bonus to ensure notification is sent + Notifications.write_notification_when_student_submits(submission) + # Send email notification to avenger + %{notification_type: "assessment_submission", submission_id: updated_submission.id} + |> Cadet.Workers.NotificationWorker.new() + |> Oban.insert() + + # Begin autograding job + GradingJob.force_grade_individual_submission(updated_submission) + + {:ok, nil} + else + {:status, :attempting} -> + {:error, {:bad_request, "Some questions have not been attempted"}} + + {:status, :submitted} -> + {:error, {:forbidden, "Assessment has already been submitted"}} + + _ -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + def unsubmit_submission( + submission_id, + cr = %CourseRegistration{id: course_reg_id, role: role} + ) + when is_ecto_id(submission_id) do + submission = + Submission + |> join(:inner, [s], a in assoc(s, :assessment)) + |> preload([_, a], assessment: a) + |> Repo.get(submission_id) + + # allows staff to unsubmit own assessment + bypass = role in @bypass_closed_roles and submission.student_id == course_reg_id + + with {:submission_found?, true} <- {:submission_found?, is_map(submission)}, + {:is_open?, true} <- {:is_open?, bypass or is_open?(submission.assessment)}, + {:status, :submitted} <- {:status, submission.status}, + {:allowed_to_unsubmit?, true} <- + {:allowed_to_unsubmit?, + role == :admin or bypass or + Cadet.Accounts.Query.avenger_of?(cr, submission.student_id)} do + Multi.new() + |> Multi.run( + :rollback_submission, + fn _repo, _ -> + submission + |> Submission.changeset(%{ + status: :attempted, + xp_bonus: 0, + unsubmitted_by_id: course_reg_id, + unsubmitted_at: Timex.now() + }) + |> Repo.update() + end + ) + |> Multi.run(:rollback_answers, fn _repo, _ -> + Answer + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [a, _], s in assoc(a, :submission)) + |> preload([_, q, s], question: q, submission: s) + |> where(submission_id: ^submission.id) + |> Repo.all() + |> Enum.reduce_while({:ok, nil}, fn answer, acc -> + case acc do + {:error, _} -> + {:halt, acc} + + {:ok, _} -> + {:cont, + answer + |> Answer.grading_changeset(%{ + xp: 0, + xp_adjustment: 0, + autograding_status: :none, + autograding_results: [] + }) + |> Repo.update()} + end + end) + end) + |> Repo.transaction() + + Cadet.Accounts.Notifications.handle_unsubmit_notifications( + submission.assessment.id, + Repo.get(CourseRegistration, submission.student_id) + ) + + {:ok, nil} + else + {:submission_found?, false} -> + {:error, {:not_found, "Submission not found"}} + + {:is_open?, false} -> + {:error, {:forbidden, "Assessment not open"}} + + {:status, :attempting} -> + {:error, {:bad_request, "Some questions have not been attempted"}} + + {:status, :attempted} -> + {:error, {:bad_request, "Assessment has not been submitted"}} + + {:allowed_to_unsubmit?, false} -> + {:error, {:forbidden, "Only Avenger of student or Admin is permitted to unsubmit"}} + + _ -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + @spec update_submission_status_and_xp_bonus(Submission.t()) :: + {:ok, Submission.t()} | {:error, Ecto.Changeset.t()} + defp update_submission_status_and_xp_bonus(submission = %Submission{}) do + assessment = submission.assessment + assessment_conifg = Repo.get_by(AssessmentConfig, id: assessment.config_id) + + max_bonus_xp = assessment_conifg.early_submission_xp + early_hours = assessment_conifg.hours_before_early_xp_decay + + xp_bonus = + if Timex.before?(Timex.now(), Timex.shift(assessment.open_at, hours: early_hours)) do + max_bonus_xp + else + # This logic interpolates from max bonus at early hour to 0 bonus at close time + decaying_hours = Timex.diff(assessment.close_at, assessment.open_at, :hours) - early_hours + remaining_hours = Enum.max([0, Timex.diff(assessment.close_at, Timex.now(), :hours)]) + proportion = if(decaying_hours > 0, do: remaining_hours / decaying_hours, else: 1) + bonus_xp = round(max_bonus_xp * proportion) + Enum.max([0, bonus_xp]) + end + + submission + |> Submission.changeset(%{status: :submitted, xp_bonus: xp_bonus}) + |> Repo.update() + end + + defp update_submission_status_router(submission = %Submission{}, question = %Question{}) do + case question.type do + :voting -> update_contest_voting_submission_status(submission, question) + :mcq -> update_submission_status(submission, question.assessment) + :programming -> update_submission_status(submission, question.assessment) + end + end + + defp update_submission_status(submission = %Submission{}, assessment = %Assessment{}) do + model_assoc_count = fn model, assoc, id -> + model + |> where(id: ^id) + |> join(:inner, [m], a in assoc(m, ^assoc)) + |> select([_, a], count(a.id)) + |> Repo.one() + end + + Multi.new() + |> Multi.run(:assessment, fn _repo, _ -> + {:ok, model_assoc_count.(Assessment, :questions, assessment.id)} + end) + |> Multi.run(:submission, fn _repo, _ -> + {:ok, model_assoc_count.(Submission, :answers, submission.id)} + end) + |> Multi.run(:update, fn _repo, %{submission: s_count, assessment: a_count} -> + if s_count == a_count do + submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() + else + {:ok, nil} + end + end) + |> Repo.transaction() + end + + defp update_contest_voting_submission_status(submission = %Submission{}, question = %Question{}) do + has_nil_entries = + SubmissionVotes + |> where(question_id: ^question.id) + |> where(voter_id: ^submission.student_id) + |> where([sv], is_nil(sv.score)) + |> Repo.exists?() + + unless has_nil_entries do + submission |> Submission.changeset(%{status: :attempted}) |> Repo.update() + end + end + + defp load_contest_voting_entries( + questions, + %CourseRegistration{role: role, course_id: course_id, id: voter_id}, + assessment + ) do + Enum.map( + questions, + fn q -> + if q.type == :voting do + submission_votes = all_submission_votes_by_question_id_and_voter_id(q.id, voter_id) + # fetch top 10 contest voting entries with the contest question id + question_id = fetch_associated_contest_question_id(course_id, q) + + leaderboard_results = + if is_nil(question_id) do + [] + else + if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do + fetch_top_relative_score_answers(question_id, 10) + else + [] + end + end + + # populate entries to vote for and leaderboard data into the question + voting_question = + q.question + |> Map.put(:contest_entries, submission_votes) + |> Map.put( + :contest_leaderboard, + leaderboard_results + ) + + Map.put(q, :question, voting_question) + else + q + end + end + ) + end + + defp all_submission_votes_by_question_id_and_voter_id(question_id, voter_id) do + SubmissionVotes + |> where([v], v.voter_id == ^voter_id and v.question_id == ^question_id) + |> join(:inner, [v], s in assoc(v, :submission)) + |> join(:inner, [v, s], a in assoc(s, :answers)) + |> select([v, s, a], %{submission_id: v.submission_id, answer: a.answer, score: v.score}) + |> Repo.all() + end + + # Finds the contest_question_id associated with the given voting_question id + defp fetch_associated_contest_question_id(course_id, voting_question) do + contest_number = voting_question.question["contest_number"] + + if is_nil(contest_number) do + nil + else + Assessment + |> where(number: ^contest_number, course_id: ^course_id) + |> join(:inner, [a], q in assoc(a, :questions)) + |> order_by([a, q], q.display_order) + |> select([a, q], q.id) + |> Repo.one() + end + end + + defp leaderboard_open?(assessment, voting_question) do + Timex.before?( + Timex.shift(assessment.close_at, hours: voting_question.question["reveal_hours"]), + Timex.now() + ) + end + + @doc """ Fetches top answers for the given question, based on the contest relative_score - + Used for contest leaderboard fetching - """ - def fetch_top_relative_score_answers(question_id, number_of_answers) do - Answer - |> where(question_id: ^question_id) - |> where( - [a], - fragment( - "?->>'code' like ?", - a.answer, - "%return%" - ) - ) - |> order_by(desc: :relative_score) - |> join(:left, [a], s in assoc(a, :submission)) - |> join(:left, [a, s], student in assoc(s, :student)) - |> join(:inner, [a, s, student], student_user in assoc(student, :user)) - |> where([a, s, student], student.role == "student") - |> select([a, s, student, student_user], %{ - submission_id: a.submission_id, - answer: a.answer, - relative_score: a.relative_score, - student_name: student_user.name - }) - |> limit(^number_of_answers) - |> Repo.all() - end - - @doc """ + """ + def fetch_top_relative_score_answers(question_id, number_of_answers) do + Answer + |> where(question_id: ^question_id) + |> where( + [a], + fragment( + "?->>'code' like ?", + a.answer, + "%return%" + ) + ) + |> order_by(desc: :relative_score) + |> join(:left, [a], s in assoc(a, :submission)) + |> join(:left, [a, s], student in assoc(s, :student)) + |> join(:inner, [a, s, student], student_user in assoc(student, :user)) + |> where([a, s, student], student.role == "student") + |> select([a, s, student, student_user], %{ + submission_id: a.submission_id, + answer: a.answer, + relative_score: a.relative_score, + student_name: student_user.name + }) + |> limit(^number_of_answers) + |> Repo.all() + end + + @doc """ Computes rolling leaderboard for contest votes that are still open. - """ - def update_rolling_contest_leaderboards do - # 115 = 2 hours - 5 minutes is default. - if Log.log_execution("update_rolling_contest_leaderboards", Timex.Duration.from_minutes(115)) do - Logger.info("Started update_rolling_contest_leaderboards") - - voting_questions_to_update = fetch_active_voting_questions() - - _ = - voting_questions_to_update - |> Enum.map(fn qn -> compute_relative_score(qn.id) end) - - Logger.info("Successfully update_rolling_contest_leaderboards") - end - end - - def fetch_active_voting_questions do - Question - |> join(:left, [q], a in assoc(q, :assessment)) - |> where([q, a], q.type == "voting") - |> where([q, a], a.is_published) - |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now()) - |> Repo.all() - end - - @doc """ + """ + def update_rolling_contest_leaderboards do + # 115 = 2 hours - 5 minutes is default. + if Log.log_execution("update_rolling_contest_leaderboards", Timex.Duration.from_minutes(115)) do + Logger.info("Started update_rolling_contest_leaderboards") + + voting_questions_to_update = fetch_active_voting_questions() + + _ = + voting_questions_to_update + |> Enum.map(fn qn -> compute_relative_score(qn.id) end) + + Logger.info("Successfully update_rolling_contest_leaderboards") + end + end + + def fetch_active_voting_questions do + Question + |> join(:left, [q], a in assoc(q, :assessment)) + |> where([q, a], q.type == "voting") + |> where([q, a], a.is_published) + |> where([q, a], a.open_at <= ^Timex.now() and a.close_at >= ^Timex.now()) + |> Repo.all() + end + + @doc """ Computes final leaderboard for contest votes that have closed. - """ - def update_final_contest_leaderboards do - # 1435 = 24 hours - 5 minutes - if Log.log_execution("update_final_contest_leaderboards", Timex.Duration.from_minutes(1435)) do - Logger.info("Started update_final_contest_leaderboards") - - voting_questions_to_update = fetch_voting_questions_due_yesterday() - - _ = - voting_questions_to_update - |> Enum.map(fn qn -> compute_relative_score(qn.id) end) - - Logger.info("Successfully update_final_contest_leaderboards") - end - end - - def fetch_voting_questions_due_yesterday do - Question - |> join(:left, [q], a in assoc(q, :assessment)) - |> where([q, a], q.type == "voting") - |> where([q, a], a.is_published) - |> where([q, a], a.open_at <= ^Timex.now()) - |> where( - [q, a], - a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1) - ) - |> Repo.all() - end - - @doc """ + """ + def update_final_contest_leaderboards do + # 1435 = 24 hours - 5 minutes + if Log.log_execution("update_final_contest_leaderboards", Timex.Duration.from_minutes(1435)) do + Logger.info("Started update_final_contest_leaderboards") + + voting_questions_to_update = fetch_voting_questions_due_yesterday() + + _ = + voting_questions_to_update + |> Enum.map(fn qn -> compute_relative_score(qn.id) end) + + Logger.info("Successfully update_final_contest_leaderboards") + end + end + + def fetch_voting_questions_due_yesterday do + Question + |> join(:left, [q], a in assoc(q, :assessment)) + |> where([q, a], q.type == "voting") + |> where([q, a], a.is_published) + |> where([q, a], a.open_at <= ^Timex.now()) + |> where( + [q, a], + a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1) + ) + |> Repo.all() + end + + @doc """ Computes the current relative_score of each voting submission answer based on current submitted votes. - """ - def compute_relative_score(contest_voting_question_id) do - # query all records from submission votes tied to the question id -> - # map score to user id -> - # store as grade -> - # query grade for contest question id. - eligible_votes = - SubmissionVotes - |> where(question_id: ^contest_voting_question_id) - |> where([sv], not is_nil(sv.score)) - |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id) - |> select( - [sv, ans], - %{ans_id: ans.id, score: sv.score, ans: ans.answer["code"]} - ) - |> Repo.all() - - entry_scores = map_eligible_votes_to_entry_score(eligible_votes) - - entry_scores - |> Enum.map(fn {ans_id, relative_score} -> - %Answer{id: ans_id} - |> Answer.contest_score_update_changeset(%{ - relative_score: relative_score - }) - end) - |> Enum.map(fn changeset -> - op_key = "answer_#{changeset.data.id}" - Multi.update(Multi.new(), op_key, changeset) - end) - |> Enum.reduce(Multi.new(), &Multi.append/2) - |> Repo.transaction() - end - - defp map_eligible_votes_to_entry_score(eligible_votes) do - # converts eligible votes to the {total cumulative score, number of votes, tokens} - entry_vote_data = - Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> - {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0}) - - Map.put( - tracker, - ans_id, - # assume each voter is assigned 10 entries which will make it fair. - {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)} - ) - end) - - # calculate the score based on formula {ans_id, score} - Enum.map( - entry_vote_data, - fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> - {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens)} - end - ) - end - - # Calculate the score based on formula - # score(v,t) = v - 2^(t/50) where v is the normalized_voting_score - # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do - normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - normalized_voting_score - :math.pow(2, min(1023.5, tokens / 50)) - end - - @doc """ + """ + def compute_relative_score(contest_voting_question_id) do + # query all records from submission votes tied to the question id -> + # map score to user id -> + # store as grade -> + # query grade for contest question id. + eligible_votes = + SubmissionVotes + |> where(question_id: ^contest_voting_question_id) + |> where([sv], not is_nil(sv.score)) + |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id) + |> select( + [sv, ans], + %{ans_id: ans.id, score: sv.score, ans: ans.answer["code"]} + ) + |> Repo.all() + + entry_scores = map_eligible_votes_to_entry_score(eligible_votes) + + entry_scores + |> Enum.map(fn {ans_id, relative_score} -> + %Answer{id: ans_id} + |> Answer.contest_score_update_changeset(%{ + relative_score: relative_score + }) + end) + |> Enum.map(fn changeset -> + op_key = "answer_#{changeset.data.id}" + Multi.update(Multi.new(), op_key, changeset) + end) + |> Enum.reduce(Multi.new(), &Multi.append/2) + |> Repo.transaction() + end + + defp map_eligible_votes_to_entry_score(eligible_votes) do + # converts eligible votes to the {total cumulative score, number of votes, tokens} + entry_vote_data = + Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> + {prev_score, prev_count, _ans_tokens} = Map.get(tracker, ans_id, {0, 0, 0}) + + Map.put( + tracker, + ans_id, + # assume each voter is assigned 10 entries which will make it fair. + {prev_score + score, prev_count + 1, Lexer.count_tokens(ans)} + ) + end) + + # calculate the score based on formula {ans_id, score} + Enum.map( + entry_vote_data, + fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> + {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens)} + end + ) + end + + # Calculate the score based on formula + # score(v,t) = v - 2^(t/50) where v is the normalized_voting_score + # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 + defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do + normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 + normalized_voting_score - :math.pow(2, min(1023.5, tokens / 50)) + end + + @doc """ Function returning submissions under a grader. This function returns only the fields that are exposed in the /grading endpoint. The reason we select only those fields is to reduce the memory usage especially when the number of submissions is large i.e. > 25000 submissions. - + The input parameters are the user and group_only. group_only is used to check whether only the groups under the grader should be returned. The parameter is a boolean which is false by default. - + The return value is {:ok, submissions} if no errors, else it is {:error, {:forbidden, "Forbidden."}} - """ - @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: - {:ok, String.t()} - def all_submissions_by_grader_for_index( - grader = %CourseRegistration{course_id: course_id}, - group_only \\ false, - ungraded_only \\ false - ) do - show_all = not group_only - - group_where = - if show_all, - do: "", - else: - "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" - - ungraded_where = - if ungraded_only, - do: "where s.\"gradedCount\" < assts.\"questionCount\"", - else: "" - - params = if show_all, do: [course_id], else: [course_id, grader.id] - - # We bypass Ecto here and use a raw query to generate JSON directly from - # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. - - case Repo.query( - """ + """ + @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: + {:ok, String.t()} + def all_submissions_by_grader_for_index( + grader = %CourseRegistration{course_id: course_id}, + group_only \\ false, + ungraded_only \\ false + ) do + show_all = not group_only + + group_where = + if show_all, + do: "", + else: + "where s.student_id in (select cr.id from course_registrations cr inner join groups g on cr.group_id = g.id where g.leader_id = $2) or s.student_id = $2" + + ungraded_where = + if ungraded_only, + do: "where s.\"gradedCount\" < assts.\"questionCount\"", + else: "" + + params = if show_all, do: [course_id], else: [course_id, grader.id] + + # We bypass Ecto here and use a raw query to generate JSON directly from + # PostgreSQL, because doing it in Elixir/Erlang is too inefficient. + + case Repo.query( + """ select json_agg(q)::TEXT from ( select @@ -1280,412 +1286,412 @@ defmodule Cadet.Assessments do users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id #{ungraded_where} ) q - """, - params - ) do - {:ok, %{rows: [[nil]]}} -> {:ok, "[]"} - {:ok, %{rows: [[json]]}} -> {:ok, json} - end - end - - @spec get_answers_in_submission(integer() | String.t()) :: - {:ok, [Answer.t()]} | {:error, {:bad_request, String.t()}} - def get_answers_in_submission(id) when is_ecto_id(id) do - answer_query = - Answer - |> where(submission_id: ^id) - |> join(:inner, [a], q in assoc(a, :question)) - |> join(:inner, [_, q], ast in assoc(q, :assessment)) - |> join(:inner, [a, ..., ast], ac in assoc(ast, :config)) - |> join(:left, [a, ...], g in assoc(a, :grader)) - |> join(:left, [a, ..., g], gu in assoc(g, :user)) - |> join(:inner, [a, ...], s in assoc(a, :submission)) - |> join(:inner, [a, ..., s], st in assoc(s, :student)) - |> join(:inner, [a, ..., st], u in assoc(st, :user)) - |> preload([_, q, ast, ac, g, gu, s, st, u], - question: {q, assessment: {ast, config: ac}}, - grader: {g, user: gu}, - submission: {s, student: {st, user: u}} - ) - - answers = - answer_query - |> Repo.all() - |> Enum.sort_by(& &1.question.display_order) - |> Enum.map(fn ans -> - if ans.question.type == :voting do - empty_contest_entries = Map.put(ans.question.question, :contest_entries, []) - empty_contest_leaderboard = Map.put(empty_contest_entries, :contest_leaderboard, []) - question = Map.put(ans.question, :question, empty_contest_leaderboard) - Map.put(ans, :question, question) - else - ans - end - end) - - if answers == [] do - {:error, {:bad_request, "Submission is not found."}} - else - {:ok, answers} - end - end - - defp is_fully_graded?(%Answer{submission_id: submission_id}) do - submission = - Submission - |> Repo.get_by(id: submission_id) - - question_count = - Question - |> where(assessment_id: ^submission.assessment_id) - |> select([q], count(q.id)) - |> Repo.one() - - graded_count = - Answer - |> where([a], submission_id: ^submission_id) - |> where([a], not is_nil(a.grader_id)) - |> select([a], count(a.id)) - |> Repo.one() - - question_count == graded_count - end - - @spec update_grading_info( - %{submission_id: integer() | String.t(), question_id: integer() | String.t()}, - %{}, - CourseRegistration.t() - ) :: - {:ok, nil} - | {:error, {:forbidden | :bad_request | :internal_server_error, String.t()}} - def update_grading_info( - %{submission_id: submission_id, question_id: question_id}, - attrs, - %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) and is_ecto_id(question_id) do - attrs = Map.put(attrs, "grader_id", grader_id) - - answer_query = - Answer - |> where(submission_id: ^submission_id) - |> where(question_id: ^question_id) - - answer_query = - answer_query - |> join(:inner, [a], s in assoc(a, :submission)) - |> preload([_, s], submission: s) - - answer = Repo.one(answer_query) - - is_own_submission = grader_id == answer.submission.student_id - - with {:answer_found?, true} <- {:answer_found?, is_map(answer)}, - {:status, true} <- - {:status, answer.submission.status == :submitted or is_own_submission}, - {:valid, changeset = %Ecto.Changeset{valid?: true}} <- - {:valid, Answer.grading_changeset(answer, attrs)}, - {:ok, _} <- Repo.update(changeset) do - if is_fully_graded?(answer) and not is_own_submission do - # Every answer in this submission has been graded manually - Notifications.write_notification_when_graded(submission_id, :graded) - else - {:ok, nil} - end - else - {:answer_found?, false} -> - {:error, {:bad_request, "Answer not found or user not permitted to grade."}} - - {:valid, changeset} -> - {:error, {:bad_request, full_error_messages(changeset)}} - - {:status, _} -> - {:error, {:method_not_allowed, "Submission is not submitted yet."}} - - {:error, _} -> - {:error, {:internal_server_error, "Please try again later."}} - end - end - - def update_grading_info( - _, - _, - _ - ) do - {:error, {:forbidden, "User is not permitted to grade."}} - end - - @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) :: - {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} - def force_regrade_submission( - submission_id, - _requesting_user = %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) do - with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)}, - {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do - GradingJob.force_grade_individual_submission(sub, true) - {:ok, nil} - else - {:get, nil} -> - {:error, {:not_found, "Submission not found"}} - - {:status, false} -> - {:error, {:bad_request, "Submission not submitted yet"}} - end - end - - def force_regrade_submission(_, _) do - {:error, {:forbidden, "User is not permitted to grade."}} - end - - @spec force_regrade_answer( - integer() | String.t(), - integer() | String.t(), - CourseRegistration.t() - ) :: - {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} - def force_regrade_answer( - submission_id, - question_id, - _requesting_user = %CourseRegistration{id: grader_id} - ) - when is_ecto_id(submission_id) and is_ecto_id(question_id) do - answer = - Answer - |> where(submission_id: ^submission_id, question_id: ^question_id) - |> preload([:question, :submission]) - |> Repo.one() - - with {:get, answer} when not is_nil(answer) <- {:get, answer}, - {:status, true} <- - {:status, - answer.submission.student_id == grader_id or answer.submission.status == :submitted} do - GradingJob.grade_answer(answer, answer.question, true) - {:ok, nil} - else - {:get, nil} -> - {:error, {:not_found, "Answer not found"}} - - {:status, false} -> - {:error, {:bad_request, "Submission not submitted yet"}} - end - end - - def force_regrade_answer(_, _, _) do - {:error, {:forbidden, "User is not permitted to grade."}} - end - - defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - submission = - Submission - |> where(student_id: ^cr.id) - |> where(assessment_id: ^assessment.id) - |> Repo.one() - - if submission do - {:ok, submission} - else - {:error, nil} - end - end - - # Checks if an assessment is open and published. - @spec is_open?(Assessment.t()) :: boolean() - def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do - Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published - end - - @spec get_group_grading_summary(integer()) :: - {:ok, [String.t(), ...], []} - def get_group_grading_summary(course_id) do - subs = - Answer - |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id) - |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id) - |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id) - |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id) - |> where( - [ans, s, st, a, ac], - not is_nil(st.group_id) and s.status == ^:submitted and - ac.show_grading_summary and a.course_id == ^course_id - ) - |> group_by([ans, s, st, a, ac], s.id) - |> select([ans, s, st, a, ac], %{ - group_id: max(st.group_id), - config_id: max(ac.id), - config_type: max(ac.type), - num_submitted: count(), - num_ungraded: filter(count(), is_nil(ans.grader_id)) - }) - - raw_data = - subs - |> subquery() - |> join(:left, [t], g in Group, on: t.group_id == g.id) - |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id) - |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id) - |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name]) - |> select([t, g, l, lu], %{ - group_name: g.name, - leader_name: lu.name, - config_id: t.config_id, - config_type: t.config_type, - ungraded: filter(count(), t.num_ungraded > 0), - submitted: count() - }) - |> Repo.all() - - showing_configs = - AssessmentConfig - |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary) - |> order_by(:order) - |> group_by([ac], ac.id) - |> select([ac], %{ - id: ac.id, - type: ac.type - }) - |> Repo.all() - - data_by_groups = - raw_data - |> Enum.reduce(%{}, fn raw, acc -> - if Map.has_key?(acc, raw.group_name) do - acc - |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) - |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) - else - acc - |> put_in([raw.group_name], %{}) - |> put_in([raw.group_name, "groupName"], raw.group_name) - |> put_in([raw.group_name, "leaderName"], raw.leader_name) - |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) - |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) - end - end) - - headings = - showing_configs - |> Enum.reduce([], fn config, acc -> - acc ++ ["submitted" <> config.type, "ungraded" <> config.type] - end) - - default_row_data = - headings - |> Enum.reduce(%{}, fn heading, acc -> - put_in(acc, [heading], 0) - end) - - rows = data_by_groups |> Enum.map(fn {_k, row} -> Map.merge(default_row_data, row) end) - cols = ["groupName", "leaderName"] ++ headings - - {:ok, cols, rows} - end - - defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - %Submission{} - |> Submission.changeset(%{student: cr, assessment: assessment}) - |> Repo.insert() - |> case do - {:ok, submission} -> {:ok, submission} - {:error, _} -> {:error, :race_condition} - end - end - - defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do - case find_submission(cr, assessment) do - {:ok, submission} -> {:ok, submission} - {:error, _} -> create_empty_submission(cr, assessment) - end - end - - defp insert_or_update_answer( - submission = %Submission{}, - question = %Question{}, - raw_answer, - course_reg_id - ) do - answer_content = build_answer_content(raw_answer, question.type) - - if question.type == :voting do - insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content) - else - answer_changeset = - %Answer{} - |> Answer.changeset(%{ - answer: answer_content, - question_id: question.id, - submission_id: submission.id, - type: question.type - }) - - Repo.insert( - answer_changeset, - on_conflict: [set: [answer: get_change(answer_changeset, :answer)]], - conflict_target: [:submission_id, :question_id] - ) - end - end - - def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do - set_score_to_nil = - SubmissionVotes - |> where(voter_id: ^course_reg_id, question_id: ^question_id) - - voting_multi = - Multi.new() - |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil]) - - answer_content - |> Enum.with_index(1) - |> Enum.reduce(voting_multi, fn {entry, index}, multi -> - multi - |> Multi.run("update#{index}", fn _repo, _ -> - SubmissionVotes - |> Repo.get_by( - voter_id: course_reg_id, - submission_id: entry.submission_id - ) - |> SubmissionVotes.changeset(%{score: entry.score}) - |> Repo.insert_or_update() - end) - end) - |> Multi.run("insert into answer table", fn _repo, _ -> - Answer - |> Repo.get_by(submission_id: submission_id, question_id: question_id) - |> case do - nil -> - Repo.insert(%Answer{ - answer: %{completed: true}, - submission_id: submission_id, - question_id: question_id, - type: :voting - }) - - _ -> - {:ok, nil} - end - end) - |> Repo.transaction() - |> case do - {:ok, _result} -> {:ok, nil} - {:error, _name, _changeset, _error} -> {:error, :invalid_vote} - end - end - - defp build_answer_content(raw_answer, question_type) do - case question_type do - :mcq -> - %{choice_id: raw_answer} - - :programming -> - %{code: raw_answer} - - :voting -> - raw_answer - |> Enum.map(fn ans -> - for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value} - end) - end - end -end + """, + params + ) do + {:ok, %{rows: [[nil]]}} -> {:ok, "[]"} + {:ok, %{rows: [[json]]}} -> {:ok, json} + end + end + + @spec get_answers_in_submission(integer() | String.t()) :: + {:ok, [Answer.t()]} | {:error, {:bad_request, String.t()}} + def get_answers_in_submission(id) when is_ecto_id(id) do + answer_query = + Answer + |> where(submission_id: ^id) + |> join(:inner, [a], q in assoc(a, :question)) + |> join(:inner, [_, q], ast in assoc(q, :assessment)) + |> join(:inner, [a, ..., ast], ac in assoc(ast, :config)) + |> join(:left, [a, ...], g in assoc(a, :grader)) + |> join(:left, [a, ..., g], gu in assoc(g, :user)) + |> join(:inner, [a, ...], s in assoc(a, :submission)) + |> join(:inner, [a, ..., s], st in assoc(s, :student)) + |> join(:inner, [a, ..., st], u in assoc(st, :user)) + |> preload([_, q, ast, ac, g, gu, s, st, u], + question: {q, assessment: {ast, config: ac}}, + grader: {g, user: gu}, + submission: {s, student: {st, user: u}} + ) + + answers = + answer_query + |> Repo.all() + |> Enum.sort_by(& &1.question.display_order) + |> Enum.map(fn ans -> + if ans.question.type == :voting do + empty_contest_entries = Map.put(ans.question.question, :contest_entries, []) + empty_contest_leaderboard = Map.put(empty_contest_entries, :contest_leaderboard, []) + question = Map.put(ans.question, :question, empty_contest_leaderboard) + Map.put(ans, :question, question) + else + ans + end + end) + + if answers == [] do + {:error, {:bad_request, "Submission is not found."}} + else + {:ok, answers} + end + end + + defp is_fully_graded?(%Answer{submission_id: submission_id}) do + submission = + Submission + |> Repo.get_by(id: submission_id) + + question_count = + Question + |> where(assessment_id: ^submission.assessment_id) + |> select([q], count(q.id)) + |> Repo.one() + + graded_count = + Answer + |> where([a], submission_id: ^submission_id) + |> where([a], not is_nil(a.grader_id)) + |> select([a], count(a.id)) + |> Repo.one() + + question_count == graded_count + end + + @spec update_grading_info( + %{submission_id: integer() | String.t(), question_id: integer() | String.t()}, + %{}, + CourseRegistration.t() + ) :: + {:ok, nil} + | {:error, {:forbidden | :bad_request | :internal_server_error, String.t()}} + def update_grading_info( + %{submission_id: submission_id, question_id: question_id}, + attrs, + %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) and is_ecto_id(question_id) do + attrs = Map.put(attrs, "grader_id", grader_id) + + answer_query = + Answer + |> where(submission_id: ^submission_id) + |> where(question_id: ^question_id) + + answer_query = + answer_query + |> join(:inner, [a], s in assoc(a, :submission)) + |> preload([_, s], submission: s) + + answer = Repo.one(answer_query) + + is_own_submission = grader_id == answer.submission.student_id + + with {:answer_found?, true} <- {:answer_found?, is_map(answer)}, + {:status, true} <- + {:status, answer.submission.status == :submitted or is_own_submission}, + {:valid, changeset = %Ecto.Changeset{valid?: true}} <- + {:valid, Answer.grading_changeset(answer, attrs)}, + {:ok, _} <- Repo.update(changeset) do + if is_fully_graded?(answer) and not is_own_submission do + # Every answer in this submission has been graded manually + Notifications.write_notification_when_graded(submission_id, :graded) + else + {:ok, nil} + end + else + {:answer_found?, false} -> + {:error, {:bad_request, "Answer not found or user not permitted to grade."}} + + {:valid, changeset} -> + {:error, {:bad_request, full_error_messages(changeset)}} + + {:status, _} -> + {:error, {:method_not_allowed, "Submission is not submitted yet."}} + + {:error, _} -> + {:error, {:internal_server_error, "Please try again later."}} + end + end + + def update_grading_info( + _, + _, + _ + ) do + {:error, {:forbidden, "User is not permitted to grade."}} + end + + @spec force_regrade_submission(integer() | String.t(), CourseRegistration.t()) :: + {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} + def force_regrade_submission( + submission_id, + _requesting_user = %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) do + with {:get, sub} when not is_nil(sub) <- {:get, Repo.get(Submission, submission_id)}, + {:status, true} <- {:status, sub.student_id == grader_id or sub.status == :submitted} do + GradingJob.force_grade_individual_submission(sub, true) + {:ok, nil} + else + {:get, nil} -> + {:error, {:not_found, "Submission not found"}} + + {:status, false} -> + {:error, {:bad_request, "Submission not submitted yet"}} + end + end + + def force_regrade_submission(_, _) do + {:error, {:forbidden, "User is not permitted to grade."}} + end + + @spec force_regrade_answer( + integer() | String.t(), + integer() | String.t(), + CourseRegistration.t() + ) :: + {:ok, nil} | {:error, {:forbidden | :not_found, String.t()}} + def force_regrade_answer( + submission_id, + question_id, + _requesting_user = %CourseRegistration{id: grader_id} + ) + when is_ecto_id(submission_id) and is_ecto_id(question_id) do + answer = + Answer + |> where(submission_id: ^submission_id, question_id: ^question_id) + |> preload([:question, :submission]) + |> Repo.one() + + with {:get, answer} when not is_nil(answer) <- {:get, answer}, + {:status, true} <- + {:status, + answer.submission.student_id == grader_id or answer.submission.status == :submitted} do + GradingJob.grade_answer(answer, answer.question, true) + {:ok, nil} + else + {:get, nil} -> + {:error, {:not_found, "Answer not found"}} + + {:status, false} -> + {:error, {:bad_request, "Submission not submitted yet"}} + end + end + + def force_regrade_answer(_, _, _) do + {:error, {:forbidden, "User is not permitted to grade."}} + end + + defp find_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + submission = + Submission + |> where(student_id: ^cr.id) + |> where(assessment_id: ^assessment.id) + |> Repo.one() + + if submission do + {:ok, submission} + else + {:error, nil} + end + end + + # Checks if an assessment is open and published. + @spec is_open?(Assessment.t()) :: boolean() + def is_open?(%Assessment{open_at: open_at, close_at: close_at, is_published: is_published}) do + Timex.between?(Timex.now(), open_at, close_at, inclusive: :start) and is_published + end + + @spec get_group_grading_summary(integer()) :: + {:ok, [String.t(), ...], []} + def get_group_grading_summary(course_id) do + subs = + Answer + |> join(:left, [ans], s in Submission, on: s.id == ans.submission_id) + |> join(:left, [ans, s], st in CourseRegistration, on: s.student_id == st.id) + |> join(:left, [ans, s, st], a in Assessment, on: a.id == s.assessment_id) + |> join(:inner, [ans, s, st, a], ac in AssessmentConfig, on: ac.id == a.config_id) + |> where( + [ans, s, st, a, ac], + not is_nil(st.group_id) and s.status == ^:submitted and + ac.show_grading_summary and a.course_id == ^course_id + ) + |> group_by([ans, s, st, a, ac], s.id) + |> select([ans, s, st, a, ac], %{ + group_id: max(st.group_id), + config_id: max(ac.id), + config_type: max(ac.type), + num_submitted: count(), + num_ungraded: filter(count(), is_nil(ans.grader_id)) + }) + + raw_data = + subs + |> subquery() + |> join(:left, [t], g in Group, on: t.group_id == g.id) + |> join(:left, [t, g], l in CourseRegistration, on: l.id == g.leader_id) + |> join(:left, [t, g, l], lu in User, on: lu.id == l.user_id) + |> group_by([t, g, l, lu], [t.group_id, t.config_id, t.config_type, g.name, lu.name]) + |> select([t, g, l, lu], %{ + group_name: g.name, + leader_name: lu.name, + config_id: t.config_id, + config_type: t.config_type, + ungraded: filter(count(), t.num_ungraded > 0), + submitted: count() + }) + |> Repo.all() + + showing_configs = + AssessmentConfig + |> where([ac], ac.course_id == ^course_id and ac.show_grading_summary) + |> order_by(:order) + |> group_by([ac], ac.id) + |> select([ac], %{ + id: ac.id, + type: ac.type + }) + |> Repo.all() + + data_by_groups = + raw_data + |> Enum.reduce(%{}, fn raw, acc -> + if Map.has_key?(acc, raw.group_name) do + acc + |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) + |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) + else + acc + |> put_in([raw.group_name], %{}) + |> put_in([raw.group_name, "groupName"], raw.group_name) + |> put_in([raw.group_name, "leaderName"], raw.leader_name) + |> put_in([raw.group_name, "ungraded" <> raw.config_type], raw.ungraded) + |> put_in([raw.group_name, "submitted" <> raw.config_type], raw.submitted) + end + end) + + headings = + showing_configs + |> Enum.reduce([], fn config, acc -> + acc ++ ["submitted" <> config.type, "ungraded" <> config.type] + end) + + default_row_data = + headings + |> Enum.reduce(%{}, fn heading, acc -> + put_in(acc, [heading], 0) + end) + + rows = data_by_groups |> Enum.map(fn {_k, row} -> Map.merge(default_row_data, row) end) + cols = ["groupName", "leaderName"] ++ headings + + {:ok, cols, rows} + end + + defp create_empty_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + %Submission{} + |> Submission.changeset(%{student: cr, assessment: assessment}) + |> Repo.insert() + |> case do + {:ok, submission} -> {:ok, submission} + {:error, _} -> {:error, :race_condition} + end + end + + defp find_or_create_submission(cr = %CourseRegistration{}, assessment = %Assessment{}) do + case find_submission(cr, assessment) do + {:ok, submission} -> {:ok, submission} + {:error, _} -> create_empty_submission(cr, assessment) + end + end + + defp insert_or_update_answer( + submission = %Submission{}, + question = %Question{}, + raw_answer, + course_reg_id + ) do + answer_content = build_answer_content(raw_answer, question.type) + + if question.type == :voting do + insert_or_update_voting_answer(submission.id, course_reg_id, question.id, answer_content) + else + answer_changeset = + %Answer{} + |> Answer.changeset(%{ + answer: answer_content, + question_id: question.id, + submission_id: submission.id, + type: question.type + }) + + Repo.insert( + answer_changeset, + on_conflict: [set: [answer: get_change(answer_changeset, :answer)]], + conflict_target: [:submission_id, :question_id] + ) + end + end + + def insert_or_update_voting_answer(submission_id, course_reg_id, question_id, answer_content) do + set_score_to_nil = + SubmissionVotes + |> where(voter_id: ^course_reg_id, question_id: ^question_id) + + voting_multi = + Multi.new() + |> Multi.update_all(:set_score_to_nil, set_score_to_nil, set: [score: nil]) + + answer_content + |> Enum.with_index(1) + |> Enum.reduce(voting_multi, fn {entry, index}, multi -> + multi + |> Multi.run("update#{index}", fn _repo, _ -> + SubmissionVotes + |> Repo.get_by( + voter_id: course_reg_id, + submission_id: entry.submission_id + ) + |> SubmissionVotes.changeset(%{score: entry.score}) + |> Repo.insert_or_update() + end) + end) + |> Multi.run("insert into answer table", fn _repo, _ -> + Answer + |> Repo.get_by(submission_id: submission_id, question_id: question_id) + |> case do + nil -> + Repo.insert(%Answer{ + answer: %{completed: true}, + submission_id: submission_id, + question_id: question_id, + type: :voting + }) + + _ -> + {:ok, nil} + end + end) + |> Repo.transaction() + |> case do + {:ok, _result} -> {:ok, nil} + {:error, _name, _changeset, _error} -> {:error, :invalid_vote} + end + end + + defp build_answer_content(raw_answer, question_type) do + case question_type do + :mcq -> + %{choice_id: raw_answer} + + :programming -> + %{code: raw_answer} + + :voting -> + raw_answer + |> Enum.map(fn ans -> + for {key, value} <- ans, into: %{}, do: {String.to_existing_atom(key), value} + end) + end + end +end From 3c1d31474d35f387c1de2b61be74e01c40b32060 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Fri, 15 Sep 2023 09:38:14 +0800 Subject: [PATCH 06/42] Add files via upload --- lib/cadet/jobs/autograder/utilities.ex | 131 +++++++++++++------------ 1 file changed, 67 insertions(+), 64 deletions(-) diff --git a/lib/cadet/jobs/autograder/utilities.ex b/lib/cadet/jobs/autograder/utilities.ex index fd91904ac..6dfd1b56f 100644 --- a/lib/cadet/jobs/autograder/utilities.ex +++ b/lib/cadet/jobs/autograder/utilities.ex @@ -1,65 +1,68 @@ -defmodule Cadet.Autograder.Utilities do - @moduledoc """ +defmodule Cadet.Autograder.Utilities do + @moduledoc """ This module defines functions that support the autograder functionality. - """ - use Cadet, :context - - require Logger - - import Ecto.Query - - alias Cadet.Accounts.CourseRegistration - alias Cadet.Assessments.{Answer, Assessment, Question, Submission} - - def dispatch_programming_answer(answer = %Answer{}, question = %Question{}, overwrite \\ false) do - # This should never fail - answer = - answer - |> Answer.autograding_changeset(%{autograding_status: :processing}) - |> Repo.update!() - - Que.add(Cadet.Autograder.LambdaWorker, %{ - question: question, - answer: answer, - overwrite: overwrite - }) - end - - def fetch_submissions(assessment_id, course_id) when is_ecto_id(assessment_id) do - CourseRegistration - |> where(role: "student", course_id: ^course_id) - |> join( - :left, - [cr], - s in Submission, - on: cr.id == s.student_id and s.assessment_id == ^assessment_id - ) - |> select([cr, s], %{student_id: cr.id, submission: s}) - |> Repo.all() - end - - def fetch_assessments_due_yesterday do - Assessment - |> where(is_published: true) - |> where([a], a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1)) - |> join(:inner, [a, c], q in assoc(a, :questions)) - |> preload([_, q], questions: q) - |> Repo.all() - |> Enum.map(&sort_assessment_questions(&1)) - end - - # fetch voting questions that are about to open the next day - def fetch_voting_questions do - Question - |> where(type: :voting) - |> join(:inner, [q], asst in assoc(q, :assessment)) - |> where([q, asst], asst.open_at > ^Timex.now() and asst.open_at <= ^Timex.shift(Timex.now(), days: 1)) - |> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id}) - |> Repo.all() - end - - def sort_assessment_questions(assessment = %Assessment{}) do - sorted_questions = Enum.sort_by(assessment.questions, & &1.id) - Map.put(assessment, :questions, sorted_questions) - end -end + """ + use Cadet, :context + + require Logger + + import Ecto.Query + + alias Cadet.Accounts.CourseRegistration + alias Cadet.Assessments.{Answer, Assessment, Question, Submission} + + def dispatch_programming_answer(answer = %Answer{}, question = %Question{}, overwrite \\ false) do + # This should never fail + answer = + answer + |> Answer.autograding_changeset(%{autograding_status: :processing}) + |> Repo.update!() + + Que.add(Cadet.Autograder.LambdaWorker, %{ + question: question, + answer: answer, + overwrite: overwrite + }) + end + + def fetch_submissions(assessment_id, course_id) when is_ecto_id(assessment_id) do + CourseRegistration + |> where(role: "student", course_id: ^course_id) + |> join( + :left, + [cr], + s in Submission, + on: cr.id == s.student_id and s.assessment_id == ^assessment_id + ) + |> select([cr, s], %{student_id: cr.id, submission: s}) + |> Repo.all() + end + + def fetch_assessments_due_yesterday do + Assessment + |> where(is_published: true) + |> where([a], a.close_at < ^Timex.now() and a.close_at >= ^Timex.shift(Timex.now(), days: -1)) + |> join(:inner, [a, c], q in assoc(a, :questions)) + |> preload([_, q], questions: q) + |> Repo.all() + |> Enum.map(&sort_assessment_questions(&1)) + end + + # fetch voting questions that are about to open the next day + def fetch_voting_questions do + Question + |> where(type: :voting) + |> join(:inner, [q], asst in assoc(q, :assessment)) + |> where( + [q, asst], + asst.open_at > ^Timex.now() and asst.open_at <= ^Timex.shift(Timex.now(), days: 1) + ) + |> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id}) + |> Repo.all() + end + + def sort_assessment_questions(assessment = %Assessment{}) do + sorted_questions = Enum.sort_by(assessment.questions, & &1.id) + Map.put(assessment, :questions, sorted_questions) + end +end From d39f5d909efed36890194a29b3f236c96de6021c Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Fri, 15 Sep 2023 10:01:34 +0800 Subject: [PATCH 07/42] Merge branch 'master' of https://github.com/kjw142857/backend-ContestVotingUpdate --- lib/cadet/assessments/assessments.ex | 214 ++++++++++++------------- lib/cadet/jobs/autograder/utilities.ex | 2 +- 2 files changed, 108 insertions(+), 108 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 0fa59bda2..d50beaaba 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1,7 +1,7 @@ defmodule Cadet.Assessments do @moduledoc """ - Assessments context contains domain logic for assessments management such as - missions, sidequests, paths, etc. + Assessments context contains domain logic for assessments management such as + missions, sidequests, paths, etc. """ use Cadet, [:context, :display] import Ecto.Query @@ -277,8 +277,8 @@ defmodule Cadet.Assessments do end @doc """ - Returns a list of assessments with all fields and an indicator showing whether it has been attempted - by the supplied user + Returns a list of assessments with all fields and an indicator showing whether it has been attempted + by the supplied user """ def all_assessments(cr = %CourseRegistration{}) do submission_aggregates = @@ -339,7 +339,7 @@ defmodule Cadet.Assessments do end @doc """ - The main function that inserts or updates assessments from the XML Parser + The main function that inserts or updates assessments from the XML Parser """ @spec insert_or_update_assessments_and_questions(map(), [map()], boolean()) :: {:ok, any()} @@ -515,7 +515,7 @@ defmodule Cadet.Assessments do end @doc """ - Generates and assigns contest entries for users with given usernames. + Generates and assigns contest entries for users with given usernames. """ def insert_voting( course_id, @@ -699,13 +699,13 @@ defmodule Cadet.Assessments do end @doc """ - Public internal api to submit new answers for a question. Possible return values are: - `{:ok, nil}` -> success - `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` - - Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: - `{:bad_request, "Missing or invalid parameter(s)"}` - + Public internal api to submit new answers for a question. Possible return values are: + `{:ok, nil}` -> success + `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` + + Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: + `{:bad_request, "Missing or invalid parameter(s)"}` + """ def answer_question( question = %Question{}, @@ -1014,9 +1014,9 @@ defmodule Cadet.Assessments do end @doc """ - Fetches top answers for the given question, based on the contest relative_score - - Used for contest leaderboard fetching + Fetches top answers for the given question, based on the contest relative_score + + Used for contest leaderboard fetching """ def fetch_top_relative_score_answers(question_id, number_of_answers) do Answer @@ -1045,7 +1045,7 @@ defmodule Cadet.Assessments do end @doc """ - Computes rolling leaderboard for contest votes that are still open. + Computes rolling leaderboard for contest votes that are still open. """ def update_rolling_contest_leaderboards do # 115 = 2 hours - 5 minutes is default. @@ -1072,7 +1072,7 @@ defmodule Cadet.Assessments do end @doc """ - Computes final leaderboard for contest votes that have closed. + Computes final leaderboard for contest votes that have closed. """ def update_final_contest_leaderboards do # 1435 = 24 hours - 5 minutes @@ -1103,8 +1103,8 @@ defmodule Cadet.Assessments do end @doc """ - Computes the current relative_score of each voting submission answer - based on current submitted votes. + Computes the current relative_score of each voting submission answer + based on current submitted votes. """ def compute_relative_score(contest_voting_question_id) do # query all records from submission votes tied to the question id -> @@ -1171,17 +1171,17 @@ defmodule Cadet.Assessments do end @doc """ - Function returning submissions under a grader. This function returns only the - fields that are exposed in the /grading endpoint. The reason we select only - those fields is to reduce the memory usage especially when the number of - submissions is large i.e. > 25000 submissions. - - The input parameters are the user and group_only. group_only is used to check - whether only the groups under the grader should be returned. The parameter is - a boolean which is false by default. - - The return value is {:ok, submissions} if no errors, else it is {:error, - {:forbidden, "Forbidden."}} + Function returning submissions under a grader. This function returns only the + fields that are exposed in the /grading endpoint. The reason we select only + those fields is to reduce the memory usage especially when the number of + submissions is large i.e. > 25000 submissions. + + The input parameters are the user and group_only. group_only is used to check + whether only the groups under the grader should be returned. The parameter is + a boolean which is false by default. + + The return value is {:ok, submissions} if no errors, else it is {:error, + {:forbidden, "Forbidden."}} """ @spec all_submissions_by_grader_for_index(CourseRegistration.t()) :: {:ok, String.t()} @@ -1210,82 +1210,82 @@ defmodule Cadet.Assessments do case Repo.query( """ - select json_agg(q)::TEXT from - ( - select - s.id, - s.status, - s."unsubmittedAt", - s.xp, - s."xpAdjustment", - s."xpBonus", - s."gradedCount", - assts.jsn as assessment, - students.jsn as student, - unsubmitters.jsn as "unsubmittedBy" - from - (select - s.id, - s.student_id, - s.assessment_id, - s.status, - s.unsubmitted_at as "unsubmittedAt", - s.unsubmitted_by_id, - sum(ans.xp) as xp, - sum(ans.xp_adjustment) as "xpAdjustment", - s.xp_bonus as "xpBonus", - count(ans.id) filter (where ans.grader_id is not null) as "gradedCount" - from submissions s - left join - answers ans on s.id = ans.submission_id - #{group_where} - group by s.id) s - inner join - (select - a.id, a."questionCount", to_json(a) as jsn - from - (select - a.id, - a.title, - a.number as "assessmentNumber", - bool_or(ac.is_manually_graded) as "isManuallyGraded", - max(ac.type) as "type", - sum(q.max_xp) as "maxXp", - count(q.id) as "questionCount" - from assessments a - left join - questions q on a.id = q.assessment_id - inner join - assessment_configs ac on ac.id = a.config_id - where a.course_id = $1 - group by a.id) a) assts on assts.id = s.assessment_id - inner join - (select - cr.id, to_json(cr) as jsn - from - (select - cr.id, - u.name as "name", - u.username as "username", - g.name as "groupName", - g.leader_id as "groupLeaderId" - from course_registrations cr - left join - groups g on g.id = cr.group_id - inner join - users u on u.id = cr.user_id) cr) students on students.id = s.student_id - left join - (select - cr.id, to_json(cr) as jsn - from - (select - cr.id, - u.name - from course_registrations cr - inner join - users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id - #{ungraded_where} - ) q + select json_agg(q)::TEXT from + ( + select + s.id, + s.status, + s."unsubmittedAt", + s.xp, + s."xpAdjustment", + s."xpBonus", + s."gradedCount", + assts.jsn as assessment, + students.jsn as student, + unsubmitters.jsn as "unsubmittedBy" + from + (select + s.id, + s.student_id, + s.assessment_id, + s.status, + s.unsubmitted_at as "unsubmittedAt", + s.unsubmitted_by_id, + sum(ans.xp) as xp, + sum(ans.xp_adjustment) as "xpAdjustment", + s.xp_bonus as "xpBonus", + count(ans.id) filter (where ans.grader_id is not null) as "gradedCount" + from submissions s + left join + answers ans on s.id = ans.submission_id + #{group_where} + group by s.id) s + inner join + (select + a.id, a."questionCount", to_json(a) as jsn + from + (select + a.id, + a.title, + a.number as "assessmentNumber", + bool_or(ac.is_manually_graded) as "isManuallyGraded", + max(ac.type) as "type", + sum(q.max_xp) as "maxXp", + count(q.id) as "questionCount" + from assessments a + left join + questions q on a.id = q.assessment_id + inner join + assessment_configs ac on ac.id = a.config_id + where a.course_id = $1 + group by a.id) a) assts on assts.id = s.assessment_id + inner join + (select + cr.id, to_json(cr) as jsn + from + (select + cr.id, + u.name as "name", + u.username as "username", + g.name as "groupName", + g.leader_id as "groupLeaderId" + from course_registrations cr + left join + groups g on g.id = cr.group_id + inner join + users u on u.id = cr.user_id) cr) students on students.id = s.student_id + left join + (select + cr.id, to_json(cr) as jsn + from + (select + cr.id, + u.name + from course_registrations cr + inner join + users u on u.id = cr.user_id) cr) unsubmitters on s.unsubmitted_by_id = unsubmitters.id + #{ungraded_where} + ) q """, params ) do diff --git a/lib/cadet/jobs/autograder/utilities.ex b/lib/cadet/jobs/autograder/utilities.ex index 6dfd1b56f..5f5a5b7b0 100644 --- a/lib/cadet/jobs/autograder/utilities.ex +++ b/lib/cadet/jobs/autograder/utilities.ex @@ -1,6 +1,6 @@ defmodule Cadet.Autograder.Utilities do @moduledoc """ - This module defines functions that support the autograder functionality. + This module defines functions that support the autograder functionality. """ use Cadet, :context From 3248a3cb5a4c58fbc1eecc9c7034b0844201dd23 Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Sat, 16 Sep 2023 00:27:11 +0800 Subject: [PATCH 08/42] 1 --- lib/cadet/assessments/assessments.ex | 234 +++++++++++--------- lib/cadet/jobs/autograder/utilities.ex | 13 -- test/cadet/assessments/assessments_test.exs | 32 ++- 3 files changed, 154 insertions(+), 125 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index d50beaaba..3f102ae1f 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -23,6 +23,7 @@ defmodule Cadet.Assessments do alias Cadet.ProgramAnalysis.Lexer alias Ecto.Multi alias Cadet.Incentives.Achievements + alias Timex.Duration require Decimal @@ -502,9 +503,9 @@ defmodule Cadet.Assessments do def update_final_contest_entries do # 1435 = 1 day - 5 minutes - if Log.log_execution("update_final_contest_entries", Timex.Duration.from_minutes(1435)) do + if Log.log_execution("update_final_contest_entries", Duration.from_minutes(1435)) do Logger.info("Started update of contest entry pools") - questions = Utilities.fetch_voting_questions() + questions = fetch_voting_questions() for q <- questions do insert_voting(q.course_id, q.question.contest_number, q.question_id) @@ -514,6 +515,19 @@ defmodule Cadet.Assessments do end end + # fetch voting questions that are about to open the next day + def fetch_voting_questions do + Question + |> where(type: :voting) + |> join(:inner, [q], asst in assoc(q, :assessment)) + |> where( + [q, asst], + asst.open_at > ^Timex.now() and asst.open_at <= ^Timex.shift(Timex.now(), days: 1) + ) + |> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id}) + |> Repo.all() + end + @doc """ Generates and assigns contest entries for users with given usernames. """ @@ -537,110 +551,118 @@ defmodule Cadet.Assessments do {:error, error_changeset} else if contest_assessment.close_at < Timex.now() do - # Returns contest submission ids with answers that contain "return" - contest_submission_ids = - Submission - |> join(:inner, [s], ans in assoc(s, :answers)) - |> join(:inner, [s, ans], cr in assoc(s, :student)) - |> where([s, ans, cr], cr.role == "student") - |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") - |> where( - [_, ans, cr], - fragment( - "?->>'code' like ?", - ans.answer, - "%return%" - ) - ) - |> select([s, _ans], {s.student_id, s.id}) - |> Repo.all() - |> Enum.into(%{}) - - contest_submission_ids_length = Enum.count(contest_submission_ids) + compile_entries(course_id, contest_assessment, question_id) + else + # contest has not closed, do nothing + {:ok, nil} + end + end + end - voter_ids = - CourseRegistration - |> where(role: "student", course_id: ^course_id) - |> select([cr], cr.id) - |> Repo.all() + def compile_entries( + course_id, + contest_assessment, + question_id + ) do + # Returns contest submission ids with answers that contain "return" + contest_submission_ids = + Submission + |> join(:inner, [s], ans in assoc(s, :answers)) + |> join(:inner, [s, ans], cr in assoc(s, :student)) + |> where([s, ans, cr], cr.role == "student") + |> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted") + |> where( + [_, ans, cr], + fragment( + "?->>'code' like ?", + ans.answer, + "%return%" + ) + ) + |> select([s, _ans], {s.student_id, s.id}) + |> Repo.all() + |> Enum.into(%{}) - votes_per_user = min(contest_submission_ids_length, 10) + contest_submission_ids_length = Enum.count(contest_submission_ids) - votes_per_submission = - if Enum.empty?(contest_submission_ids) do - 0 - else - trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) - end + voter_ids = + CourseRegistration + |> where(role: "student", course_id: ^course_id) + |> select([cr], cr.id) + |> Repo.all() - submission_id_list = - contest_submission_ids - |> Enum.map(fn {_, s_id} -> s_id end) - |> Enum.shuffle() - |> List.duplicate(votes_per_submission) - |> List.flatten() - - {_submission_map, submission_votes_changesets} = - voter_ids - |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> - {submission_list, submission_votes} = acc - - user_contest_submission_id = Map.get(contest_submission_ids, voter_id) - - {votes, rest} = - submission_list - |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> - {user_votes, submissions} = acc - - max_votes = - if votes_per_user == contest_submission_ids_length and - not is_nil(user_contest_submission_id) do - # no. of submssions is less than 10. Unable to find - votes_per_user - 1 - else - votes_per_user - end - - if MapSet.size(user_votes) < max_votes do - if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do - new_user_votes = MapSet.put(user_votes, s_id) - new_submissions = List.delete(submissions, s_id) - {:cont, {new_user_votes, new_submissions}} - else - {:cont, {user_votes, submissions}} - end - else - {:halt, acc} - end - end) - - votes = MapSet.to_list(votes) - - new_submission_votes = - votes - |> Enum.map(fn s_id -> - %SubmissionVotes{ - voter_id: voter_id, - submission_id: s_id, - question_id: question_id - } - end) - |> Enum.concat(submission_votes) - - {rest, new_submission_votes} - end) + votes_per_user = min(contest_submission_ids_length, 10) - submission_votes_changesets - |> Enum.with_index() - |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> - Multi.insert(multi, Integer.to_string(index), changeset) - end) - |> Repo.transaction() + votes_per_submission = + if Enum.empty?(contest_submission_ids) do + 0 else - # contest has not closed, do nothing - {:ok, nil} + trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length)) end - end + + submission_id_list = + contest_submission_ids + |> Enum.map(fn {_, s_id} -> s_id end) + |> Enum.shuffle() + |> List.duplicate(votes_per_submission) + |> List.flatten() + + {_submission_map, submission_votes_changesets} = + voter_ids + |> Enum.reduce({submission_id_list, []}, fn voter_id, acc -> + {submission_list, submission_votes} = acc + + user_contest_submission_id = Map.get(contest_submission_ids, voter_id) + + {votes, rest} = + submission_list + |> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc -> + {user_votes, submissions} = acc + + max_votes = + if votes_per_user == contest_submission_ids_length and + not is_nil(user_contest_submission_id) do + # no. of submssions is less than 10. Unable to find + votes_per_user - 1 + else + votes_per_user + end + + if MapSet.size(user_votes) < max_votes do + if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do + new_user_votes = MapSet.put(user_votes, s_id) + new_submissions = List.delete(submissions, s_id) + {:cont, {new_user_votes, new_submissions}} + else + {:cont, {user_votes, submissions}} + end + else + {:halt, acc} + end + end) + + votes = MapSet.to_list(votes) + + new_submission_votes = + votes + |> Enum.map(fn s_id -> + %SubmissionVotes{ + voter_id: voter_id, + submission_id: s_id, + question_id: question_id + } + end) + |> Enum.concat(submission_votes) + + {rest, new_submission_votes} + end) + + submission_votes_changesets + |> Enum.with_index() + |> Enum.reduce(Multi.new(), fn {changeset, index}, multi -> + Multi.insert(multi, Integer.to_string(index), changeset) + end) + |> Repo.transaction() end def update_assessment(id, params) when is_ecto_id(id) do @@ -702,10 +724,10 @@ defmodule Cadet.Assessments do Public internal api to submit new answers for a question. Possible return values are: `{:ok, nil}` -> success `{:error, error}` -> failed. `error` is in the format of `{http_response_code, error message}` - + Note: In the event of `find_or_create_submission` failing due to a race condition, error will be: `{:bad_request, "Missing or invalid parameter(s)"}` - + """ def answer_question( question = %Question{}, @@ -1015,7 +1037,7 @@ defmodule Cadet.Assessments do @doc """ Fetches top answers for the given question, based on the contest relative_score - + Used for contest leaderboard fetching """ def fetch_top_relative_score_answers(question_id, number_of_answers) do @@ -1049,7 +1071,7 @@ defmodule Cadet.Assessments do """ def update_rolling_contest_leaderboards do # 115 = 2 hours - 5 minutes is default. - if Log.log_execution("update_rolling_contest_leaderboards", Timex.Duration.from_minutes(115)) do + if Log.log_execution("update_rolling_contest_leaderboards", Duration.from_minutes(115)) do Logger.info("Started update_rolling_contest_leaderboards") voting_questions_to_update = fetch_active_voting_questions() @@ -1076,7 +1098,7 @@ defmodule Cadet.Assessments do """ def update_final_contest_leaderboards do # 1435 = 24 hours - 5 minutes - if Log.log_execution("update_final_contest_leaderboards", Timex.Duration.from_minutes(1435)) do + if Log.log_execution("update_final_contest_leaderboards", Duration.from_minutes(1435)) do Logger.info("Started update_final_contest_leaderboards") voting_questions_to_update = fetch_voting_questions_due_yesterday() @@ -1175,11 +1197,11 @@ defmodule Cadet.Assessments do fields that are exposed in the /grading endpoint. The reason we select only those fields is to reduce the memory usage especially when the number of submissions is large i.e. > 25000 submissions. - + The input parameters are the user and group_only. group_only is used to check whether only the groups under the grader should be returned. The parameter is a boolean which is false by default. - + The return value is {:ok, submissions} if no errors, else it is {:error, {:forbidden, "Forbidden."}} """ diff --git a/lib/cadet/jobs/autograder/utilities.ex b/lib/cadet/jobs/autograder/utilities.ex index 5f5a5b7b0..32208e0cf 100644 --- a/lib/cadet/jobs/autograder/utilities.ex +++ b/lib/cadet/jobs/autograder/utilities.ex @@ -48,19 +48,6 @@ defmodule Cadet.Autograder.Utilities do |> Enum.map(&sort_assessment_questions(&1)) end - # fetch voting questions that are about to open the next day - def fetch_voting_questions do - Question - |> where(type: :voting) - |> join(:inner, [q], asst in assoc(q, :assessment)) - |> where( - [q, asst], - asst.open_at > ^Timex.now() and asst.open_at <= ^Timex.shift(Timex.now(), days: 1) - ) - |> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id}) - |> Repo.all() - end - def sort_assessment_questions(assessment = %Assessment{}) do sorted_questions = Enum.sort_by(assessment.questions, & &1.id) Map.put(assessment, :questions, sorted_questions) diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 319f3aba4..0b0e339f2 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -148,9 +148,19 @@ defmodule Cadet.AssessmentsTest do describe "contest voting" do test "inserts votes into submission_votes table" do - contest_question = insert(:programming_question) - contest_assessment = contest_question.assessment - course = contest_question.assessment.course + course = insert(:course) + config = insert(:assessment_config) + # contest assessment that has closed + contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: -1), + course: course, + config: config + ) + + contest_question = insert(:programming_question, assessment: contest_assessment) voting_assessment = insert(:assessment, %{course: course}) question = @@ -225,9 +235,19 @@ defmodule Cadet.AssessmentsTest do end test "deletes submission_votes when assessment is deleted" do - contest_question = insert(:programming_question) - course = contest_question.assessment.course - config = contest_question.assessment.config + course = insert(:course) + config = insert(:assessment_config) + # contest assessment that has closed + contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: -1), + course: course, + config: config + ) + + contest_question = insert(:programming_question, assessment: contest_assessment) voting_assessment = insert(:assessment, %{course: course, config: config}) question = insert(:voting_question, assessment: voting_assessment) students = insert_list(5, :course_registration, %{role: :student, course: course}) From 85650130d705eb4fe1d3f446fc2ff767e4aa3c59 Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Sat, 16 Sep 2023 15:44:29 +0800 Subject: [PATCH 09/42] commit --- config/config.exs | 2 +- lib/cadet/assessments/assessments.ex | 18 ++++++++++-------- mix.exs | 2 +- mix.lock | 24 +++++++++++------------- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/config/config.exs b/config/config.exs index c998adfe2..bd6a2e551 100644 --- a/config/config.exs +++ b/config/config.exs @@ -25,7 +25,7 @@ config :cadet, Cadet.Jobs.Scheduler, # Compute rolling leaderboard every 2 hours {"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}}, # Collate contest entries that close in the previous day at 00:01 - {"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}} + {"15 13 * * *", {Cadet.Assessments, :update_final_contest_entries, []}} ] # Configures the endpoint diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 3f102ae1f..4d9a06bfa 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -505,7 +505,7 @@ defmodule Cadet.Assessments do # 1435 = 1 day - 5 minutes if Log.log_execution("update_final_contest_entries", Duration.from_minutes(1435)) do Logger.info("Started update of contest entry pools") - questions = fetch_voting_questions() + questions = fetch_unassigned_voting_questions() for q <- questions do insert_voting(q.course_id, q.question.contest_number, q.question_id) @@ -515,15 +515,17 @@ defmodule Cadet.Assessments do end end - # fetch voting questions that are about to open the next day - def fetch_voting_questions do + # fetch voting questions where entries have not been assigned + def fetch_unassigned_voting_questions do + voting_assigned_question_ids = + SubmissionVotes + |> select([v], v.question_id) + |> Repo.all() + Question |> where(type: :voting) + |> where([q], q.id not in ^voting_assigned_question_ids) |> join(:inner, [q], asst in assoc(q, :assessment)) - |> where( - [q, asst], - asst.open_at > ^Timex.now() and asst.open_at <= ^Timex.shift(Timex.now(), days: 1) - ) |> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id}) |> Repo.all() end @@ -550,7 +552,7 @@ defmodule Cadet.Assessments do {:error, error_changeset} else - if contest_assessment.close_at < Timex.now() do + if Timex.compare(contest_assessment.close_at, Timex.now()) < 0 do compile_entries(course_id, contest_assessment, question_id) else # contest has not closed, do nothing diff --git a/mix.exs b/mix.exs index d127c998a..39bfc1249 100644 --- a/mix.exs +++ b/mix.exs @@ -85,7 +85,7 @@ defmodule Cadet.Mixfile do # notifiations system dependencies {:phoenix_html, "~> 3.0"}, {:bamboo, "~> 2.3.0"}, - {:bamboo_ses, "~> 0.4.1"}, + {:bamboo_ses, "~> 0.3.0"}, {:bamboo_phoenix, "~> 1.0.0"}, {:oban, "~> 2.13"}, diff --git a/mix.lock b/mix.lock index 1f991ddc6..6fa3c0f0c 100644 --- a/mix.lock +++ b/mix.lock @@ -4,7 +4,7 @@ "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, "bamboo": {:hex, :bamboo, "2.3.0", "d2392a2cabe91edf488553d3c70638b532e8db7b76b84b0a39e3dfe492ffd6fc", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dd0037e68e108fd04d0e8773921512c940e35d981e097b5793543e3b2f9cd3f6"}, "bamboo_phoenix": {:hex, :bamboo_phoenix, "1.0.0", "f3cc591ffb163ed0bf935d256f1f4645cd870cf436545601215745fb9cc9953f", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6db88fbb26019c84a47994bb2bd879c0887c29ce6c559bc6385fd54eb8b37dee"}, - "bamboo_ses": {:hex, :bamboo_ses, "0.4.1", "632ac190fac92a94511add2eaa678ba095c32e67a0955c7f122460af8c0d8434", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:eiconv, ">= 0.0.0", [hex: :eiconv, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 1.2.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c6338270d605a71cf781bbf79d0793af8a6c8e07395d38e22686c67c318f701f"}, + "bamboo_ses": {:hex, :bamboo_ses, "0.3.2", "891bd2dcb191777d4cb714dca25c9a656c207a1d634ffad7e4173a1332cee6ce", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "0.2.3", [hex: :mail, repo: "hexpm", optional: false]}], "hexpm", "c3f9f58501106fdfba7d85de909bf3b5b02aae09b98080b94528fb607669658f"}, "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, @@ -22,14 +22,13 @@ "csv": {:hex, :csv, "2.5.0", "c47b5a5221bf2e56d6e8eb79e77884046d7fd516280dc7d9b674251e0ae46246", [:mix], [{:parallel_stream, "~> 1.0.4 or ~> 1.1.0", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm", "e821f541487045c7591a1963eeb42afff0dfa99bdcdbeb3410795a2f59c77d34"}, "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, - "dialyxir": {:hex, :dialyxir, "1.4.1", "a22ed1e7bd3a3e3f197b68d806ef66acb61ee8f57b3ac85fc5d57354c5482a93", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "84b795d6d7796297cca5a3118444b80c7d94f7ce247d49886e7c291e1ae49801"}, + "dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"}, "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm", "bbc7008b0161a6f130d8d903b5b3232351fccc9c31a991f8fcbf2a12ace22995"}, "ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.10.2", "6b98b46534b5c2f8b8b5f03f126e75e2a73c64f3c071149d32987a5378b0fdbd", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "68c018debca57cb9235e3889affdaec7a10616a4e3a80c99fa1d01fdafaa9007"}, - "eiconv": {:hex, :eiconv, "1.0.0", "ee1e47ee37799a05beff7a68d61f63cccc93101833c4fb94b454c23b12a21629", [:rebar3], [], "hexpm", "8c80851decf72fc4571a70278d7932e9a87437770322077ecf797533fbb792cd"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "ex_aws": {:hex, :ex_aws, "2.5.0", "1785e69350b16514c1049330537c7da10039b1a53e1d253bbd703b135174aec3", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "971b86e5495fc0ae1c318e35e23f389e74cf322f2c02d34037c6fc6d405006f1"}, + "ex_aws": {:hex, :ex_aws, "2.4.3", "6c6d88ba7b9c07e3b0f4b70406d5fccb9f5358f5ef18138f7bd396f7863e8255", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "67f61f8b6aec740150d483a21f551fabce26a481d9917305ed2bb47717007519"}, "ex_aws_lambda": {:hex, :ex_aws_lambda, "2.1.0", "f28bffae6dde34ba17ef815ef50a82a1e7f3af26d36fe31dbda3a7657e0de449", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "25608630b8b45fe22b0237696662f88be724bc89f77ee42708a5871511f531a0"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.4.0", "ce8decb6b523381812798396bc0e3aaa62282e1b40520125d1f4eff4abdff0f4", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "85dda6e27754d94582869d39cba3241d9ea60b6aa4167f9c88e309dc687e56bb"}, "ex_aws_secretsmanager": {:hex, :ex_aws_secretsmanager, "2.0.0", "deff8c12335f0160882afeb9687e55a97fddcd7d9a82fc3a6fbb270797374773", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "8b2838af536c32263ff797012b29e87bad73ef34f43cfa60ebca8e84576f6d45"}, @@ -38,13 +37,12 @@ "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, "ex_utils": {:hex, :ex_utils, "0.1.7", "2c133e0bcdc49a858cf8dacf893308ebc05bc5fba501dc3d2935e65365ec0bf3", [:mix], [], "hexpm", "66d4fe75285948f2d1e69c2a5ddd651c398c813574f8d36a9eef11dc20356ef6"}, "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"}, - "excoveralls": {:hex, :excoveralls, "0.17.1", "83fa7906ef23aa7fc8ad7ee469c357a63b1b3d55dd701ff5b9ce1f72442b2874", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "95bc6fda953e84c60f14da4a198880336205464e75383ec0f570180567985ae0"}, + "excoveralls": {:hex, :excoveralls, "0.16.1", "0bd42ed05c7d2f4d180331a20113ec537be509da31fed5c8f7047ce59ee5a7c5", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dae763468e2008cf7075a64cb1249c97cb4bc71e236c5c2b5e5cdf1cfa2bf138"}, "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"}, "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, "exvcr": {:hex, :exvcr, "0.14.4", "1aa5fe7d3f10b117251c158f8d28b39f7fc73d0a7628b2d0b75bf8cfb1111576", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:finch, "~> 0.16", [hex: :finch, repo: "hexpm", optional: true]}, {:httpoison, "~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4e600568c02ed29d46bc2e2c74927d172ba06658aa8b14705c0207363c44cc94"}, "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "gettext": {:hex, :gettext, "0.22.2", "6bfca374de34ecc913a28ba391ca184d88d77810a3e427afa8454a71a51341ac", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "8a2d389673aea82d7eae387e6a2ccc12660610080ae7beb19452cfdc1ec30f60"}, "git_hooks": {:hex, :git_hooks, "0.7.3", "09489e94d88dfc767662e22aff2b6208bd7cf555a19dd0e1477cca4683ce0701", [:mix], [{:blankable, "~> 1.0.0", [hex: :blankable, repo: "hexpm", optional: false]}, {:recase, "~> 0.7.0", [hex: :recase, repo: "hexpm", optional: false]}], "hexpm", "d6ddedeb4d3a8602bc3f84e087a38f6150a86d9e790628ed8bc70e6d90681659"}, @@ -68,13 +66,13 @@ "openid_connect": {:hex, :openid_connect, "0.2.2", "c05055363330deab39ffd89e609db6b37752f255a93802006d83b45596189c0b", [:mix], [{:httpoison, "~> 1.2", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "735769b6d592124b58edd0582554ce638524c0214cd783d8903d33357d74cc13"}, "parallel_stream": {:hex, :parallel_stream, "1.1.0", "f52f73eb344bc22de335992377413138405796e0d0ad99d995d9977ac29f1ca9", [:mix], [], "hexpm", "684fd19191aedfaf387bbabbeb8ff3c752f0220c8112eb907d797f4592d6e871"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, - "phoenix": {:hex, :phoenix, "1.7.7", "4cc501d4d823015007ba3cdd9c41ecaaf2ffb619d6fb283199fa8ddba89191e0", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "8966e15c395e5e37591b6ed0bd2ae7f48e961f0f60ac4c733f9566b519453085"}, + "phoenix": {:hex, :phoenix, "1.7.6", "61f0625af7c1d1923d582470446de29b008c0e07ae33d7a3859ede247ddaf59a", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f6b4be7780402bb060cbc6e83f1b6d3f5673b674ba73cc4a7dd47db0322dfb88"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.2", "b21bd01fdeffcfe2fab49e4942aa938b6d3e89e93a480d4aee58085560a0bc0d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "70242edd4601d50b69273b057ecf7b684644c19ee750989fd555625ae4ce8f5d"}, - "phoenix_html": {:hex, :phoenix_html, "3.3.2", "d6ce982c6d8247d2fc0defe625255c721fb8d5f1942c5ac051f6177bffa5973f", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "44adaf8e667c1c20fb9d284b6b0fa8dc7946ce29e81ce621860aa7e96de9a11d"}, + "phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.19.3", "3918c1b34df8ac71a9a636806ba5b7f053349a0392b312e16f35b0bf4d070aab", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "545626887948495fd8ea23d83b75bd7aaf9dc4221563e158d2c4b52ea1dd7e00"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_swagger": {:hex, :phoenix_swagger, "0.8.3", "298d6204802409d3b0b4fc1013873839478707cf3a62532a9e10fec0e26d0e37", [:mix], [{:ex_json_schema, "~> 0.7.1", [hex: :ex_json_schema, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "3bc0fa9f5b679b8a61b90a52b2c67dd932320e9a84a6f91a4af872a0ab367337"}, - "phoenix_template": {:hex, :phoenix_template, "1.0.3", "32de561eefcefa951aead30a1f94f1b5f0379bc9e340bb5c667f65f1edfa4326", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "16f4b6588a4152f3cc057b9d0c0ba7e82ee23afa65543da535313ad8d25d8e2c"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.1", "85f79e3ad1b0180abb43f9725973e3b8c2c3354a87245f91431eec60553ed3ef", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "157dc078f6226334c91cb32c1865bf3911686f8bcd6bcff86736f6253e6993ee"}, "phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"}, "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, @@ -84,15 +82,15 @@ "que": {:hex, :que, "0.10.1", "788ed0ec92ed69bdf9cfb29bf41a94ca6355b8d44959bd0669cf706e557ac891", [:mix], [{:ex_utils, "~> 0.1.6", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.3.0", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "a737b365253e75dbd24b2d51acc1d851049e87baae08cd0c94e2bc5cd65088d5"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"}, - "sentry": {:hex, :sentry, "8.1.0", "8d235b62fce5f8e067ea1644e30939405b71a5e1599d9529ff82899d11d03f2b", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "f9fc7641ef61e885510f5e5963c2948b9de1de597c63f781e9d3d6c9c8681ab4"}, + "sentry": {:hex, :sentry, "8.0.6", "c8de1bf0523bc120ec37d596c55260901029ecb0994e7075b0973328779ceef7", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "051a2d0472162f3137787c7c9d6e6e4ef239de9329c8c45b1f1bf1e9379e1883"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.3", "debb256781c75ff6a8c5cbf7981146312b66f044a2898f453709a53e5031b45b", [:mix], [], "hexpm", "e110c867a1b3fe74bfc7dd9893aa851f0eed5518d0d7cad76d7baafd30e4f5ba"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_registry": {:hex, :telemetry_registry, "0.3.1", "14a3319a7d9027bdbff7ebcacf1a438f5f5c903057b93aee484cca26f05bdcba", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6d0ca77b691cf854ed074b459a93b87f4c7f5512f8f7743c635ca83da81f939e"}, "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, - "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.4", "7af8408e7ed9d56578539594d1ee7d8461e2dd5c3f57b0f2a5352d610ddde757", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d2c238c79c52cbe223fcdae22ca0bb5007a735b9e933870e241fce66afb4f4ab"}, + "websock": {:hex, :websock, "0.5.2", "b3c08511d8d79ed2c2f589ff430bd1fe799bb389686dafce86d28801783d8351", [:mix], [], "hexpm", "925f5de22fca6813dfa980fb62fd542ec43a2d1a1f83d2caec907483fe66ff05"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.3", "4908718e42e4a548fc20e00e70848620a92f11f7a6add8cf0886c4232267498d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "cbe5b814c1f86b6ea002b52dd99f345aeecf1a1a6964e209d208fb404d930d3d"}, "xml_builder": {:hex, :xml_builder, "2.2.0", "cc5f1eeefcfcde6e90a9b77fb6c490a20bc1b856a7010ce6396f6da9719cbbab", [:mix], [], "hexpm", "9d66d52fb917565d358166a4314078d39ef04d552904de96f8e73f68f64a62c9"}, } From 289b9db984b33bf1313ef0da4e02edcfdd55ec8f Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Sat, 16 Sep 2023 18:26:41 +0800 Subject: [PATCH 10/42] commit --- lib/cadet/assessments/assessments.ex | 2 +- test/cadet/assessments/assessments_test.exs | 230 +++++++++++++++++++- 2 files changed, 226 insertions(+), 6 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 4d9a06bfa..57d622ef3 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -508,7 +508,7 @@ defmodule Cadet.Assessments do questions = fetch_unassigned_voting_questions() for q <- questions do - insert_voting(q.course_id, q.question.contest_number, q.question_id) + insert_voting(q.course_id, q.question["contest_number"], q.question_id) end Logger.info("Successfully update contest entry pools") diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 0b0e339f2..70fe132cd 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -147,11 +147,11 @@ defmodule Cadet.AssessmentsTest do end describe "contest voting" do - test "inserts votes into submission_votes table" do + test "inserts votes into submission_votes table if contest has closed" do course = insert(:course) config = insert(:assessment_config) # contest assessment that has closed - contest_assessment = + closed_contest_assessment = insert(:assessment, is_published: true, open_at: Timex.shift(Timex.now(), days: -5), @@ -160,13 +160,14 @@ defmodule Cadet.AssessmentsTest do config: config ) - contest_question = insert(:programming_question, assessment: contest_assessment) + contest_question = insert(:programming_question, assessment: closed_contest_assessment) voting_assessment = insert(:assessment, %{course: course}) question = insert(:voting_question, %{ assessment: voting_assessment, - question: build(:voting_question_content, contest_number: contest_assessment.number) + question: + build(:voting_question_content, contest_number: closed_contest_assessment.number) }) students = @@ -212,7 +213,226 @@ defmodule Cadet.AssessmentsTest do # students with own contest submissions will vote for 5 entries # students without own contest submissin will vote for 6 entries - assert length(Repo.all(SubmissionVotes, question_id: question.id)) == 6 * 5 + 6 + assert SubmissionVotes |> where(question_id: ^question.id) |> Repo.all() |> length() == + 6 * 5 + 6 + end + + test "does not insert entries for voting if contest is still open" do + course = insert(:course) + config = insert(:assessment_config) + # contest assessment that is still open + open_contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: 1), + course: course, + config: config + ) + + contest_question = insert(:programming_question, assessment: open_contest_assessment) + voting_assessment = insert(:assessment, %{course: course}) + + question = + insert(:voting_question, %{ + assessment: voting_assessment, + question: + build(:voting_question_content, contest_number: open_contest_assessment.number) + }) + + students = + insert_list(6, :course_registration, %{ + role: :student, + course: course + }) + + Enum.map(students, fn student -> + submission = + insert(:submission, + student: student, + assessment: contest_question.assessment, + status: "submitted" + ) + + insert(:answer, + answer: %{code: "return 2;"}, + submission: submission, + question: contest_question + ) + end) + + Assessments.insert_voting(course.id, contest_question.assessment.number, question.id) + + # No entries should be released for students to vote on while the contest is still open + assert SubmissionVotes |> where(question_id: ^question.id) |> Repo.all() |> length() == 0 + end + + test "function that checks for closed contests and releases entries into voting pool" do + course = insert(:course) + config = insert(:assessment_config) + # contest assessment that has closed + closed_contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: -1), + course: course, + config: config + ) + + # contest assessment that is still open + open_contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: 1), + course: course, + config: config + ) + + # contest assessment that is closed but insert_voting has already been done + compiled_contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: -1), + course: course, + config: config + ) + + closed_contest_question = + insert(:programming_question, assessment: closed_contest_assessment) + + open_contest_question = insert(:programming_question, assessment: open_contest_assessment) + + compiled_contest_question = + insert(:programming_question, assessment: compiled_contest_assessment) + + closed_voting_assessment = insert(:assessment, %{course: course}) + open_voting_assessment = insert(:assessment, %{course: course}) + compiled_voting_assessment = insert(:assessment, %{course: course}) + + closed_question = + insert(:voting_question, %{ + assessment: closed_voting_assessment, + question: + build(:voting_question_content, contest_number: closed_contest_assessment.number) + }) + + open_question = + insert(:voting_question, %{ + assessment: open_voting_assessment, + question: + build(:voting_question_content, contest_number: open_contest_assessment.number) + }) + + compiled_question = + insert(:voting_question, %{ + assessment: compiled_voting_assessment, + question: + build(:voting_question_content, contest_number: compiled_contest_assessment.number) + }) + + students = + insert_list(10, :course_registration, %{ + role: :student, + course: course + }) + + first_four = Enum.slice(students, 0..3) + last_six = Enum.slice(students, 4..9) + + Enum.map(first_four, fn student -> + submission = + insert(:submission, + student: student, + assessment: compiled_contest_question.assessment, + status: "submitted" + ) + + insert(:answer, + answer: %{code: "return 2;"}, + submission: submission, + question: compiled_contest_question + ) + end) + + # Only the compiled_assessment has already released entries into voting pool + Assessments.insert_voting( + course.id, + compiled_contest_question.assessment.number, + compiled_question.id + ) + + assert SubmissionVotes |> where(question_id: ^closed_question.id) |> Repo.all() |> length() == + 0 + + assert SubmissionVotes |> where(question_id: ^open_question.id) |> Repo.all() |> length() == + 0 + + assert SubmissionVotes + |> where(question_id: ^compiled_question.id) + |> Repo.all() + |> length() == 4 * 3 + 6 * 4 + + Enum.map(students, fn student -> + submission = + insert(:submission, + student: student, + assessment: closed_contest_question.assessment, + status: "submitted" + ) + + insert(:answer, + answer: %{code: "return 2;"}, + submission: submission, + question: closed_contest_question + ) + end) + + Enum.map(students, fn student -> + submission = + insert(:submission, + student: student, + assessment: open_contest_question.assessment, + status: "submitted" + ) + + insert(:answer, + answer: %{code: "return 2;"}, + submission: submission, + question: open_contest_question + ) + end) + + Enum.map(last_six, fn student -> + submission = + insert(:submission, + student: student, + assessment: compiled_contest_question.assessment, + status: "submitted" + ) + + insert(:answer, + answer: %{code: "return 2;"}, + submission: submission, + question: compiled_contest_question + ) + end) + + Assessments.update_final_contest_entries() + + # only the closed_contest should have been updated + assert SubmissionVotes |> where(question_id: ^closed_question.id) |> Repo.all() |> length() == + 10 * 9 + + assert SubmissionVotes |> where(question_id: ^open_question.id) |> Repo.all() |> length() == + 0 + + assert SubmissionVotes + |> where(question_id: ^compiled_question.id) + |> Repo.all() + |> length() == 4 * 3 + 6 * 4 end test "create voting parameters with invalid contest number" do From 5c97c1e4be78f02dd162b0b56f0efe95757a65e0 Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Sun, 17 Sep 2023 20:23:32 +0800 Subject: [PATCH 11/42] Update config.exs --- config/config.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index bd6a2e551..c998adfe2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -25,7 +25,7 @@ config :cadet, Cadet.Jobs.Scheduler, # Compute rolling leaderboard every 2 hours {"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}}, # Collate contest entries that close in the previous day at 00:01 - {"15 13 * * *", {Cadet.Assessments, :update_final_contest_entries, []}} + {"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}} ] # Configures the endpoint From 6057cfd589658dd51568ae28020ac82b65670751 Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Sun, 17 Sep 2023 23:03:39 +0800 Subject: [PATCH 12/42] Update assessments.ex --- lib/cadet/assessments/assessments.ex | 30 ++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 57d622ef3..0991b9db0 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -36,18 +36,28 @@ defmodule Cadet.Assessments do def delete_assessment(id) do assessment = Repo.get(Assessment, id) - Submission - |> where(assessment_id: ^id) - |> delete_submission_assocation(id) + is_voted_on = + Question + |> where(type: :voting) + |> where([q], q.question["contest_number"] == ^assessment.number) + |> Repo.exists?() - Question - |> where(assessment_id: ^id) - |> Repo.all() - |> Enum.each(fn q -> - delete_submission_votes_association(q) - end) + if is_voted_on do + {:error, {:bad_request, "Contest voting for this contest is still up"}} + else + Submission + |> where(assessment_id: ^id) + |> delete_submission_assocation(id) + + Question + |> where(assessment_id: ^id) + |> Repo.all() + |> Enum.each(fn q -> + delete_submission_votes_association(q) + end) - Repo.delete(assessment) + Repo.delete(assessment) + end end defp delete_submission_votes_association(question) do From 46649b8336c80857ae47ee9ace752577bb5d7094 Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Mon, 18 Sep 2023 17:00:24 +0800 Subject: [PATCH 13/42] commit --- lib/cadet/assessments/assessments.ex | 7 ++- test/cadet/assessments/assessments_test.exs | 60 +++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 0991b9db0..3d05edb70 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -518,7 +518,12 @@ defmodule Cadet.Assessments do questions = fetch_unassigned_voting_questions() for q <- questions do - insert_voting(q.course_id, q.question["contest_number"], q.question_id) + contest_number = q.question["contest_number"] + course_id = q.course_id + contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id) + if not is_nil(contest_assessment) do + insert_voting(course_id, contest_number, q.question_id) + end end Logger.info("Successfully update contest entry pools") diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 70fe132cd..cc006ff8e 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -311,6 +311,8 @@ defmodule Cadet.AssessmentsTest do closed_voting_assessment = insert(:assessment, %{course: course}) open_voting_assessment = insert(:assessment, %{course: course}) compiled_voting_assessment = insert(:assessment, %{course: course}) + # voting assessment that references an invalid contest number + invalid_voting_assessment = insert(:assessment, %{course: course}) closed_question = insert(:voting_question, %{ @@ -333,6 +335,13 @@ defmodule Cadet.AssessmentsTest do build(:voting_question_content, contest_number: compiled_contest_assessment.number) }) + invalid_question = + insert(:voting_question, %{ + assessment: invalid_voting_assessment, + question: + build(:voting_question_content, contest_number: "test_invalid") + }) + students = insert_list(10, :course_registration, %{ role: :student, @@ -375,6 +384,9 @@ defmodule Cadet.AssessmentsTest do |> Repo.all() |> length() == 4 * 3 + 6 * 4 + assert SubmissionVotes |> where(question_id: ^invalid_question.id) |> Repo.all() |> length() == + 0 + Enum.map(students, fn student -> submission = insert(:submission, @@ -433,6 +445,9 @@ defmodule Cadet.AssessmentsTest do |> where(question_id: ^compiled_question.id) |> Repo.all() |> length() == 4 * 3 + 6 * 4 + + assert SubmissionVotes |> where(question_id: ^invalid_question.id) |> Repo.all() |> length() == + 0 end test "create voting parameters with invalid contest number" do @@ -493,6 +508,51 @@ defmodule Cadet.AssessmentsTest do Assessments.delete_assessment(voting_assessment.id) refute Repo.exists?(SubmissionVotes, question_id: question.id) end + + test "does not delete contest assessment if voting assessment is present" do + course = insert(:course) + config = insert(:assessment_config) + # contest assessment that has closed + contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: -1), + course: course, + config: config, + number: "test" + ) + + contest_question = insert(:programming_question, assessment: contest_assessment) + voting_assessment = insert(:assessment, %{course: course, config: config}) + question = + insert(:voting_question, %{ + assessment: voting_assessment, + question: + build(:voting_question_content, contest_number: contest_assessment.number) + }) + students = insert_list(5, :course_registration, %{role: :student, course: course}) + error_message = {:bad_request, "Contest voting for this contest is still up"} + + Enum.map(students, fn student -> + submission = + insert(:submission, + student: student, + assessment: contest_question.assessment, + status: "submitted" + ) + + insert(:answer, + answer: %{code: "return 2;"}, + submission: submission, + question: contest_question + ) + end) + + assert {:error, error_message} = Assessments.delete_assessment(contest_assessment.id) + # deletion should fail + assert Assessment |> where(id: ^contest_assessment.id) |> Repo.exists?() + end end describe "contest voting leaderboard utility functions" do From 902cd8074b85eac73704ebbfd36293b8c42db0f2 Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Mon, 18 Sep 2023 17:08:18 +0800 Subject: [PATCH 14/42] commit --- lib/cadet/assessments/assessments.ex | 1 + test/cadet/assessments/assessments_test.exs | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 3d05edb70..d53acd798 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -521,6 +521,7 @@ defmodule Cadet.Assessments do contest_number = q.question["contest_number"] course_id = q.course_id contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id) + if not is_nil(contest_assessment) do insert_voting(course_id, contest_number, q.question_id) end diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index cc006ff8e..ad80df985 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -338,8 +338,7 @@ defmodule Cadet.AssessmentsTest do invalid_question = insert(:voting_question, %{ assessment: invalid_voting_assessment, - question: - build(:voting_question_content, contest_number: "test_invalid") + question: build(:voting_question_content, contest_number: "test_invalid") }) students = @@ -525,12 +524,13 @@ defmodule Cadet.AssessmentsTest do contest_question = insert(:programming_question, assessment: contest_assessment) voting_assessment = insert(:assessment, %{course: course, config: config}) + question = insert(:voting_question, %{ assessment: voting_assessment, - question: - build(:voting_question_content, contest_number: contest_assessment.number) + question: build(:voting_question_content, contest_number: contest_assessment.number) }) + students = insert_list(5, :course_registration, %{role: :student, course: course}) error_message = {:bad_request, "Contest voting for this contest is still up"} From df7d66de69b6f041a34b6b0d6d5f554c6bc0518b Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Tue, 19 Sep 2023 17:10:06 +0800 Subject: [PATCH 15/42] commit --- lib/cadet/assessments/assessments.ex | 41 +++++++++----- test/cadet/assessments/assessments_test.exs | 62 ++++++++++++++------- 2 files changed, 69 insertions(+), 34 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index d53acd798..a543f819e 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -39,7 +39,12 @@ defmodule Cadet.Assessments do is_voted_on = Question |> where(type: :voting) - |> where([q], q.question["contest_number"] == ^assessment.number) + |> join(:inner, [q], asst in assoc(q, :assessment)) + |> where( + [q, asst], + q.question["contest_number"] == ^assessment.number and + asst.course_id == ^assessment.course_id + ) |> Repo.exists?() if is_voted_on do @@ -518,13 +523,7 @@ defmodule Cadet.Assessments do questions = fetch_unassigned_voting_questions() for q <- questions do - contest_number = q.question["contest_number"] - course_id = q.course_id - contest_assessment = Repo.get_by(Assessment, number: contest_number, course_id: course_id) - - if not is_nil(contest_assessment) do - insert_voting(course_id, contest_number, q.question_id) - end + insert_voting(q.course_id, q.question["contest_number"], q.question_id) end Logger.info("Successfully update contest entry pools") @@ -538,12 +537,26 @@ defmodule Cadet.Assessments do |> select([v], v.question_id) |> Repo.all() - Question - |> where(type: :voting) - |> where([q], q.id not in ^voting_assigned_question_ids) - |> join(:inner, [q], asst in assoc(q, :assessment)) - |> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id}) - |> Repo.all() + valid_assessments = + Assessment + |> select([a], %{number: a.number, course_id: a.course_id}) + |> Repo.all() + + valid_questions = + Question + |> where(type: :voting) + |> where([q], q.id not in ^voting_assigned_question_ids) + |> join(:inner, [q], asst in assoc(q, :assessment)) + |> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id}) + |> Repo.all() + + # fetch only voting where there is a corresponding contest + Enum.filter(valid_questions, fn q -> + Enum.any?( + valid_assessments, + fn a -> a.number == q.question["contest_number"] and a.course_id == q.course_id end + ) + end) end @doc """ diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index ad80df985..61800c289 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -431,6 +431,15 @@ defmodule Cadet.AssessmentsTest do ) end) + # fetching all unassigned voting questions should only yield open and closed questions + unassigned_voting_questions = Assessments.fetch_unassigned_voting_questions() + assert Enum.count(unassigned_voting_questions) == 2 + + assert Enum.map(unassigned_voting_questions, fn q -> q.question_id end) == [ + closed_question.id, + open_question.id + ] + Assessments.update_final_contest_entries() # only the closed_contest should have been updated @@ -508,10 +517,10 @@ defmodule Cadet.AssessmentsTest do refute Repo.exists?(SubmissionVotes, question_id: question.id) end - test "does not delete contest assessment if voting assessment is present" do + test "does not delete contest assessment if referencing voting assessment is present" do course = insert(:course) config = insert(:assessment_config) - # contest assessment that has closed + contest_assessment = insert(:assessment, is_published: true, @@ -522,36 +531,49 @@ defmodule Cadet.AssessmentsTest do number: "test" ) - contest_question = insert(:programming_question, assessment: contest_assessment) voting_assessment = insert(:assessment, %{course: course, config: config}) - question = + # insert voting question that references the contest assessment + _voting_question = insert(:voting_question, %{ assessment: voting_assessment, question: build(:voting_question_content, contest_number: contest_assessment.number) }) - students = insert_list(5, :course_registration, %{role: :student, course: course}) error_message = {:bad_request, "Contest voting for this contest is still up"} - Enum.map(students, fn student -> - submission = - insert(:submission, - student: student, - assessment: contest_question.assessment, - status: "submitted" - ) + assert {:error, ^error_message} = Assessments.delete_assessment(contest_assessment.id) + # deletion should fail + assert Assessment |> where(id: ^contest_assessment.id) |> Repo.exists?() + end - insert(:answer, - answer: %{code: "return 2;"}, - submission: submission, - question: contest_question + test "deletes contest assessment if voting assessment references same number but different course" do + course_1 = insert(:course) + course_2 = insert(:course) + config = insert(:assessment_config) + + contest_assessment = + insert(:assessment, + is_published: true, + open_at: Timex.shift(Timex.now(), days: -5), + close_at: Timex.shift(Timex.now(), hours: -1), + course: course_1, + config: config, + number: "test" ) - end) - assert {:error, error_message} = Assessments.delete_assessment(contest_assessment.id) - # deletion should fail - assert Assessment |> where(id: ^contest_assessment.id) |> Repo.exists?() + voting_assessment = insert(:assessment, %{course: course_2, config: config}) + + # insert voting question from a different course that references the same contest number + _voting_question = + insert(:voting_question, %{ + assessment: voting_assessment, + question: build(:voting_question_content, contest_number: contest_assessment.number) + }) + + assert {:ok, _} = Assessments.delete_assessment(contest_assessment.id) + # deletion should succeed + refute Assessment |> where(id: ^contest_assessment.id) |> Repo.exists?() end end From a5069fc138b510215474d217b4b1fc31c434acf3 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 20 Sep 2023 14:19:49 +0800 Subject: [PATCH 16/42] Add files via upload --- test/cadet/assessments/assessments_test.exs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 61800c289..31e4fec60 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -435,10 +435,11 @@ defmodule Cadet.AssessmentsTest do unassigned_voting_questions = Assessments.fetch_unassigned_voting_questions() assert Enum.count(unassigned_voting_questions) == 2 - assert Enum.map(unassigned_voting_questions, fn q -> q.question_id end) == [ - closed_question.id, - open_question.id - ] + unassigned_voting_question_ids = + Enum.map(unassigned_voting_questions, fn q -> q.question_id end) + + assert closed_question.id in unassigned_voting_question_ids + assert open_question.id in unassigned_voting_question_ids Assessments.update_final_contest_entries() From cd858eb33a998dcf265ad7b2e72b78991e089b50 Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Wed, 20 Sep 2023 15:42:50 +0800 Subject: [PATCH 17/42] Update assessments_test.exs --- test/cadet/assessments/assessments_test.exs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 61800c289..31e4fec60 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -435,10 +435,11 @@ defmodule Cadet.AssessmentsTest do unassigned_voting_questions = Assessments.fetch_unassigned_voting_questions() assert Enum.count(unassigned_voting_questions) == 2 - assert Enum.map(unassigned_voting_questions, fn q -> q.question_id end) == [ - closed_question.id, - open_question.id - ] + unassigned_voting_question_ids = + Enum.map(unassigned_voting_questions, fn q -> q.question_id end) + + assert closed_question.id in unassigned_voting_question_ids + assert open_question.id in unassigned_voting_question_ids Assessments.update_final_contest_entries() From 3475d0bdc96909eaae491fa43c7ecb5bee917b23 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Sun, 1 Oct 2023 17:44:16 +0800 Subject: [PATCH 18/42] Change modifier to 70 for Game of Tones contest Change the token penalty modifier to 70 for Contest C6 Game of Tones, NUS SA 23/24 Sem 1. Note: In the future, proposed implementation is to have the token penalty modifier inserted in the contest voting file as a parameter at the point of upload. --- lib/cadet/assessments/assessments.ex | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index a543f819e..a3edb1dea 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1216,11 +1216,12 @@ defmodule Cadet.Assessments do end # Calculate the score based on formula - # score(v,t) = v - 2^(t/50) where v is the normalized_voting_score + # score(v,t) = v - 2^(t/n) where v is the normalized_voting_score and n is the modifier + # n = 50 for C3 & C4, n = 70 for C6 # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - normalized_voting_score - :math.pow(2, min(1023.5, tokens / 50)) + normalized_voting_score - :math.pow(2, min(1023.5, tokens / 70)) end @doc """ From 76d41082b22a69f0b207126e6b7a4ceae8a3e652 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Sun, 1 Oct 2023 17:52:18 +0800 Subject: [PATCH 19/42] Update assessments_test.exs --- test/cadet/assessments/assessments_test.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 31e4fec60..1e709221d 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -1708,10 +1708,11 @@ defmodule Cadet.AssessmentsTest do end defp expected_top_relative_scores(top_x) do + # test with token penalty of 70 # "return 0;" in the factory has 3 token 10..0 |> Enum.to_list() - |> Enum.map(fn score -> 10 * score - :math.pow(2, 3 / 50) end) + |> Enum.map(fn score -> 10 * score - :math.pow(2, 3 / 70) end) |> Enum.take(top_x) end end From bda994af001fbdcf957067864bda607dd9e88e53 Mon Sep 17 00:00:00 2001 From: Richard Dominick <34370238+RichDom2185@users.noreply.github.com> Date: Sun, 1 Oct 2023 18:11:00 +0800 Subject: [PATCH 20/42] Fix incorrect denominator to 80 --- lib/cadet/assessments/assessments.ex | 4 ++-- test/cadet/assessments/assessments_test.exs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index a3edb1dea..fc8db2902 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1217,11 +1217,11 @@ defmodule Cadet.Assessments do # Calculate the score based on formula # score(v,t) = v - 2^(t/n) where v is the normalized_voting_score and n is the modifier - # n = 50 for C3 & C4, n = 70 for C6 + # n = 50 for C3 & C4, n = 80 for C6 # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - normalized_voting_score - :math.pow(2, min(1023.5, tokens / 70)) + normalized_voting_score - :math.pow(2, min(1023.5, tokens / 80)) end @doc """ diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 1e709221d..7efd892d9 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -1708,11 +1708,11 @@ defmodule Cadet.AssessmentsTest do end defp expected_top_relative_scores(top_x) do - # test with token penalty of 70 + # test with token penalty of 80 # "return 0;" in the factory has 3 token 10..0 |> Enum.to_list() - |> Enum.map(fn score -> 10 * score - :math.pow(2, 3 / 70) end) + |> Enum.map(fn score -> 10 * score - :math.pow(2, 3 / 80) end) |> Enum.take(top_x) end end From 934c7f1957ae5ffb0dcf06ae6a847ec847693ddd Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Tue, 7 Nov 2023 23:19:08 +0800 Subject: [PATCH 21/42] commit --- lib/cadet/assessments/assessments.ex | 18 ++++++++++++------ .../question_types/voting_question.ex | 3 ++- lib/cadet/jobs/xml_parser.ex | 3 ++- test/cadet/assessments/assessments_test.exs | 15 ++++++++------- test/cadet/assessments/question_test.exs | 3 ++- .../question_types/voting_question_test.exs | 3 ++- test/factories/assessments/question_factory.ex | 6 ++++-- test/support/xml_generator.ex | 5 +++-- 8 files changed, 35 insertions(+), 21 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index a543f819e..7fd1a15bd 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1175,7 +1175,13 @@ defmodule Cadet.Assessments do ) |> Repo.all() - entry_scores = map_eligible_votes_to_entry_score(eligible_votes) + token_divider = + Question + |> select([q], q.question["token_divider"]) + |> Repo.get_by(id: contest_voting_question_id) + + + entry_scores = map_eligible_votes_to_entry_score(eligible_votes, token_divider) entry_scores |> Enum.map(fn {ans_id, relative_score} -> @@ -1192,7 +1198,7 @@ defmodule Cadet.Assessments do |> Repo.transaction() end - defp map_eligible_votes_to_entry_score(eligible_votes) do + defp map_eligible_votes_to_entry_score(eligible_votes, token_divider) do # converts eligible votes to the {total cumulative score, number of votes, tokens} entry_vote_data = Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> @@ -1210,17 +1216,17 @@ defmodule Cadet.Assessments do Enum.map( entry_vote_data, fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> - {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens)} + {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider)} end ) end # Calculate the score based on formula - # score(v,t) = v - 2^(t/50) where v is the normalized_voting_score + # score(v,t) = v - 2^(t/token_divider) where v is the normalized_voting_score # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do + defp calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider) do normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - normalized_voting_score - :math.pow(2, min(1023.5, tokens / 50)) + normalized_voting_score - :math.pow(2, min(1023.5, tokens / token_divider)) end @doc """ diff --git a/lib/cadet/assessments/question_types/voting_question.ex b/lib/cadet/assessments/question_types/voting_question.ex index d76d65912..2601f32be 100644 --- a/lib/cadet/assessments/question_types/voting_question.ex +++ b/lib/cadet/assessments/question_types/voting_question.ex @@ -11,9 +11,10 @@ defmodule Cadet.Assessments.QuestionTypes.VotingQuestion do field(:template, :string) field(:contest_number, :string) field(:reveal_hours, :integer) + field(:token_divider, :integer) end - @required_fields ~w(content contest_number reveal_hours)a + @required_fields ~w(content contest_number reveal_hours token_divider)a @optional_fields ~w(prepend template)a def changeset(question, params \\ %{}) do diff --git a/lib/cadet/jobs/xml_parser.ex b/lib/cadet/jobs/xml_parser.ex index 003091b84..fb1742571 100644 --- a/lib/cadet/jobs/xml_parser.ex +++ b/lib/cadet/jobs/xml_parser.ex @@ -259,7 +259,8 @@ defmodule Cadet.Updater.XMLParser do |> xpath( ~x"./VOTING"e, contest_number: ~x"./@assessment_number"s, - reveal_hours: ~x"./@reveal_hours"i + reveal_hours: ~x"./@reveal_hours"i, + token_divider: ~x"./@token_divider"i ) ) end diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 31e4fec60..3575768ff 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -77,7 +77,8 @@ defmodule Cadet.AssessmentsTest do question: %{ content: Faker.Pokemon.name(), contest_number: assessment.number, - reveal_hours: 48 + reveal_hours: 48, + token_divider: 50 } }, assessment.id @@ -646,13 +647,13 @@ defmodule Cadet.AssessmentsTest do top_x_ans = Assessments.fetch_top_relative_score_answers(question_id, 5) - assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(5) + assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(5, 50) x = 3 top_x_ans = Assessments.fetch_top_relative_score_answers(question_id, x) # verify that top x ans are queried correctly - assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(3) + assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(3, 50) end end @@ -886,7 +887,7 @@ defmodule Cadet.AssessmentsTest do assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(yesterday_question.id, 5) - ) == expected_top_relative_scores(5) + ) == expected_top_relative_scores(5, 50) end test "update_rolling_contest_leaderboards correcly updates leaderboards which voting is active", @@ -908,7 +909,7 @@ defmodule Cadet.AssessmentsTest do assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(current_question.id, 5) - ) == expected_top_relative_scores(5) + ) == expected_top_relative_scores(5, 50) end end @@ -1707,11 +1708,11 @@ defmodule Cadet.AssessmentsTest do questions |> Enum.map(fn q -> q.id end) |> Enum.sort() end - defp expected_top_relative_scores(top_x) do + defp expected_top_relative_scores(top_x, token_divider) do # "return 0;" in the factory has 3 token 10..0 |> Enum.to_list() - |> Enum.map(fn score -> 10 * score - :math.pow(2, 3 / 50) end) + |> Enum.map(fn score -> 10 * score - :math.pow(2, 3 / token_divider) end) |> Enum.take(top_x) end end diff --git a/test/cadet/assessments/question_test.exs b/test/cadet/assessments/question_test.exs index 7a77f38fb..34381a5f1 100644 --- a/test/cadet/assessments/question_test.exs +++ b/test/cadet/assessments/question_test.exs @@ -39,7 +39,8 @@ defmodule Cadet.Assessments.QuestionTest do question: %{ content: Faker.Pokemon.name(), contest_number: assessment.number, - reveal_hours: 48 + reveal_hours: 48, + token_divider: 50 } } diff --git a/test/cadet/assessments/question_types/voting_question_test.exs b/test/cadet/assessments/question_types/voting_question_test.exs index b3fd8949e..43329a094 100644 --- a/test/cadet/assessments/question_types/voting_question_test.exs +++ b/test/cadet/assessments/question_types/voting_question_test.exs @@ -9,7 +9,8 @@ defmodule Cadet.Assessments.QuestionTypes.VotingQuestionTest do %{ content: "content", contest_number: "C4", - reveal_hours: 48 + reveal_hours: 48, + token_divider: 50 }, :valid ) diff --git a/test/factories/assessments/question_factory.ex b/test/factories/assessments/question_factory.ex index 9836d1047..cfd088dd1 100644 --- a/test/factories/assessments/question_factory.ex +++ b/test/factories/assessments/question_factory.ex @@ -93,7 +93,8 @@ defmodule Cadet.Assessments.QuestionFactory do prepend: Faker.Pokemon.location(), template: Faker.Lorem.Shakespeare.as_you_like_it(), contest_number: contest_assessment.number, - reveal_hours: 48 + reveal_hours: 48, + token_divider: 50 } } end @@ -106,7 +107,8 @@ defmodule Cadet.Assessments.QuestionFactory do prepend: Faker.Pokemon.location(), template: Faker.Lorem.Shakespeare.as_you_like_it(), contest_number: contest_assessment.number, - reveal_hours: 48 + reveal_hours: 48, + token_divider: 50 } end end diff --git a/test/support/xml_generator.ex b/test/support/xml_generator.ex index e028b31b7..8782d32a4 100644 --- a/test/support/xml_generator.ex +++ b/test/support/xml_generator.ex @@ -157,7 +157,8 @@ defmodule Cadet.Test.XMLGenerator do voting_field = voting(%{ reveal_hours: question.question.reveal_hours, - assessment_number: question.question.contest_number + assessment_number: question.question.contest_number, + token_divider: question.question.token_divider }) [ @@ -167,7 +168,7 @@ defmodule Cadet.Test.XMLGenerator do end defp voting(raw_attr) do - {"VOTING", map_permit_keys(raw_attr, ~w(assessment_number reveal_hours)a)} + {"VOTING", map_permit_keys(raw_attr, ~w(assessment_number reveal_hours token_divider)a)} end defp deployment(raw_attrs, children) do From 67710dc36ee73ed83d86ed4688ee8e7c34a911f3 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:00:48 +0800 Subject: [PATCH 22/42] Update assessments.ex --- lib/cadet/assessments/assessments.ex | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index d8f9bc67f..6bb932160 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -1175,7 +1175,12 @@ defmodule Cadet.Assessments do ) |> Repo.all() - entry_scores = map_eligible_votes_to_entry_score(eligible_votes) + token_divider = + Question + |> select([q], q.question["token_divider"]) + |> Repo.get_by(id: contest_voting_question_id) + + entry_scores = map_eligible_votes_to_entry_score(eligible_votes, token_divider) entry_scores |> Enum.map(fn {ans_id, relative_score} -> @@ -1192,7 +1197,7 @@ defmodule Cadet.Assessments do |> Repo.transaction() end - defp map_eligible_votes_to_entry_score(eligible_votes) do + defp map_eligible_votes_to_entry_score(eligible_votes, token_divider) do # converts eligible votes to the {total cumulative score, number of votes, tokens} entry_vote_data = Enum.reduce(eligible_votes, %{}, fn %{ans_id: ans_id, score: score, ans: ans}, tracker -> @@ -1210,18 +1215,17 @@ defmodule Cadet.Assessments do Enum.map( entry_vote_data, fn {ans_id, {sum_of_scores, number_of_voters, tokens}} -> - {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens)} + {ans_id, calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider)} end ) end # Calculate the score based on formula - # score(v,t) = v - 2^(t/n) where v is the normalized_voting_score and n is the modifier - # n = 50 for C3 & C4, n = 80 for C6 + # score(v,t) = v - 2^(t/token_divider) where v is the normalized_voting_score # normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - defp calculate_formula_score(sum_of_scores, number_of_voters, tokens) do + defp calculate_formula_score(sum_of_scores, number_of_voters, tokens, token_divider) do normalized_voting_score = sum_of_scores / number_of_voters / 10 * 100 - normalized_voting_score - :math.pow(2, min(1023.5, tokens / 80)) + normalized_voting_score - :math.pow(2, min(1023.5, tokens / token_divider)) end @doc """ From a733e87a5e41a105d3e039bf0356257067c45f60 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:02:49 +0800 Subject: [PATCH 23/42] Update voting_question.ex --- lib/cadet/assessments/question_types/voting_question.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/cadet/assessments/question_types/voting_question.ex b/lib/cadet/assessments/question_types/voting_question.ex index d76d65912..32cfd5662 100644 --- a/lib/cadet/assessments/question_types/voting_question.ex +++ b/lib/cadet/assessments/question_types/voting_question.ex @@ -13,7 +13,7 @@ defmodule Cadet.Assessments.QuestionTypes.VotingQuestion do field(:reveal_hours, :integer) end - @required_fields ~w(content contest_number reveal_hours)a + @required_fields ~w(content contest_number reveal_hours token_divider)a @optional_fields ~w(prepend template)a def changeset(question, params \\ %{}) do From 5d402a88bc6ce5a4cab2b48eb6f4900b4e480c19 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:04:26 +0800 Subject: [PATCH 24/42] Update xml_parser.ex --- lib/cadet/jobs/xml_parser.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/cadet/jobs/xml_parser.ex b/lib/cadet/jobs/xml_parser.ex index 003091b84..fb1742571 100644 --- a/lib/cadet/jobs/xml_parser.ex +++ b/lib/cadet/jobs/xml_parser.ex @@ -259,7 +259,8 @@ defmodule Cadet.Updater.XMLParser do |> xpath( ~x"./VOTING"e, contest_number: ~x"./@assessment_number"s, - reveal_hours: ~x"./@reveal_hours"i + reveal_hours: ~x"./@reveal_hours"i, + token_divider: ~x"./@token_divider"i ) ) end From c5ded99682ca048972cc40c8e3bf27cbd724c100 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:06:16 +0800 Subject: [PATCH 25/42] Add files via upload --- test/cadet/assessments/assessments_test.exs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 7efd892d9..3575768ff 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -77,7 +77,8 @@ defmodule Cadet.AssessmentsTest do question: %{ content: Faker.Pokemon.name(), contest_number: assessment.number, - reveal_hours: 48 + reveal_hours: 48, + token_divider: 50 } }, assessment.id @@ -646,13 +647,13 @@ defmodule Cadet.AssessmentsTest do top_x_ans = Assessments.fetch_top_relative_score_answers(question_id, 5) - assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(5) + assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(5, 50) x = 3 top_x_ans = Assessments.fetch_top_relative_score_answers(question_id, x) # verify that top x ans are queried correctly - assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(3) + assert get_answer_relative_scores(top_x_ans) == expected_top_relative_scores(3, 50) end end @@ -886,7 +887,7 @@ defmodule Cadet.AssessmentsTest do assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(yesterday_question.id, 5) - ) == expected_top_relative_scores(5) + ) == expected_top_relative_scores(5, 50) end test "update_rolling_contest_leaderboards correcly updates leaderboards which voting is active", @@ -908,7 +909,7 @@ defmodule Cadet.AssessmentsTest do assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(current_question.id, 5) - ) == expected_top_relative_scores(5) + ) == expected_top_relative_scores(5, 50) end end @@ -1707,12 +1708,11 @@ defmodule Cadet.AssessmentsTest do questions |> Enum.map(fn q -> q.id end) |> Enum.sort() end - defp expected_top_relative_scores(top_x) do - # test with token penalty of 80 + defp expected_top_relative_scores(top_x, token_divider) do # "return 0;" in the factory has 3 token 10..0 |> Enum.to_list() - |> Enum.map(fn score -> 10 * score - :math.pow(2, 3 / 80) end) + |> Enum.map(fn score -> 10 * score - :math.pow(2, 3 / token_divider) end) |> Enum.take(top_x) end end From 2c3c64b917364189062c9a7e05048c44d060eba6 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:07:26 +0800 Subject: [PATCH 26/42] Add files via upload --- test/cadet/assessments/question_test.exs | 239 ++++++++++++----------- 1 file changed, 120 insertions(+), 119 deletions(-) diff --git a/test/cadet/assessments/question_test.exs b/test/cadet/assessments/question_test.exs index 7a77f38fb..f0f63eb86 100644 --- a/test/cadet/assessments/question_test.exs +++ b/test/cadet/assessments/question_test.exs @@ -1,119 +1,120 @@ -defmodule Cadet.Assessments.QuestionTest do - alias Cadet.Assessments.Question - - use Cadet.ChangesetCase, entity: Question - - @required_fields ~w(question type assessment_id)a - @required_embeds ~w(library)a - - setup do - assessment = insert(:assessment) - - valid_programming_params = %{ - type: :programming, - assessment_id: assessment.id, - library: build(:library), - question: %{ - content: Faker.Pokemon.name(), - prepend: "", - template: Faker.Lorem.Shakespeare.as_you_like_it(), - postpend: "", - solution: Faker.Lorem.Shakespeare.hamlet() - } - } - - valid_mcq_params = %{ - type: :mcq, - assessment_id: assessment.id, - library: build(:library), - question: %{ - content: Faker.Pokemon.name(), - choices: Enum.map(0..2, &build(:mcq_choice, %{choice_id: &1, is_correct: &1 == 0})) - } - } - - valid_voting_params = %{ - type: :voting, - assessment_id: assessment.id, - library: build(:library), - question: %{ - content: Faker.Pokemon.name(), - contest_number: assessment.number, - reveal_hours: 48 - } - } - - %{ - assessment: assessment, - valid_mcq_params: valid_mcq_params, - valid_programming_params: valid_programming_params, - valid_voting_params: valid_voting_params - } - end - - describe "valid changesets" do - test "valid mcq question", %{valid_mcq_params: params} do - assert_changeset_db(params, :valid) - end - - test "valid programming question", %{valid_programming_params: params} do - assert_changeset_db(params, :valid) - end - - test "valid voting question", %{valid_voting_params: params} do - assert_changeset_db(params, :valid) - end - - test "cast model param in valid changeset to id", %{ - assessment: assessment, - valid_mcq_params: params - } do - params - |> Map.delete(:assessment_id) - |> Map.put(:assessment, assessment) - |> assert_changeset_db(:valid) - end - end - - describe "invalid changesets" do - test "missing params", %{ - valid_mcq_params: mcq_params, - valid_programming_params: programming_params, - valid_voting_params: voting_params - } do - for params <- [mcq_params, programming_params, voting_params], - field <- @required_fields ++ @required_embeds do - params - |> Map.delete(field) - |> assert_changeset(:invalid) - end - end - - test "invalid question content", %{ - valid_mcq_params: mcq_params, - valid_programming_params: programming_params, - valid_voting_params: voting_params - } do - mcq_params - |> Map.put(:type, :programming) - |> assert_changeset(:invalid) - - programming_params - |> Map.put(:type, :mcq) - |> assert_changeset(:invalid) - - voting_params - |> Map.put(:type, :programming) - |> assert_changeset(:invalid) - end - - test "foreign key constraints", %{ - assessment: assessment, - valid_mcq_params: params - } do - {:ok, _} = Repo.delete(assessment) - - assert_changeset_db(params, :invalid) - end - end -end +defmodule Cadet.Assessments.QuestionTest do + alias Cadet.Assessments.Question + + use Cadet.ChangesetCase, entity: Question + + @required_fields ~w(question type assessment_id)a + @required_embeds ~w(library)a + + setup do + assessment = insert(:assessment) + + valid_programming_params = %{ + type: :programming, + assessment_id: assessment.id, + library: build(:library), + question: %{ + content: Faker.Pokemon.name(), + prepend: "", + template: Faker.Lorem.Shakespeare.as_you_like_it(), + postpend: "", + solution: Faker.Lorem.Shakespeare.hamlet() + } + } + + valid_mcq_params = %{ + type: :mcq, + assessment_id: assessment.id, + library: build(:library), + question: %{ + content: Faker.Pokemon.name(), + choices: Enum.map(0..2, &build(:mcq_choice, %{choice_id: &1, is_correct: &1 == 0})) + } + } + + valid_voting_params = %{ + type: :voting, + assessment_id: assessment.id, + library: build(:library), + question: %{ + content: Faker.Pokemon.name(), + contest_number: assessment.number, + reveal_hours: 48, + token_divider: 50 + } + } + + %{ + assessment: assessment, + valid_mcq_params: valid_mcq_params, + valid_programming_params: valid_programming_params, + valid_voting_params: valid_voting_params + } + end + + describe "valid changesets" do + test "valid mcq question", %{valid_mcq_params: params} do + assert_changeset_db(params, :valid) + end + + test "valid programming question", %{valid_programming_params: params} do + assert_changeset_db(params, :valid) + end + + test "valid voting question", %{valid_voting_params: params} do + assert_changeset_db(params, :valid) + end + + test "cast model param in valid changeset to id", %{ + assessment: assessment, + valid_mcq_params: params + } do + params + |> Map.delete(:assessment_id) + |> Map.put(:assessment, assessment) + |> assert_changeset_db(:valid) + end + end + + describe "invalid changesets" do + test "missing params", %{ + valid_mcq_params: mcq_params, + valid_programming_params: programming_params, + valid_voting_params: voting_params + } do + for params <- [mcq_params, programming_params, voting_params], + field <- @required_fields ++ @required_embeds do + params + |> Map.delete(field) + |> assert_changeset(:invalid) + end + end + + test "invalid question content", %{ + valid_mcq_params: mcq_params, + valid_programming_params: programming_params, + valid_voting_params: voting_params + } do + mcq_params + |> Map.put(:type, :programming) + |> assert_changeset(:invalid) + + programming_params + |> Map.put(:type, :mcq) + |> assert_changeset(:invalid) + + voting_params + |> Map.put(:type, :programming) + |> assert_changeset(:invalid) + end + + test "foreign key constraints", %{ + assessment: assessment, + valid_mcq_params: params + } do + {:ok, _} = Repo.delete(assessment) + + assert_changeset_db(params, :invalid) + end + end +end From 6471b68cd1abdba24863d3becddceba4a9016b2e Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:08:09 +0800 Subject: [PATCH 27/42] Add files via upload --- .../question_types/voting_question_test.exs | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/test/cadet/assessments/question_types/voting_question_test.exs b/test/cadet/assessments/question_types/voting_question_test.exs index b3fd8949e..0c5a4f2da 100644 --- a/test/cadet/assessments/question_types/voting_question_test.exs +++ b/test/cadet/assessments/question_types/voting_question_test.exs @@ -1,27 +1,28 @@ -defmodule Cadet.Assessments.QuestionTypes.VotingQuestionTest do - alias Cadet.Assessments.QuestionTypes.VotingQuestion - - use Cadet.ChangesetCase, entity: VotingQuestion - - describe "Changesets" do - test "valid changeset" do - assert_changeset( - %{ - content: "content", - contest_number: "C4", - reveal_hours: 48 - }, - :valid - ) - end - - test "invalid changesets" do - assert_changeset( - %{ - content: 1 - }, - :invalid - ) - end - end -end +defmodule Cadet.Assessments.QuestionTypes.VotingQuestionTest do + alias Cadet.Assessments.QuestionTypes.VotingQuestion + + use Cadet.ChangesetCase, entity: VotingQuestion + + describe "Changesets" do + test "valid changeset" do + assert_changeset( + %{ + content: "content", + contest_number: "C4", + reveal_hours: 48, + token_divider: 50 + }, + :valid + ) + end + + test "invalid changesets" do + assert_changeset( + %{ + content: 1 + }, + :invalid + ) + end + end +end From a26b4b37fb946c51666a1040a48020227af592ea Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:09:12 +0800 Subject: [PATCH 28/42] Add files via upload --- .../factories/assessments/question_factory.ex | 230 +++++++++--------- 1 file changed, 116 insertions(+), 114 deletions(-) diff --git a/test/factories/assessments/question_factory.ex b/test/factories/assessments/question_factory.ex index 9836d1047..536281428 100644 --- a/test/factories/assessments/question_factory.ex +++ b/test/factories/assessments/question_factory.ex @@ -1,114 +1,116 @@ -defmodule Cadet.Assessments.QuestionFactory do - @moduledoc """ - Factories for the Question entity - """ - - defmacro __using__(_opts) do - quote do - alias Cadet.Assessments.Question - - def question_factory do - Enum.random([build(:programming_question), build(:mcq_question), build(:voting_question)]) - end - - def programming_question_factory do - library = build(:library) - - %Question{ - type: :programming, - max_xp: 100, - assessment: build(:assessment, %{is_published: true}), - library: library, - grading_library: Enum.random([build(:library), library]), - question: build(:programming_question_content) - } - end - - def programming_question_content_factory do - %{ - prepend: Faker.Pokemon.location(), - content: Faker.Pokemon.name(), - postpend: Faker.Pokemon.location(), - template: Faker.Lorem.Shakespeare.as_you_like_it(), - solution: Faker.Lorem.Shakespeare.hamlet(), - public: [ - %{ - score: :rand.uniform(5), - answer: Faker.StarWars.character(), - program: Faker.Lorem.Shakespeare.king_richard_iii() - } - ], - opaque: [ - %{ - score: :rand.uniform(5), - answer: Faker.StarWars.character(), - program: Faker.Lorem.Shakespeare.king_richard_iii() - } - ], - secret: [ - %{ - score: :rand.uniform(5), - answer: Faker.StarWars.character(), - program: Faker.Lorem.Shakespeare.king_richard_iii() - } - ] - } - end - - def mcq_question_factory do - library = build(:library) - - %Question{ - type: :mcq, - max_xp: 100, - assessment: build(:assessment, %{is_published: true}), - library: build(:library), - grading_library: Enum.random([build(:library), library]), - question: %{ - content: Faker.Pokemon.name(), - choices: Enum.map(0..2, &build(:mcq_choice, %{choice_id: &1, is_correct: &1 == 0})) - } - } - end - - def mcq_choice_factory do - %{ - content: Faker.Pokemon.name(), - hint: Faker.Pokemon.location() - } - end - - def voting_question_factory do - library = build(:library) - contest_assessment = insert(:assessment, %{is_published: true}) - - %Question{ - type: :voting, - max_xp: 100, - assessment: build(:assessment, %{is_published: true}), - library: build(:library), - grading_library: Enum.random([build(:library), library]), - question: %{ - content: Faker.Pokemon.name(), - prepend: Faker.Pokemon.location(), - template: Faker.Lorem.Shakespeare.as_you_like_it(), - contest_number: contest_assessment.number, - reveal_hours: 48 - } - } - end - - def voting_question_content_factory do - contest_assessment = insert(:assessment, %{is_published: true}) - - %{ - content: Faker.Pokemon.name(), - prepend: Faker.Pokemon.location(), - template: Faker.Lorem.Shakespeare.as_you_like_it(), - contest_number: contest_assessment.number, - reveal_hours: 48 - } - end - end - end -end +defmodule Cadet.Assessments.QuestionFactory do + @moduledoc """ + Factories for the Question entity + """ + + defmacro __using__(_opts) do + quote do + alias Cadet.Assessments.Question + + def question_factory do + Enum.random([build(:programming_question), build(:mcq_question), build(:voting_question)]) + end + + def programming_question_factory do + library = build(:library) + + %Question{ + type: :programming, + max_xp: 100, + assessment: build(:assessment, %{is_published: true}), + library: library, + grading_library: Enum.random([build(:library), library]), + question: build(:programming_question_content) + } + end + + def programming_question_content_factory do + %{ + prepend: Faker.Pokemon.location(), + content: Faker.Pokemon.name(), + postpend: Faker.Pokemon.location(), + template: Faker.Lorem.Shakespeare.as_you_like_it(), + solution: Faker.Lorem.Shakespeare.hamlet(), + public: [ + %{ + score: :rand.uniform(5), + answer: Faker.StarWars.character(), + program: Faker.Lorem.Shakespeare.king_richard_iii() + } + ], + opaque: [ + %{ + score: :rand.uniform(5), + answer: Faker.StarWars.character(), + program: Faker.Lorem.Shakespeare.king_richard_iii() + } + ], + secret: [ + %{ + score: :rand.uniform(5), + answer: Faker.StarWars.character(), + program: Faker.Lorem.Shakespeare.king_richard_iii() + } + ] + } + end + + def mcq_question_factory do + library = build(:library) + + %Question{ + type: :mcq, + max_xp: 100, + assessment: build(:assessment, %{is_published: true}), + library: build(:library), + grading_library: Enum.random([build(:library), library]), + question: %{ + content: Faker.Pokemon.name(), + choices: Enum.map(0..2, &build(:mcq_choice, %{choice_id: &1, is_correct: &1 == 0})) + } + } + end + + def mcq_choice_factory do + %{ + content: Faker.Pokemon.name(), + hint: Faker.Pokemon.location() + } + end + + def voting_question_factory do + library = build(:library) + contest_assessment = insert(:assessment, %{is_published: true}) + + %Question{ + type: :voting, + max_xp: 100, + assessment: build(:assessment, %{is_published: true}), + library: build(:library), + grading_library: Enum.random([build(:library), library]), + question: %{ + content: Faker.Pokemon.name(), + prepend: Faker.Pokemon.location(), + template: Faker.Lorem.Shakespeare.as_you_like_it(), + contest_number: contest_assessment.number, + reveal_hours: 48, + token_divider: 50 + } + } + end + + def voting_question_content_factory do + contest_assessment = insert(:assessment, %{is_published: true}) + + %{ + content: Faker.Pokemon.name(), + prepend: Faker.Pokemon.location(), + template: Faker.Lorem.Shakespeare.as_you_like_it(), + contest_number: contest_assessment.number, + reveal_hours: 48, + token_divider: 50 + } + end + end + end +end From d60ac121af39cffd1be3a247a162d27693f48b23 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:09:36 +0800 Subject: [PATCH 29/42] Add files via upload --- test/support/xml_generator.ex | 631 +++++++++++++++++----------------- 1 file changed, 316 insertions(+), 315 deletions(-) diff --git a/test/support/xml_generator.ex b/test/support/xml_generator.ex index e028b31b7..423735317 100644 --- a/test/support/xml_generator.ex +++ b/test/support/xml_generator.ex @@ -1,315 +1,316 @@ -defmodule Cadet.Test.XMLGenerator do - @moduledoc """ - This module contains functions to produce sample XML codes in accordance to - the specification (xml_api.rst). - - # TODO: Refactor using macros - """ - - alias Cadet.Assessments.{Assessment, Question} - - import XmlBuilder - - # TODO: refactor in smaller functions - @spec generate_xml_for(Assessment.t(), [Question.t()], [{atom(), any()}]) :: String.t() - def generate_xml_for( - assessment = %Assessment{}, - questions, - opts \\ [] - ) do - assessment_wide_library = - if opts[:library] do - process_library(opts[:library], using: &deployment/2, no_deployment: opts[:no_deployment]) - else - [] - end - - assessment_wide_grading_library = - if opts[:grading_library] do - process_library( - opts[:grading_library], - using: &graderdeployment/2, - no_deployment: opts[:no_deployment] - ) - else - [] - end - - generate( - content([ - task( - map_convert_keys(assessment, %{ - access: :access, - number: :number, - open_at: :startdate, - close_at: :duedate, - title: :title, - story: :story - }), - [ - password(assessment.password), - reading(assessment.reading), - websummary(assessment.summary_short), - text(assessment.summary_long), - problems([ - for question <- questions do - problem( - generate_problem_attrs( - question, - opts[:problem_permit_keys], - opts[:override_type] - ), - [text(question.question.content)] ++ - process_question_by_question_type(question) ++ - process_library( - question.library, - using: &deployment/2, - no_deployment: opts[:no_deployment] - ) ++ - process_library( - question.grading_library, - using: &graderdeployment/2, - no_deployment: opts[:no_deployment] - ) - ) - end - ]) - ] ++ assessment_wide_library ++ assessment_wide_grading_library - ) - ]) - ) - end - - defp generate_problem_attrs(question, permit_keys, override_type) do - type = override_type || question.type - - map_permit_keys( - %{type: type, maxxp: question.max_xp}, - permit_keys || ~w(type maxxp)a - ) - end - - defp content(children) do - document( - {"CONTENT", - %{ - "xmlns:xsi" => "http://www.w3.org/2001/XMLSchema-instance", - "xmlns:xlink" => "http://128.199.210.247" - }, children} - ) - end - - defp process_question_by_question_type(question = %Question{}) do - case question.type do - :mcq -> - for mcq_choice <- question.question.choices do - choice(%{correct: mcq_choice.is_correct, hint: mcq_choice.hint}, [ - text(mcq_choice.content) - ]) - end - - :programming -> - prepend_field = [prepend(question.question.prepend)] - - template_field = [template(question.question.template)] - - postpend_field = [postpend(question.question.postpend)] - - solution_field = - if question.question[:solution] do - [solution(question.question[:solution])] - else - [] - end - - testcases_fields = [ - testcases( - [ - for testcase <- question.question[:public] do - public(%{score: testcase.score, answer: testcase.answer}, testcase.program) - end - ] ++ - [ - for testcase <- question.question[:opaque] do - opaque(%{score: testcase.score, answer: testcase.answer}, testcase.program) - end - ] ++ - [ - for testcase <- question.question[:secret] do - secret(%{score: testcase.score, answer: testcase.answer}, testcase.program) - end - ] - ) - ] - - [ - snippet( - prepend_field ++ - template_field ++ postpend_field ++ solution_field ++ testcases_fields - ) - ] - - :voting -> - prepend_field = [prepend(question.question.prepend)] - - template_field = [template(question.question.template)] - - voting_field = - voting(%{ - reveal_hours: question.question.reveal_hours, - assessment_number: question.question.contest_number - }) - - [ - snippet(prepend_field ++ template_field) - ] ++ [voting_field] - end - end - - defp voting(raw_attr) do - {"VOTING", map_permit_keys(raw_attr, ~w(assessment_number reveal_hours)a)} - end - - defp deployment(raw_attrs, children) do - {"DEPLOYMENT", map_permit_keys(raw_attrs, ~w(interpreter)a), children} - end - - defp graderdeployment(raw_attrs, children) do - {"GRADERDEPLOYMENT", map_permit_keys(raw_attrs, ~w(interpreter)a), children} - end - - defp external(raw_attrs, children) do - {"EXTERNAL", map_permit_keys(raw_attrs, ~w(name)a), children} - end - - defp symbol(content) do - {"SYMBOL", nil, content} - end - - defp global(children) do - {"GLOBAL", nil, children} - end - - defp identifier(content) do - {"IDENTIFIER", nil, content} - end - - defp value(content) do - {"VALUE", nil, content} - end - - defp process_library(nil, _) do - [] - end - - defp process_library(library, using: tag_function, no_deployment: no_deployment) - when is_map(library) do - if no_deployment do - [] - else - [ - tag_function.( - %{interpreter: library.chapter}, - [ - external( - %{name: library.external.name}, - Enum.map(library.external.symbols, &symbol/1) - ) - ] ++ process_globals(library[:globals]) - ) - ] - end - end - - defp process_globals(nil) do - [] - end - - defp process_globals(globals) when is_map(globals) do - for {k, v} <- globals do - global([identifier(k), value(v)]) - end - end - - defp task(raw_attrs, children) do - {"TASK", map_permit_keys(raw_attrs, ~w(number startdate duedate title story access)a), - children} - end - - defp password(content) do - {"PASSWORD", nil, content} - end - - defp reading(content) do - {"READING", nil, content} - end - - defp websummary(content) do - {"WEBSUMMARY", nil, content} - end - - defp problems(children) do - {"PROBLEMS", nil, children} - end - - defp problem(raw_attrs, children) do - {"PROBLEM", map_permit_keys(raw_attrs, ~w(maxxp type)a), children} - end - - defp text(content) do - {"TEXT", nil, content} - end - - defp choice(raw_attrs, content) do - {"CHOICE", map_permit_keys(raw_attrs, ~w(correct hint)a), content} - end - - defp snippet(children) do - {"SNIPPET", nil, children} - end - - defp prepend(content) do - {"PREPEND", nil, content} - end - - defp template(content) do - {"TEMPLATE", nil, content} - end - - defp postpend(content) do - {"POSTPEND", nil, content} - end - - defp solution(content) do - {"SOLUTION", nil, content} - end - - defp testcases(children) do - {"TESTCASES", nil, children} - end - - defp public(raw_attrs, content) do - {"PUBLIC", map_permit_keys(raw_attrs, ~w(score answer)a), content} - end - - defp opaque(raw_attrs, content) do - {"OPAQUE", map_permit_keys(raw_attrs, ~w(score answer)a), content} - end - - defp secret(raw_attrs, content) do - {"SECRET", map_permit_keys(raw_attrs, ~w(score answer)a), content} - end - - defp map_permit_keys(map, keys) when is_map(map) and is_list(keys) do - map - |> Enum.filter(fn {k, v} -> k in keys and not is_nil(v) end) - |> Enum.into(%{}) - end - - defp map_convert_keys(struct, mapping) do - struct - |> Map.from_struct() - |> Enum.filter(fn {k, v} -> k in Map.keys(mapping) and not is_nil(v) end) - |> Enum.into(%{}, fn {k, v} -> {mapping[k], v} end) - end -end +defmodule Cadet.Test.XMLGenerator do + @moduledoc """ + This module contains functions to produce sample XML codes in accordance to + the specification (xml_api.rst). + + # TODO: Refactor using macros + """ + + alias Cadet.Assessments.{Assessment, Question} + + import XmlBuilder + + # TODO: refactor in smaller functions + @spec generate_xml_for(Assessment.t(), [Question.t()], [{atom(), any()}]) :: String.t() + def generate_xml_for( + assessment = %Assessment{}, + questions, + opts \\ [] + ) do + assessment_wide_library = + if opts[:library] do + process_library(opts[:library], using: &deployment/2, no_deployment: opts[:no_deployment]) + else + [] + end + + assessment_wide_grading_library = + if opts[:grading_library] do + process_library( + opts[:grading_library], + using: &graderdeployment/2, + no_deployment: opts[:no_deployment] + ) + else + [] + end + + generate( + content([ + task( + map_convert_keys(assessment, %{ + access: :access, + number: :number, + open_at: :startdate, + close_at: :duedate, + title: :title, + story: :story + }), + [ + password(assessment.password), + reading(assessment.reading), + websummary(assessment.summary_short), + text(assessment.summary_long), + problems([ + for question <- questions do + problem( + generate_problem_attrs( + question, + opts[:problem_permit_keys], + opts[:override_type] + ), + [text(question.question.content)] ++ + process_question_by_question_type(question) ++ + process_library( + question.library, + using: &deployment/2, + no_deployment: opts[:no_deployment] + ) ++ + process_library( + question.grading_library, + using: &graderdeployment/2, + no_deployment: opts[:no_deployment] + ) + ) + end + ]) + ] ++ assessment_wide_library ++ assessment_wide_grading_library + ) + ]) + ) + end + + defp generate_problem_attrs(question, permit_keys, override_type) do + type = override_type || question.type + + map_permit_keys( + %{type: type, maxxp: question.max_xp}, + permit_keys || ~w(type maxxp)a + ) + end + + defp content(children) do + document( + {"CONTENT", + %{ + "xmlns:xsi" => "http://www.w3.org/2001/XMLSchema-instance", + "xmlns:xlink" => "http://128.199.210.247" + }, children} + ) + end + + defp process_question_by_question_type(question = %Question{}) do + case question.type do + :mcq -> + for mcq_choice <- question.question.choices do + choice(%{correct: mcq_choice.is_correct, hint: mcq_choice.hint}, [ + text(mcq_choice.content) + ]) + end + + :programming -> + prepend_field = [prepend(question.question.prepend)] + + template_field = [template(question.question.template)] + + postpend_field = [postpend(question.question.postpend)] + + solution_field = + if question.question[:solution] do + [solution(question.question[:solution])] + else + [] + end + + testcases_fields = [ + testcases( + [ + for testcase <- question.question[:public] do + public(%{score: testcase.score, answer: testcase.answer}, testcase.program) + end + ] ++ + [ + for testcase <- question.question[:opaque] do + opaque(%{score: testcase.score, answer: testcase.answer}, testcase.program) + end + ] ++ + [ + for testcase <- question.question[:secret] do + secret(%{score: testcase.score, answer: testcase.answer}, testcase.program) + end + ] + ) + ] + + [ + snippet( + prepend_field ++ + template_field ++ postpend_field ++ solution_field ++ testcases_fields + ) + ] + + :voting -> + prepend_field = [prepend(question.question.prepend)] + + template_field = [template(question.question.template)] + + voting_field = + voting(%{ + reveal_hours: question.question.reveal_hours, + assessment_number: question.question.contest_number, + token_divider: question.question.token_divider + }) + + [ + snippet(prepend_field ++ template_field) + ] ++ [voting_field] + end + end + + defp voting(raw_attr) do + {"VOTING", map_permit_keys(raw_attr, ~w(assessment_number reveal_hours token_divider)a)} + end + + defp deployment(raw_attrs, children) do + {"DEPLOYMENT", map_permit_keys(raw_attrs, ~w(interpreter)a), children} + end + + defp graderdeployment(raw_attrs, children) do + {"GRADERDEPLOYMENT", map_permit_keys(raw_attrs, ~w(interpreter)a), children} + end + + defp external(raw_attrs, children) do + {"EXTERNAL", map_permit_keys(raw_attrs, ~w(name)a), children} + end + + defp symbol(content) do + {"SYMBOL", nil, content} + end + + defp global(children) do + {"GLOBAL", nil, children} + end + + defp identifier(content) do + {"IDENTIFIER", nil, content} + end + + defp value(content) do + {"VALUE", nil, content} + end + + defp process_library(nil, _) do + [] + end + + defp process_library(library, using: tag_function, no_deployment: no_deployment) + when is_map(library) do + if no_deployment do + [] + else + [ + tag_function.( + %{interpreter: library.chapter}, + [ + external( + %{name: library.external.name}, + Enum.map(library.external.symbols, &symbol/1) + ) + ] ++ process_globals(library[:globals]) + ) + ] + end + end + + defp process_globals(nil) do + [] + end + + defp process_globals(globals) when is_map(globals) do + for {k, v} <- globals do + global([identifier(k), value(v)]) + end + end + + defp task(raw_attrs, children) do + {"TASK", map_permit_keys(raw_attrs, ~w(number startdate duedate title story access)a), + children} + end + + defp password(content) do + {"PASSWORD", nil, content} + end + + defp reading(content) do + {"READING", nil, content} + end + + defp websummary(content) do + {"WEBSUMMARY", nil, content} + end + + defp problems(children) do + {"PROBLEMS", nil, children} + end + + defp problem(raw_attrs, children) do + {"PROBLEM", map_permit_keys(raw_attrs, ~w(maxxp type)a), children} + end + + defp text(content) do + {"TEXT", nil, content} + end + + defp choice(raw_attrs, content) do + {"CHOICE", map_permit_keys(raw_attrs, ~w(correct hint)a), content} + end + + defp snippet(children) do + {"SNIPPET", nil, children} + end + + defp prepend(content) do + {"PREPEND", nil, content} + end + + defp template(content) do + {"TEMPLATE", nil, content} + end + + defp postpend(content) do + {"POSTPEND", nil, content} + end + + defp solution(content) do + {"SOLUTION", nil, content} + end + + defp testcases(children) do + {"TESTCASES", nil, children} + end + + defp public(raw_attrs, content) do + {"PUBLIC", map_permit_keys(raw_attrs, ~w(score answer)a), content} + end + + defp opaque(raw_attrs, content) do + {"OPAQUE", map_permit_keys(raw_attrs, ~w(score answer)a), content} + end + + defp secret(raw_attrs, content) do + {"SECRET", map_permit_keys(raw_attrs, ~w(score answer)a), content} + end + + defp map_permit_keys(map, keys) when is_map(map) and is_list(keys) do + map + |> Enum.filter(fn {k, v} -> k in keys and not is_nil(v) end) + |> Enum.into(%{}) + end + + defp map_convert_keys(struct, mapping) do + struct + |> Map.from_struct() + |> Enum.filter(fn {k, v} -> k in Map.keys(mapping) and not is_nil(v) end) + |> Enum.into(%{}, fn {k, v} -> {mapping[k], v} end) + end +end From a0bf48a584824bada7a702188ec20f8e61d62c56 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:20:39 +0800 Subject: [PATCH 30/42] Add files via upload --- test/cadet/assessments/question_test.exs | 240 +++++++++++------------ 1 file changed, 120 insertions(+), 120 deletions(-) diff --git a/test/cadet/assessments/question_test.exs b/test/cadet/assessments/question_test.exs index f0f63eb86..34381a5f1 100644 --- a/test/cadet/assessments/question_test.exs +++ b/test/cadet/assessments/question_test.exs @@ -1,120 +1,120 @@ -defmodule Cadet.Assessments.QuestionTest do - alias Cadet.Assessments.Question - - use Cadet.ChangesetCase, entity: Question - - @required_fields ~w(question type assessment_id)a - @required_embeds ~w(library)a - - setup do - assessment = insert(:assessment) - - valid_programming_params = %{ - type: :programming, - assessment_id: assessment.id, - library: build(:library), - question: %{ - content: Faker.Pokemon.name(), - prepend: "", - template: Faker.Lorem.Shakespeare.as_you_like_it(), - postpend: "", - solution: Faker.Lorem.Shakespeare.hamlet() - } - } - - valid_mcq_params = %{ - type: :mcq, - assessment_id: assessment.id, - library: build(:library), - question: %{ - content: Faker.Pokemon.name(), - choices: Enum.map(0..2, &build(:mcq_choice, %{choice_id: &1, is_correct: &1 == 0})) - } - } - - valid_voting_params = %{ - type: :voting, - assessment_id: assessment.id, - library: build(:library), - question: %{ - content: Faker.Pokemon.name(), - contest_number: assessment.number, - reveal_hours: 48, - token_divider: 50 - } - } - - %{ - assessment: assessment, - valid_mcq_params: valid_mcq_params, - valid_programming_params: valid_programming_params, - valid_voting_params: valid_voting_params - } - end - - describe "valid changesets" do - test "valid mcq question", %{valid_mcq_params: params} do - assert_changeset_db(params, :valid) - end - - test "valid programming question", %{valid_programming_params: params} do - assert_changeset_db(params, :valid) - end - - test "valid voting question", %{valid_voting_params: params} do - assert_changeset_db(params, :valid) - end - - test "cast model param in valid changeset to id", %{ - assessment: assessment, - valid_mcq_params: params - } do - params - |> Map.delete(:assessment_id) - |> Map.put(:assessment, assessment) - |> assert_changeset_db(:valid) - end - end - - describe "invalid changesets" do - test "missing params", %{ - valid_mcq_params: mcq_params, - valid_programming_params: programming_params, - valid_voting_params: voting_params - } do - for params <- [mcq_params, programming_params, voting_params], - field <- @required_fields ++ @required_embeds do - params - |> Map.delete(field) - |> assert_changeset(:invalid) - end - end - - test "invalid question content", %{ - valid_mcq_params: mcq_params, - valid_programming_params: programming_params, - valid_voting_params: voting_params - } do - mcq_params - |> Map.put(:type, :programming) - |> assert_changeset(:invalid) - - programming_params - |> Map.put(:type, :mcq) - |> assert_changeset(:invalid) - - voting_params - |> Map.put(:type, :programming) - |> assert_changeset(:invalid) - end - - test "foreign key constraints", %{ - assessment: assessment, - valid_mcq_params: params - } do - {:ok, _} = Repo.delete(assessment) - - assert_changeset_db(params, :invalid) - end - end -end +defmodule Cadet.Assessments.QuestionTest do + alias Cadet.Assessments.Question + + use Cadet.ChangesetCase, entity: Question + + @required_fields ~w(question type assessment_id)a + @required_embeds ~w(library)a + + setup do + assessment = insert(:assessment) + + valid_programming_params = %{ + type: :programming, + assessment_id: assessment.id, + library: build(:library), + question: %{ + content: Faker.Pokemon.name(), + prepend: "", + template: Faker.Lorem.Shakespeare.as_you_like_it(), + postpend: "", + solution: Faker.Lorem.Shakespeare.hamlet() + } + } + + valid_mcq_params = %{ + type: :mcq, + assessment_id: assessment.id, + library: build(:library), + question: %{ + content: Faker.Pokemon.name(), + choices: Enum.map(0..2, &build(:mcq_choice, %{choice_id: &1, is_correct: &1 == 0})) + } + } + + valid_voting_params = %{ + type: :voting, + assessment_id: assessment.id, + library: build(:library), + question: %{ + content: Faker.Pokemon.name(), + contest_number: assessment.number, + reveal_hours: 48, + token_divider: 50 + } + } + + %{ + assessment: assessment, + valid_mcq_params: valid_mcq_params, + valid_programming_params: valid_programming_params, + valid_voting_params: valid_voting_params + } + end + + describe "valid changesets" do + test "valid mcq question", %{valid_mcq_params: params} do + assert_changeset_db(params, :valid) + end + + test "valid programming question", %{valid_programming_params: params} do + assert_changeset_db(params, :valid) + end + + test "valid voting question", %{valid_voting_params: params} do + assert_changeset_db(params, :valid) + end + + test "cast model param in valid changeset to id", %{ + assessment: assessment, + valid_mcq_params: params + } do + params + |> Map.delete(:assessment_id) + |> Map.put(:assessment, assessment) + |> assert_changeset_db(:valid) + end + end + + describe "invalid changesets" do + test "missing params", %{ + valid_mcq_params: mcq_params, + valid_programming_params: programming_params, + valid_voting_params: voting_params + } do + for params <- [mcq_params, programming_params, voting_params], + field <- @required_fields ++ @required_embeds do + params + |> Map.delete(field) + |> assert_changeset(:invalid) + end + end + + test "invalid question content", %{ + valid_mcq_params: mcq_params, + valid_programming_params: programming_params, + valid_voting_params: voting_params + } do + mcq_params + |> Map.put(:type, :programming) + |> assert_changeset(:invalid) + + programming_params + |> Map.put(:type, :mcq) + |> assert_changeset(:invalid) + + voting_params + |> Map.put(:type, :programming) + |> assert_changeset(:invalid) + end + + test "foreign key constraints", %{ + assessment: assessment, + valid_mcq_params: params + } do + {:ok, _} = Repo.delete(assessment) + + assert_changeset_db(params, :invalid) + end + end +end From eaaee94e4f425bb68887e3c43d0c550d98459b9c Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:21:10 +0800 Subject: [PATCH 31/42] Add files via upload --- .../question_types/voting_question_test.exs | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/test/cadet/assessments/question_types/voting_question_test.exs b/test/cadet/assessments/question_types/voting_question_test.exs index 0c5a4f2da..43329a094 100644 --- a/test/cadet/assessments/question_types/voting_question_test.exs +++ b/test/cadet/assessments/question_types/voting_question_test.exs @@ -1,28 +1,28 @@ -defmodule Cadet.Assessments.QuestionTypes.VotingQuestionTest do - alias Cadet.Assessments.QuestionTypes.VotingQuestion - - use Cadet.ChangesetCase, entity: VotingQuestion - - describe "Changesets" do - test "valid changeset" do - assert_changeset( - %{ - content: "content", - contest_number: "C4", - reveal_hours: 48, - token_divider: 50 - }, - :valid - ) - end - - test "invalid changesets" do - assert_changeset( - %{ - content: 1 - }, - :invalid - ) - end - end -end +defmodule Cadet.Assessments.QuestionTypes.VotingQuestionTest do + alias Cadet.Assessments.QuestionTypes.VotingQuestion + + use Cadet.ChangesetCase, entity: VotingQuestion + + describe "Changesets" do + test "valid changeset" do + assert_changeset( + %{ + content: "content", + contest_number: "C4", + reveal_hours: 48, + token_divider: 50 + }, + :valid + ) + end + + test "invalid changesets" do + assert_changeset( + %{ + content: 1 + }, + :invalid + ) + end + end +end From 6508bbfc946f3b1f4627964181123c22d16a30bc Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:21:35 +0800 Subject: [PATCH 32/42] Add files via upload --- test/support/xml_generator.ex | 626 +++++++++++++++++----------------- 1 file changed, 313 insertions(+), 313 deletions(-) diff --git a/test/support/xml_generator.ex b/test/support/xml_generator.ex index 423735317..e5d8d9c76 100644 --- a/test/support/xml_generator.ex +++ b/test/support/xml_generator.ex @@ -1,316 +1,316 @@ -defmodule Cadet.Test.XMLGenerator do - @moduledoc """ +defmodule Cadet.Test.XMLGenerator do + @moduledoc """ This module contains functions to produce sample XML codes in accordance to the specification (xml_api.rst). - + # TODO: Refactor using macros - """ - - alias Cadet.Assessments.{Assessment, Question} - - import XmlBuilder - - # TODO: refactor in smaller functions - @spec generate_xml_for(Assessment.t(), [Question.t()], [{atom(), any()}]) :: String.t() - def generate_xml_for( - assessment = %Assessment{}, - questions, - opts \\ [] - ) do - assessment_wide_library = - if opts[:library] do - process_library(opts[:library], using: &deployment/2, no_deployment: opts[:no_deployment]) - else - [] - end - - assessment_wide_grading_library = - if opts[:grading_library] do - process_library( - opts[:grading_library], - using: &graderdeployment/2, - no_deployment: opts[:no_deployment] - ) - else - [] - end - - generate( - content([ - task( - map_convert_keys(assessment, %{ - access: :access, - number: :number, - open_at: :startdate, - close_at: :duedate, - title: :title, - story: :story - }), - [ - password(assessment.password), - reading(assessment.reading), - websummary(assessment.summary_short), - text(assessment.summary_long), - problems([ - for question <- questions do - problem( - generate_problem_attrs( - question, - opts[:problem_permit_keys], - opts[:override_type] - ), - [text(question.question.content)] ++ - process_question_by_question_type(question) ++ - process_library( - question.library, - using: &deployment/2, - no_deployment: opts[:no_deployment] - ) ++ - process_library( - question.grading_library, - using: &graderdeployment/2, - no_deployment: opts[:no_deployment] - ) - ) - end - ]) - ] ++ assessment_wide_library ++ assessment_wide_grading_library - ) - ]) - ) - end - - defp generate_problem_attrs(question, permit_keys, override_type) do - type = override_type || question.type - - map_permit_keys( - %{type: type, maxxp: question.max_xp}, - permit_keys || ~w(type maxxp)a - ) - end - - defp content(children) do - document( - {"CONTENT", - %{ - "xmlns:xsi" => "http://www.w3.org/2001/XMLSchema-instance", - "xmlns:xlink" => "http://128.199.210.247" - }, children} - ) - end - - defp process_question_by_question_type(question = %Question{}) do - case question.type do - :mcq -> - for mcq_choice <- question.question.choices do - choice(%{correct: mcq_choice.is_correct, hint: mcq_choice.hint}, [ - text(mcq_choice.content) - ]) - end - - :programming -> - prepend_field = [prepend(question.question.prepend)] - - template_field = [template(question.question.template)] - - postpend_field = [postpend(question.question.postpend)] - - solution_field = - if question.question[:solution] do - [solution(question.question[:solution])] - else - [] - end - - testcases_fields = [ - testcases( - [ - for testcase <- question.question[:public] do - public(%{score: testcase.score, answer: testcase.answer}, testcase.program) - end - ] ++ - [ - for testcase <- question.question[:opaque] do - opaque(%{score: testcase.score, answer: testcase.answer}, testcase.program) - end - ] ++ - [ - for testcase <- question.question[:secret] do - secret(%{score: testcase.score, answer: testcase.answer}, testcase.program) - end - ] - ) - ] - - [ - snippet( - prepend_field ++ - template_field ++ postpend_field ++ solution_field ++ testcases_fields - ) - ] - - :voting -> - prepend_field = [prepend(question.question.prepend)] - - template_field = [template(question.question.template)] - - voting_field = - voting(%{ - reveal_hours: question.question.reveal_hours, - assessment_number: question.question.contest_number, - token_divider: question.question.token_divider - }) - - [ - snippet(prepend_field ++ template_field) - ] ++ [voting_field] - end - end - - defp voting(raw_attr) do - {"VOTING", map_permit_keys(raw_attr, ~w(assessment_number reveal_hours token_divider)a)} - end - - defp deployment(raw_attrs, children) do - {"DEPLOYMENT", map_permit_keys(raw_attrs, ~w(interpreter)a), children} - end - - defp graderdeployment(raw_attrs, children) do - {"GRADERDEPLOYMENT", map_permit_keys(raw_attrs, ~w(interpreter)a), children} - end - - defp external(raw_attrs, children) do - {"EXTERNAL", map_permit_keys(raw_attrs, ~w(name)a), children} - end - - defp symbol(content) do - {"SYMBOL", nil, content} - end - - defp global(children) do - {"GLOBAL", nil, children} - end - - defp identifier(content) do - {"IDENTIFIER", nil, content} - end - - defp value(content) do - {"VALUE", nil, content} - end - - defp process_library(nil, _) do - [] - end - - defp process_library(library, using: tag_function, no_deployment: no_deployment) - when is_map(library) do - if no_deployment do - [] - else - [ - tag_function.( - %{interpreter: library.chapter}, - [ - external( - %{name: library.external.name}, - Enum.map(library.external.symbols, &symbol/1) - ) - ] ++ process_globals(library[:globals]) - ) - ] - end - end - - defp process_globals(nil) do - [] - end - - defp process_globals(globals) when is_map(globals) do - for {k, v} <- globals do - global([identifier(k), value(v)]) - end - end - - defp task(raw_attrs, children) do - {"TASK", map_permit_keys(raw_attrs, ~w(number startdate duedate title story access)a), - children} - end - - defp password(content) do - {"PASSWORD", nil, content} - end - - defp reading(content) do - {"READING", nil, content} - end - - defp websummary(content) do - {"WEBSUMMARY", nil, content} - end - - defp problems(children) do - {"PROBLEMS", nil, children} - end - - defp problem(raw_attrs, children) do - {"PROBLEM", map_permit_keys(raw_attrs, ~w(maxxp type)a), children} - end - - defp text(content) do - {"TEXT", nil, content} - end - - defp choice(raw_attrs, content) do - {"CHOICE", map_permit_keys(raw_attrs, ~w(correct hint)a), content} - end - - defp snippet(children) do - {"SNIPPET", nil, children} - end - - defp prepend(content) do - {"PREPEND", nil, content} - end - - defp template(content) do - {"TEMPLATE", nil, content} - end - - defp postpend(content) do - {"POSTPEND", nil, content} - end - - defp solution(content) do - {"SOLUTION", nil, content} - end - - defp testcases(children) do - {"TESTCASES", nil, children} - end - - defp public(raw_attrs, content) do - {"PUBLIC", map_permit_keys(raw_attrs, ~w(score answer)a), content} - end - - defp opaque(raw_attrs, content) do - {"OPAQUE", map_permit_keys(raw_attrs, ~w(score answer)a), content} - end - - defp secret(raw_attrs, content) do - {"SECRET", map_permit_keys(raw_attrs, ~w(score answer)a), content} - end - - defp map_permit_keys(map, keys) when is_map(map) and is_list(keys) do - map - |> Enum.filter(fn {k, v} -> k in keys and not is_nil(v) end) - |> Enum.into(%{}) - end - - defp map_convert_keys(struct, mapping) do - struct - |> Map.from_struct() - |> Enum.filter(fn {k, v} -> k in Map.keys(mapping) and not is_nil(v) end) - |> Enum.into(%{}, fn {k, v} -> {mapping[k], v} end) - end -end + """ + + alias Cadet.Assessments.{Assessment, Question} + + import XmlBuilder + + # TODO: refactor in smaller functions + @spec generate_xml_for(Assessment.t(), [Question.t()], [{atom(), any()}]) :: String.t() + def generate_xml_for( + assessment = %Assessment{}, + questions, + opts \\ [] + ) do + assessment_wide_library = + if opts[:library] do + process_library(opts[:library], using: &deployment/2, no_deployment: opts[:no_deployment]) + else + [] + end + + assessment_wide_grading_library = + if opts[:grading_library] do + process_library( + opts[:grading_library], + using: &graderdeployment/2, + no_deployment: opts[:no_deployment] + ) + else + [] + end + + generate( + content([ + task( + map_convert_keys(assessment, %{ + access: :access, + number: :number, + open_at: :startdate, + close_at: :duedate, + title: :title, + story: :story + }), + [ + password(assessment.password), + reading(assessment.reading), + websummary(assessment.summary_short), + text(assessment.summary_long), + problems([ + for question <- questions do + problem( + generate_problem_attrs( + question, + opts[:problem_permit_keys], + opts[:override_type] + ), + [text(question.question.content)] ++ + process_question_by_question_type(question) ++ + process_library( + question.library, + using: &deployment/2, + no_deployment: opts[:no_deployment] + ) ++ + process_library( + question.grading_library, + using: &graderdeployment/2, + no_deployment: opts[:no_deployment] + ) + ) + end + ]) + ] ++ assessment_wide_library ++ assessment_wide_grading_library + ) + ]) + ) + end + + defp generate_problem_attrs(question, permit_keys, override_type) do + type = override_type || question.type + + map_permit_keys( + %{type: type, maxxp: question.max_xp}, + permit_keys || ~w(type maxxp)a + ) + end + + defp content(children) do + document( + {"CONTENT", + %{ + "xmlns:xsi" => "http://www.w3.org/2001/XMLSchema-instance", + "xmlns:xlink" => "http://128.199.210.247" + }, children} + ) + end + + defp process_question_by_question_type(question = %Question{}) do + case question.type do + :mcq -> + for mcq_choice <- question.question.choices do + choice(%{correct: mcq_choice.is_correct, hint: mcq_choice.hint}, [ + text(mcq_choice.content) + ]) + end + + :programming -> + prepend_field = [prepend(question.question.prepend)] + + template_field = [template(question.question.template)] + + postpend_field = [postpend(question.question.postpend)] + + solution_field = + if question.question[:solution] do + [solution(question.question[:solution])] + else + [] + end + + testcases_fields = [ + testcases( + [ + for testcase <- question.question[:public] do + public(%{score: testcase.score, answer: testcase.answer}, testcase.program) + end + ] ++ + [ + for testcase <- question.question[:opaque] do + opaque(%{score: testcase.score, answer: testcase.answer}, testcase.program) + end + ] ++ + [ + for testcase <- question.question[:secret] do + secret(%{score: testcase.score, answer: testcase.answer}, testcase.program) + end + ] + ) + ] + + [ + snippet( + prepend_field ++ + template_field ++ postpend_field ++ solution_field ++ testcases_fields + ) + ] + + :voting -> + prepend_field = [prepend(question.question.prepend)] + + template_field = [template(question.question.template)] + + voting_field = + voting(%{ + reveal_hours: question.question.reveal_hours, + assessment_number: question.question.contest_number, + token_divider: question.question.token_divider + }) + + [ + snippet(prepend_field ++ template_field) + ] ++ [voting_field] + end + end + + defp voting(raw_attr) do + {"VOTING", map_permit_keys(raw_attr, ~w(assessment_number reveal_hours token_divider)a)} + end + + defp deployment(raw_attrs, children) do + {"DEPLOYMENT", map_permit_keys(raw_attrs, ~w(interpreter)a), children} + end + + defp graderdeployment(raw_attrs, children) do + {"GRADERDEPLOYMENT", map_permit_keys(raw_attrs, ~w(interpreter)a), children} + end + + defp external(raw_attrs, children) do + {"EXTERNAL", map_permit_keys(raw_attrs, ~w(name)a), children} + end + + defp symbol(content) do + {"SYMBOL", nil, content} + end + + defp global(children) do + {"GLOBAL", nil, children} + end + + defp identifier(content) do + {"IDENTIFIER", nil, content} + end + + defp value(content) do + {"VALUE", nil, content} + end + + defp process_library(nil, _) do + [] + end + + defp process_library(library, using: tag_function, no_deployment: no_deployment) + when is_map(library) do + if no_deployment do + [] + else + [ + tag_function.( + %{interpreter: library.chapter}, + [ + external( + %{name: library.external.name}, + Enum.map(library.external.symbols, &symbol/1) + ) + ] ++ process_globals(library[:globals]) + ) + ] + end + end + + defp process_globals(nil) do + [] + end + + defp process_globals(globals) when is_map(globals) do + for {k, v} <- globals do + global([identifier(k), value(v)]) + end + end + + defp task(raw_attrs, children) do + {"TASK", map_permit_keys(raw_attrs, ~w(number startdate duedate title story access)a), + children} + end + + defp password(content) do + {"PASSWORD", nil, content} + end + + defp reading(content) do + {"READING", nil, content} + end + + defp websummary(content) do + {"WEBSUMMARY", nil, content} + end + + defp problems(children) do + {"PROBLEMS", nil, children} + end + + defp problem(raw_attrs, children) do + {"PROBLEM", map_permit_keys(raw_attrs, ~w(maxxp type)a), children} + end + + defp text(content) do + {"TEXT", nil, content} + end + + defp choice(raw_attrs, content) do + {"CHOICE", map_permit_keys(raw_attrs, ~w(correct hint)a), content} + end + + defp snippet(children) do + {"SNIPPET", nil, children} + end + + defp prepend(content) do + {"PREPEND", nil, content} + end + + defp template(content) do + {"TEMPLATE", nil, content} + end + + defp postpend(content) do + {"POSTPEND", nil, content} + end + + defp solution(content) do + {"SOLUTION", nil, content} + end + + defp testcases(children) do + {"TESTCASES", nil, children} + end + + defp public(raw_attrs, content) do + {"PUBLIC", map_permit_keys(raw_attrs, ~w(score answer)a), content} + end + + defp opaque(raw_attrs, content) do + {"OPAQUE", map_permit_keys(raw_attrs, ~w(score answer)a), content} + end + + defp secret(raw_attrs, content) do + {"SECRET", map_permit_keys(raw_attrs, ~w(score answer)a), content} + end + + defp map_permit_keys(map, keys) when is_map(map) and is_list(keys) do + map + |> Enum.filter(fn {k, v} -> k in keys and not is_nil(v) end) + |> Enum.into(%{}) + end + + defp map_convert_keys(struct, mapping) do + struct + |> Map.from_struct() + |> Enum.filter(fn {k, v} -> k in Map.keys(mapping) and not is_nil(v) end) + |> Enum.into(%{}, fn {k, v} -> {mapping[k], v} end) + end +end From a465e8a7dd449ef55389f6fe356d6e39d9f8a971 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:22:02 +0800 Subject: [PATCH 33/42] Add files via upload --- .../factories/assessments/question_factory.ex | 230 +++++++++--------- 1 file changed, 115 insertions(+), 115 deletions(-) diff --git a/test/factories/assessments/question_factory.ex b/test/factories/assessments/question_factory.ex index 536281428..677b98e16 100644 --- a/test/factories/assessments/question_factory.ex +++ b/test/factories/assessments/question_factory.ex @@ -1,116 +1,116 @@ -defmodule Cadet.Assessments.QuestionFactory do - @moduledoc """ +defmodule Cadet.Assessments.QuestionFactory do + @moduledoc """ Factories for the Question entity - """ - - defmacro __using__(_opts) do - quote do - alias Cadet.Assessments.Question - - def question_factory do - Enum.random([build(:programming_question), build(:mcq_question), build(:voting_question)]) - end - - def programming_question_factory do - library = build(:library) - - %Question{ - type: :programming, - max_xp: 100, - assessment: build(:assessment, %{is_published: true}), - library: library, - grading_library: Enum.random([build(:library), library]), - question: build(:programming_question_content) - } - end - - def programming_question_content_factory do - %{ - prepend: Faker.Pokemon.location(), - content: Faker.Pokemon.name(), - postpend: Faker.Pokemon.location(), - template: Faker.Lorem.Shakespeare.as_you_like_it(), - solution: Faker.Lorem.Shakespeare.hamlet(), - public: [ - %{ - score: :rand.uniform(5), - answer: Faker.StarWars.character(), - program: Faker.Lorem.Shakespeare.king_richard_iii() - } - ], - opaque: [ - %{ - score: :rand.uniform(5), - answer: Faker.StarWars.character(), - program: Faker.Lorem.Shakespeare.king_richard_iii() - } - ], - secret: [ - %{ - score: :rand.uniform(5), - answer: Faker.StarWars.character(), - program: Faker.Lorem.Shakespeare.king_richard_iii() - } - ] - } - end - - def mcq_question_factory do - library = build(:library) - - %Question{ - type: :mcq, - max_xp: 100, - assessment: build(:assessment, %{is_published: true}), - library: build(:library), - grading_library: Enum.random([build(:library), library]), - question: %{ - content: Faker.Pokemon.name(), - choices: Enum.map(0..2, &build(:mcq_choice, %{choice_id: &1, is_correct: &1 == 0})) - } - } - end - - def mcq_choice_factory do - %{ - content: Faker.Pokemon.name(), - hint: Faker.Pokemon.location() - } - end - - def voting_question_factory do - library = build(:library) - contest_assessment = insert(:assessment, %{is_published: true}) - - %Question{ - type: :voting, - max_xp: 100, - assessment: build(:assessment, %{is_published: true}), - library: build(:library), - grading_library: Enum.random([build(:library), library]), - question: %{ - content: Faker.Pokemon.name(), - prepend: Faker.Pokemon.location(), - template: Faker.Lorem.Shakespeare.as_you_like_it(), - contest_number: contest_assessment.number, - reveal_hours: 48, - token_divider: 50 - } - } - end - - def voting_question_content_factory do - contest_assessment = insert(:assessment, %{is_published: true}) - - %{ - content: Faker.Pokemon.name(), - prepend: Faker.Pokemon.location(), - template: Faker.Lorem.Shakespeare.as_you_like_it(), - contest_number: contest_assessment.number, - reveal_hours: 48, - token_divider: 50 - } - end - end - end -end + """ + + defmacro __using__(_opts) do + quote do + alias Cadet.Assessments.Question + + def question_factory do + Enum.random([build(:programming_question), build(:mcq_question), build(:voting_question)]) + end + + def programming_question_factory do + library = build(:library) + + %Question{ + type: :programming, + max_xp: 100, + assessment: build(:assessment, %{is_published: true}), + library: library, + grading_library: Enum.random([build(:library), library]), + question: build(:programming_question_content) + } + end + + def programming_question_content_factory do + %{ + prepend: Faker.Pokemon.location(), + content: Faker.Pokemon.name(), + postpend: Faker.Pokemon.location(), + template: Faker.Lorem.Shakespeare.as_you_like_it(), + solution: Faker.Lorem.Shakespeare.hamlet(), + public: [ + %{ + score: :rand.uniform(5), + answer: Faker.StarWars.character(), + program: Faker.Lorem.Shakespeare.king_richard_iii() + } + ], + opaque: [ + %{ + score: :rand.uniform(5), + answer: Faker.StarWars.character(), + program: Faker.Lorem.Shakespeare.king_richard_iii() + } + ], + secret: [ + %{ + score: :rand.uniform(5), + answer: Faker.StarWars.character(), + program: Faker.Lorem.Shakespeare.king_richard_iii() + } + ] + } + end + + def mcq_question_factory do + library = build(:library) + + %Question{ + type: :mcq, + max_xp: 100, + assessment: build(:assessment, %{is_published: true}), + library: build(:library), + grading_library: Enum.random([build(:library), library]), + question: %{ + content: Faker.Pokemon.name(), + choices: Enum.map(0..2, &build(:mcq_choice, %{choice_id: &1, is_correct: &1 == 0})) + } + } + end + + def mcq_choice_factory do + %{ + content: Faker.Pokemon.name(), + hint: Faker.Pokemon.location() + } + end + + def voting_question_factory do + library = build(:library) + contest_assessment = insert(:assessment, %{is_published: true}) + + %Question{ + type: :voting, + max_xp: 100, + assessment: build(:assessment, %{is_published: true}), + library: build(:library), + grading_library: Enum.random([build(:library), library]), + question: %{ + content: Faker.Pokemon.name(), + prepend: Faker.Pokemon.location(), + template: Faker.Lorem.Shakespeare.as_you_like_it(), + contest_number: contest_assessment.number, + reveal_hours: 48, + token_divider: 50 + } + } + end + + def voting_question_content_factory do + contest_assessment = insert(:assessment, %{is_published: true}) + + %{ + content: Faker.Pokemon.name(), + prepend: Faker.Pokemon.location(), + template: Faker.Lorem.Shakespeare.as_you_like_it(), + contest_number: contest_assessment.number, + reveal_hours: 48, + token_divider: 50 + } + end + end + end +end From 5f2df96e3dd2707b5cdc5ba93ca44540de5e736a Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:25:53 +0800 Subject: [PATCH 34/42] Add files via upload --- test/support/xml_generator.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/support/xml_generator.ex b/test/support/xml_generator.ex index e5d8d9c76..7062824fb 100644 --- a/test/support/xml_generator.ex +++ b/test/support/xml_generator.ex @@ -1,9 +1,9 @@ defmodule Cadet.Test.XMLGenerator do @moduledoc """ - This module contains functions to produce sample XML codes in accordance to - the specification (xml_api.rst). - - # TODO: Refactor using macros + This module contains functions to produce sample XML codes in accordance to + the specification (xml_api.rst). + + # TODO: Refactor using macros """ alias Cadet.Assessments.{Assessment, Question} From 2cb1b2eb9866f39ac7f0d5dc548db63a6211d211 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:26:22 +0800 Subject: [PATCH 35/42] Add files via upload --- test/factories/assessments/question_factory.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/factories/assessments/question_factory.ex b/test/factories/assessments/question_factory.ex index 677b98e16..cfd088dd1 100644 --- a/test/factories/assessments/question_factory.ex +++ b/test/factories/assessments/question_factory.ex @@ -1,6 +1,6 @@ defmodule Cadet.Assessments.QuestionFactory do @moduledoc """ - Factories for the Question entity + Factories for the Question entity """ defmacro __using__(_opts) do From ddc874cc0dd929ba7c3a7b36cae15183b71e2f2c Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:28:55 +0800 Subject: [PATCH 36/42] Add files via upload --- test/support/xml_generator.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/support/xml_generator.ex b/test/support/xml_generator.ex index 7062824fb..8782d32a4 100644 --- a/test/support/xml_generator.ex +++ b/test/support/xml_generator.ex @@ -2,7 +2,7 @@ defmodule Cadet.Test.XMLGenerator do @moduledoc """ This module contains functions to produce sample XML codes in accordance to the specification (xml_api.rst). - + # TODO: Refactor using macros """ From 02410e06325a3f02bb39ef149c442ecfb55755cc Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 00:40:58 +0800 Subject: [PATCH 37/42] Update voting_question.ex --- lib/cadet/assessments/question_types/voting_question.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/cadet/assessments/question_types/voting_question.ex b/lib/cadet/assessments/question_types/voting_question.ex index 32cfd5662..2601f32be 100644 --- a/lib/cadet/assessments/question_types/voting_question.ex +++ b/lib/cadet/assessments/question_types/voting_question.ex @@ -11,6 +11,7 @@ defmodule Cadet.Assessments.QuestionTypes.VotingQuestion do field(:template, :string) field(:contest_number, :string) field(:reveal_hours, :integer) + field(:token_divider, :integer) end @required_fields ~w(content contest_number reveal_hours token_divider)a From f61a3588fcb688cee880a3bdf22cf565c409fce4 Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 13:15:59 +0800 Subject: [PATCH 38/42] Add files via upload --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 77f955b81..992f71aed 100644 --- a/mix.exs +++ b/mix.exs @@ -85,7 +85,7 @@ defmodule Cadet.Mixfile do # notifiations system dependencies {:phoenix_html, "~> 3.0"}, {:bamboo, "~> 2.3.0"}, - {:bamboo_ses, "~> 0.3.0"}, + {:bamboo_ses, "~> 0.4.1"}, {:bamboo_phoenix, "~> 1.0.0"}, {:oban, "~> 2.13"}, From d1412cc0835d0b05ccc6cf9285b27f3552ea89fe Mon Sep 17 00:00:00 2001 From: kjw142857 <122250318+kjw142857@users.noreply.github.com> Date: Wed, 8 Nov 2023 14:19:27 +0800 Subject: [PATCH 39/42] Add files via upload From e6dc124bb68a9cbc43c6e5a756af19b8dc1fa543 Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Wed, 8 Nov 2023 14:49:23 +0800 Subject: [PATCH 40/42] Update mix.lock --- mix.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.lock b/mix.lock index a5dc20d76..0b8458245 100644 --- a/mix.lock +++ b/mix.lock @@ -4,7 +4,7 @@ "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, "bamboo": {:hex, :bamboo, "2.3.0", "d2392a2cabe91edf488553d3c70638b532e8db7b76b84b0a39e3dfe492ffd6fc", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dd0037e68e108fd04d0e8773921512c940e35d981e097b5793543e3b2f9cd3f6"}, "bamboo_phoenix": {:hex, :bamboo_phoenix, "1.0.0", "f3cc591ffb163ed0bf935d256f1f4645cd870cf436545601215745fb9cc9953f", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6db88fbb26019c84a47994bb2bd879c0887c29ce6c559bc6385fd54eb8b37dee"}, - "bamboo_ses": {:hex, :bamboo_ses, "0.4.2", "e148a0ae17f8223b830029c2e81b2ba18220aa7378531ef1f50c4212fbd9ddb1", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 1.2.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "feb609b57316d335b217937f66cfc7c1ebe37ec481bebe97fcd5da5f31171808"}, + "bamboo_ses": {:hex, :bamboo_ses, "0.3.2", "891bd2dcb191777d4cb714dca25c9a656c207a1d634ffad7e4173a1332cee6ce", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "0.2.3", [hex: :mail, repo: "hexpm", optional: false]}], "hexpm", "c3f9f58501106fdfba7d85de909bf3b5b02aae09b98080b94528fb607669658f"}, "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, From 2b648c306453f770f9cadb2dd0a782c0bcf3ff62 Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Wed, 8 Nov 2023 14:53:34 +0800 Subject: [PATCH 41/42] Revert "Update mix.lock" This reverts commit e6dc124bb68a9cbc43c6e5a756af19b8dc1fa543. --- mix.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.lock b/mix.lock index 0b8458245..a5dc20d76 100644 --- a/mix.lock +++ b/mix.lock @@ -4,7 +4,7 @@ "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, "bamboo": {:hex, :bamboo, "2.3.0", "d2392a2cabe91edf488553d3c70638b532e8db7b76b84b0a39e3dfe492ffd6fc", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dd0037e68e108fd04d0e8773921512c940e35d981e097b5793543e3b2f9cd3f6"}, "bamboo_phoenix": {:hex, :bamboo_phoenix, "1.0.0", "f3cc591ffb163ed0bf935d256f1f4645cd870cf436545601215745fb9cc9953f", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6db88fbb26019c84a47994bb2bd879c0887c29ce6c559bc6385fd54eb8b37dee"}, - "bamboo_ses": {:hex, :bamboo_ses, "0.3.2", "891bd2dcb191777d4cb714dca25c9a656c207a1d634ffad7e4173a1332cee6ce", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "0.2.3", [hex: :mail, repo: "hexpm", optional: false]}], "hexpm", "c3f9f58501106fdfba7d85de909bf3b5b02aae09b98080b94528fb607669658f"}, + "bamboo_ses": {:hex, :bamboo_ses, "0.4.2", "e148a0ae17f8223b830029c2e81b2ba18220aa7378531ef1f50c4212fbd9ddb1", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:ex_aws, "~> 2.4.1", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 1.2.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "feb609b57316d335b217937f66cfc7c1ebe37ec481bebe97fcd5da5f31171808"}, "blankable": {:hex, :blankable, "1.0.0", "89ab564a63c55af117e115144e3b3b57eb53ad43ba0f15553357eb283e0ed425", [:mix], [], "hexpm", "7cf11aac0e44f4eedbee0c15c1d37d94c090cb72a8d9fddf9f7aec30f9278899"}, "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, From 4537fc06113656dbfe717656949d401f289cd48a Mon Sep 17 00:00:00 2001 From: kjw142857 Date: Thu, 16 Nov 2023 12:08:32 +0800 Subject: [PATCH 42/42] Insert validation that token divider must be > 0 --- .../question_types/voting_question.ex | 1 + .../question_types/voting_question_test.exs | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/lib/cadet/assessments/question_types/voting_question.ex b/lib/cadet/assessments/question_types/voting_question.ex index 2601f32be..95c762fcd 100644 --- a/lib/cadet/assessments/question_types/voting_question.ex +++ b/lib/cadet/assessments/question_types/voting_question.ex @@ -21,5 +21,6 @@ defmodule Cadet.Assessments.QuestionTypes.VotingQuestion do question |> cast(params, @required_fields ++ @optional_fields) |> validate_required(@required_fields) + |> validate_number(:token_divider, greater_than: 0) end end diff --git a/test/cadet/assessments/question_types/voting_question_test.exs b/test/cadet/assessments/question_types/voting_question_test.exs index 43329a094..d714b849b 100644 --- a/test/cadet/assessments/question_types/voting_question_test.exs +++ b/test/cadet/assessments/question_types/voting_question_test.exs @@ -23,6 +23,26 @@ defmodule Cadet.Assessments.QuestionTypes.VotingQuestionTest do }, :invalid ) + + assert_changeset( + %{ + content: "content", + contest_number: "C3", + reveal_hours: 48, + token_divider: -1 + }, + :invalid + ) + + assert_changeset( + %{ + content: "content", + contest_number: "C6", + reveal_hours: 48, + token_divider: 0 + }, + :invalid + ) end end end