diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 244ebc742..aa532789b 100644 --- a/lib/cadet/assessments/assessments.ex +++ b/lib/cadet/assessments/assessments.ex @@ -4,6 +4,7 @@ defmodule Cadet.Assessments do missions, sidequests, paths, etc. """ use Cadet, [:context, :display] + alias Cadet.Incentives.{Achievement, GoalProgress, Goal} import Ecto.Query require Logger @@ -25,7 +26,8 @@ defmodule Cadet.Assessments do alias Cadet.Jobs.Log alias Cadet.ProgramAnalysis.Lexer alias Ecto.Multi - alias Cadet.Incentives.Achievements + alias Cadet.Incentives.{Achievements, AchievementToGoal} + alias Ecto.Changeset alias Timex.Duration require Decimal @@ -146,6 +148,132 @@ defmodule Cadet.Assessments do total_achievement_xp + total_assessment_xp end + def all_user_total_xp(course_id, offset \\ nil, limit \\ nil) do + # get all users even if they have 0 xp + base_user_query = + from( + cr in CourseRegistration, + full_join: u in User, + on: cr.user_id == u.id, + where: cr.course_id == ^course_id, + select: %{ + user_id: u.id, + name: u.name, + username: u.username + } + ) + + achievements_xp_query = + from(u in User, + full_join: cr in CourseRegistration, + on: cr.user_id == u.id and cr.course_id == ^course_id, + left_join: a in Achievement, + on: a.course_id == cr.course_id, + left_join: j in assoc(a, :goals), + left_join: g in assoc(j, :goal), + left_join: p in GoalProgress, + on: p.goal_uuid == g.uuid and p.course_reg_id == cr.id, + where: a.course_id == ^course_id or not is_nil(cr.id), + group_by: [u.id, u.name, u.username, cr.id], + having: + fragment( + "bool_and(?)", + p.completed and p.count == g.target_count and not is_nil(p.course_reg_id) + ), + select_merge: %{ + user_id: u.id, + name: u.name, + username: u.username, + is_variable_xp: fragment("bool_and(is_variable_xp)"), + xp_count_sum: sum(p.count), + xp_max: max(a.xp) + } + ) + + achievements_xp_final_query = + from(ax in subquery(achievements_xp_query), + select: %{ + user_id: ax.user_id, + name: ax.name, + username: ax.username, + achievements_xp: + fragment( + "CASE WHEN ? THEN ? ELSE ? END", + ax.is_variable_xp, + ax.xp_count_sum, + ax.xp_max + ) + } + ) + + submissions_xp_query = + from( + sub_xp in subquery( + from(cr in CourseRegistration, + full_join: u in User, + on: cr.user_id == u.id, + full_join: tm in TeamMember, + on: cr.id == tm.student_id, + full_join: s in Submission, + on: tm.team_id == s.team_id or s.student_id == cr.id, + full_join: a in Answer, + on: s.id == a.submission_id, + where: s.is_grading_published == true and cr.course_id == ^course_id, + group_by: [cr.id, u.name, u.username, s.id, a.xp, a.xp_adjustment], + select: %{ + user_id: cr.id, + name: u.name, + username: u.username, + submission_xp: a.xp + a.xp_adjustment + max(s.xp_bonus) + } + ) + ), + group_by: [sub_xp.user_id, sub_xp.name, sub_xp.username], + select: %{ + user_id: sub_xp.user_id, + name: sub_xp.name, + username: sub_xp.username, + submission_xp: sum(sub_xp.submission_xp) + } + ) + + total_xp_query = + from(bu in subquery(base_user_query), + left_join: ax in subquery(achievements_xp_final_query), + on: bu.user_id == ax.user_id, + left_join: sx in subquery(submissions_xp_query), + on: bu.user_id == sx.user_id, + select: %{ + user_id: bu.user_id, + name: bu.name, + username: bu.username, + total_xp: + fragment( + "COALESCE(?, 0) + COALESCE(?, 0)", + ax.achievements_xp, + sx.submission_xp + ) + }, + order_by: [desc: fragment("total_xp")] + ) + + # add rank index + ranked_xp_query = + from(t in subquery(total_xp_query), + select: %{ + rank: fragment("RANK() OVER (ORDER BY total_xp DESC)"), + user_id: t.user_id, + name: t.name, + username: t.username, + total_xp: t.total_xp + }, + limit: ^limit, + offset: ^offset + ) + + Repo.all(ranked_xp_query) + end + defp decimal_to_integer(decimal) do if Decimal.is_decimal(decimal) do Decimal.to_integer(decimal) @@ -287,6 +415,13 @@ defmodule Cadet.Assessments do |> join(:inner, [a], s in assoc(a, :submission)) |> where([_, s], s.student_id == ^course_reg.id or s.team_id == ^team_id) + visible_entries = + Assessment + |> join(:inner, [a], c in assoc(a, :course)) + |> where([a, c], a.id == ^id) + |> select([a, c], c.top_contest_leaderboard_display) + |> Repo.one() + questions = Question |> where(assessment_id: ^id) @@ -301,7 +436,7 @@ defmodule Cadet.Assessments do {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) + |> load_contest_voting_entries(course_reg, assessment, visible_entries) is_grading_published = Submission @@ -1591,7 +1726,8 @@ defmodule Cadet.Assessments do defp load_contest_voting_entries( questions, %CourseRegistration{role: role, course_id: course_id, id: voter_id}, - assessment + assessment, + visibleEntries ) do Enum.map( questions, @@ -1607,7 +1743,7 @@ defmodule Cadet.Assessments do [] else if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do - fetch_top_popular_score_answers(question_id, 10) + fetch_top_popular_score_answers(question_id, visibleEntries) else [] end @@ -1618,7 +1754,7 @@ defmodule Cadet.Assessments do [] else if leaderboard_open?(assessment, q) or role in @open_all_assessment_roles do - fetch_top_relative_score_answers(question_id, 10) + fetch_top_relative_score_answers(question_id, visibleEntries) else [] end @@ -1677,35 +1813,171 @@ defmodule Cadet.Assessments do ) end + def fetch_contest_voting_assesment_id(assessment_id) do + contest_number = + Assessment + |> where(id: ^assessment_id) + |> select([a], a.number) + |> Repo.one() + + if is_nil(contest_number) do + nil + else + Assessment + |> join(:inner, [a], q in assoc(a, :questions)) + |> where([a, q], q.question["contest_number"] == ^contest_number) + |> select([a], a.id) + |> Repo.one() + end + end + + @doc """ + Fetches all contest scores for the given question, sorted by relative score + + Used for contest leaderboard fetching + """ + def fetch_contest_relative_scores(question_id) do + subquery = + 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, + code: a.answer["code"], + score: a.relative_score, + name: student_user.name, + username: student_user.username + }) + + query = + from(sub in subquery(subquery), + select_merge: %{ + rank: fragment("RANK() OVER (ORDER BY ? DESC)", sub.score) + } + ) + + Repo.all(query) + end + + @doc """ + Fetches all contests for the course id where the voting assessment has been published + + Used for contest leaderboard dropdown fetching + """ + def fetch_all_contests(course_id) do + contest_numbers = + Question + |> where(type: :voting) + |> select([q], q.question["contest_number"]) + |> Repo.all() + |> Enum.reject(&is_nil/1) + + if contest_numbers == [] do + [] + else + Assessment + |> where([a], a.number in ^contest_numbers and a.course_id == ^course_id) + |> join(:inner, [a], ac in AssessmentConfig, on: a.config_id == ac.id) + |> where([a, ac], ac.type == "Contests") + |> select([a], %{contest_id: a.id, title: a.title, published: a.is_published}) + |> Repo.all() + end + end + + @doc """ + Fetches all contest scores for the given question, sorted by popular score + + Used for contest leaderboard fetching + """ + def fetch_contest_popular_scores(question_id) do + subquery = + Answer + |> where(question_id: ^question_id) + |> where( + [a], + fragment( + "?->>'code' like ?", + a.answer, + "%return%" + ) + ) + |> order_by(desc: :popular_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, + code: a.answer["code"], + score: a.popular_score, + name: student_user.name, + username: student_user.username + }) + + query = + from(sub in subquery(subquery), + select_merge: %{ + rank: fragment("RANK() OVER (ORDER BY ? DESC)", sub.score) + } + ) + + Repo.all(query) + 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%" + subquery = + 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() + |> 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 + }) + + ranked_subquery = + from(sub in subquery(subquery), + select_merge: %{ + rank: fragment("RANK() OVER (ORDER BY ? DESC)", sub.relative_score) + } + ) + + final_query = + from(r in subquery(ranked_subquery), + where: r.rank <= ^number_of_answers + ) + + Repo.all(final_query) end @doc """ @@ -1714,29 +1986,42 @@ defmodule Cadet.Assessments do Used for contest leaderboard fetching """ def fetch_top_popular_score_answers(question_id, number_of_answers) do - Answer - |> where(question_id: ^question_id) - |> where( - [a], - fragment( - "?->>'code' like ?", - a.answer, - "%return%" + subquery = + Answer + |> where(question_id: ^question_id) + |> where( + [a], + fragment( + "?->>'code' like ?", + a.answer, + "%return%" + ) ) - ) - |> order_by(desc: :popular_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, - popular_score: a.popular_score, - student_name: student_user.name - }) - |> limit(^number_of_answers) - |> Repo.all() + |> order_by(desc: :popular_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, + popular_score: a.popular_score, + student_name: student_user.name + }) + + ranked_subquery = + from(sub in subquery(subquery), + select_merge: %{ + rank: fragment("RANK() OVER (ORDER BY ? DESC)", sub.popular_score) + } + ) + + final_query = + from(r in subquery(ranked_subquery), + where: r.rank <= ^number_of_answers + ) + + Repo.all(final_query) end @doc """ @@ -1774,13 +2059,28 @@ defmodule Cadet.Assessments 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() + voting_questions_to_update = fetch_voting_questions_due_yesterday() || [] - _ = - voting_questions_to_update - |> Enum.map(fn qn -> compute_relative_score(qn.id) end) + voting_questions_to_update = + if is_nil(voting_questions_to_update), do: [], else: voting_questions_to_update + + scores = + Enum.map(voting_questions_to_update, fn qn -> + compute_relative_score(qn.id) + end) + + if Enum.empty?(voting_questions_to_update) do + Logger.warn("No voting questions to update.") + else + # Process each voting question + Enum.each(voting_questions_to_update, fn qn -> + assign_winning_contest_entries_xp(qn.id) + end) - Logger.info("Successfully update_final_contest_leaderboards") + Logger.info("Successfully update_final_contest_leaderboards") + end + + scores end end @@ -1797,11 +2097,110 @@ defmodule Cadet.Assessments do |> Repo.all() end + @doc """ + Automatically assigns XP to the winning contest entries + """ + def assign_winning_contest_entries_xp(contest_voting_question_id) do + voting_questions = + Question + |> where(type: :voting) + |> where(id: ^contest_voting_question_id) + |> Repo.one() + + contest_question_id = + SubmissionVotes + |> where(question_id: ^contest_voting_question_id) + |> join(:inner, [sv], ans in Answer, on: sv.submission_id == ans.submission_id) + |> select([sv, ans], ans.question_id) + |> limit(1) + |> Repo.one() + + if is_nil(contest_question_id) do + Logger.warn("Contest question ID is missing. Terminating.") + :ok + else + default_xp_values = %Cadet.Assessments.QuestionTypes.VotingQuestion{} |> Map.get(:xp_values) + scores = voting_questions.question["xp_values"] || default_xp_values + + if scores == [] do + Logger.warn("No XP values provided. Terminating.") + :ok + else + Repo.transaction(fn -> + winning_popular_entries = + Answer + |> where(question_id: ^contest_question_id) + |> select([a], %{ + id: a.id, + rank: fragment("rank() OVER (ORDER BY ? DESC)", a.popular_score) + }) + |> Repo.all() + + winning_popular_entries + |> Enum.each(fn %{id: answer_id, rank: rank} -> + increment = Enum.at(scores, rank - 1, 0) + answer = Repo.get!(Answer, answer_id) + Repo.update!(Changeset.change(answer, %{xp: increment})) + end) + + winning_score_entries = + Answer + |> where(question_id: ^contest_question_id) + |> select([a], %{ + id: a.id, + rank: fragment("rank() OVER (ORDER BY ? DESC)", a.relative_score) + }) + |> Repo.all() + + winning_score_entries + |> Enum.each(fn %{id: answer_id, rank: rank} -> + increment = Enum.at(scores, rank - 1, 0) + answer = Repo.get!(Answer, answer_id) + new_value = answer.xp + increment + Repo.update!(Changeset.change(answer, %{xp: new_value})) + end) + end) + + Logger.info("XP assigned to winning contest entries") + end + end + 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 + # reset all scores to 0 first + voting_questions = + Question + |> where(type: :voting) + |> where(id: ^contest_voting_question_id) + |> Repo.one() + + if is_nil(voting_questions) do + IO.puts("Voting question not found, skipping score computation.") + :ok + else + course_id = + Assessment + |> where(id: ^voting_questions.assessment_id) + |> select([a], a.course_id) + |> Repo.one() + + if is_nil(course_id) do + IO.puts("Course ID not found, skipping score computation.") + :ok + else + contest_question_id = fetch_associated_contest_question_id(course_id, voting_questions) + + Answer + |> where([ans], ans.question_id == ^contest_question_id) + |> update([ans], set: [popular_score: 0.0, relative_score: 0.0]) + |> Repo.update_all([]) + end + end + # query all records from submission votes tied to the question id -> # map score to user id -> # store as grade -> diff --git a/lib/cadet/assessments/question_types/voting_question.ex b/lib/cadet/assessments/question_types/voting_question.ex index 95c762fcd..52aae9d51 100644 --- a/lib/cadet/assessments/question_types/voting_question.ex +++ b/lib/cadet/assessments/question_types/voting_question.ex @@ -12,10 +12,11 @@ defmodule Cadet.Assessments.QuestionTypes.VotingQuestion do field(:contest_number, :string) field(:reveal_hours, :integer) field(:token_divider, :integer) + field(:xp_values, {:array, :integer}, default: [500, 400, 300]) end @required_fields ~w(content contest_number reveal_hours token_divider)a - @optional_fields ~w(prepend template)a + @optional_fields ~w(prepend template xp_values)a def changeset(question, params \\ %{}) do question diff --git a/lib/cadet/courses/course.ex b/lib/cadet/courses/course.ex index c74d23bd7..7ddd80a49 100644 --- a/lib/cadet/courses/course.ex +++ b/lib/cadet/courses/course.ex @@ -12,6 +12,10 @@ defmodule Cadet.Courses.Course do viewable: boolean(), enable_game: boolean(), enable_achievements: boolean(), + enable_overall_leaderboard: boolean(), + enable_contest_leaderboard: boolean(), + top_leaderboard_display: integer(), + top_contest_leaderboard_display: integer(), enable_sourcecast: boolean(), enable_stories: boolean(), source_chapter: integer(), @@ -26,6 +30,10 @@ defmodule Cadet.Courses.Course do field(:viewable, :boolean, default: true) field(:enable_game, :boolean, default: true) field(:enable_achievements, :boolean, default: true) + field(:enable_overall_leaderboard, :boolean, default: true) + field(:enable_contest_leaderboard, :boolean, default: true) + field(:top_leaderboard_display, :integer, default: 100) + field(:top_contest_leaderboard_display, :integer, default: 10) field(:enable_sourcecast, :boolean, default: true) field(:enable_stories, :boolean, default: false) field(:source_chapter, :integer) @@ -41,7 +49,7 @@ defmodule Cadet.Courses.Course do end @required_fields ~w(course_name viewable enable_game - enable_achievements enable_sourcecast enable_stories source_chapter source_variant)a + enable_achievements enable_overall_leaderboard enable_contest_leaderboard top_leaderboard_display top_contest_leaderboard_display enable_sourcecast enable_stories source_chapter source_variant)a @optional_fields ~w(course_short_name module_help_text)a def changeset(course, params) do diff --git a/lib/cadet/jobs/xml_parser.ex b/lib/cadet/jobs/xml_parser.ex index b49128506..f12408236 100644 --- a/lib/cadet/jobs/xml_parser.ex +++ b/lib/cadet/jobs/xml_parser.ex @@ -246,22 +246,30 @@ defmodule Cadet.Updater.XMLParser do end defp process_question_entity_by_type(entity, "voting") do - Map.merge( - entity - |> xpath( - ~x"."e, - content: ~x"./TEXT/text()" |> transform_by(&process_charlist/1), - prepend: ~x"./SNIPPET/PREPEND/text()" |> transform_by(&process_charlist/1), - template: ~x"./SNIPPET/TEMPLATE/text()" |> transform_by(&process_charlist/1) - ), - entity - |> xpath( - ~x"./VOTING"e, - contest_number: ~x"./@assessment_number"s, - reveal_hours: ~x"./@reveal_hours"i, - token_divider: ~x"./@token_divider"i + question_data = + Map.merge( + entity + |> xpath( + ~x"."e, + content: ~x"./TEXT/text()" |> transform_by(&process_charlist/1), + prepend: ~x"./SNIPPET/PREPEND/text()" |> transform_by(&process_charlist/1), + template: ~x"./SNIPPET/TEMPLATE/text()" |> transform_by(&process_charlist/1) + ), + entity + |> xpath( + ~x"./VOTING"e, + contest_number: ~x"./@assessment_number"s, + reveal_hours: ~x"./@reveal_hours"i, + token_divider: ~x"./@token_divider"i + ) ) - ) + + xp_values = + entity + |> xpath(~x"./VOTING/XP_ARRAY/XP"el, value: ~x"./@value"i) + |> Enum.map(& &1[:value]) + + if xp_values == [], do: question_data, else: Map.merge(question_data, %{xp_values: xp_values}) end defp process_question_entity_by_type(_, _) do diff --git a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex index 9263195f2..5ad008963 100644 --- a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex @@ -135,7 +135,11 @@ defmodule CadetWeb.AdminAssessmentsController do end end - def get_score_leaderboard(conn, %{"assessmentid" => assessment_id, "course_id" => course_id}) do + def get_score_leaderboard(conn, %{ + "assessmentid" => assessment_id, + "visibleentries" => visible_entries, + "course_id" => course_id + }) do voting_questions = Question |> where(type: :voting) @@ -146,7 +150,7 @@ defmodule CadetWeb.AdminAssessmentsController do result = contest_id - |> Assessments.fetch_top_relative_score_answers(10) + |> Assessments.fetch_top_relative_score_answers(visible_entries) |> Enum.map(fn entry -> AssessmentsHelpers.build_contest_leaderboard_entry(entry) end) @@ -154,7 +158,11 @@ defmodule CadetWeb.AdminAssessmentsController do render(conn, "leaderboard.json", leaderboard: result) end - def get_popular_leaderboard(conn, %{"assessmentid" => assessment_id, "course_id" => course_id}) do + def get_popular_leaderboard(conn, %{ + "assessmentid" => assessment_id, + "visibleentries" => visible_entries, + "course_id" => course_id + }) do voting_questions = Question |> where(type: :voting) @@ -165,7 +173,7 @@ defmodule CadetWeb.AdminAssessmentsController do result = contest_id - |> Assessments.fetch_top_popular_score_answers(10) + |> Assessments.fetch_top_popular_score_answers(visible_entries) |> Enum.map(fn entry -> AssessmentsHelpers.build_popular_leaderboard_entry(entry) end) @@ -173,6 +181,37 @@ defmodule CadetWeb.AdminAssessmentsController do render(conn, "leaderboard.json", leaderboard: result) end + def calculate_contest_score(conn, %{"assessmentid" => assessment_id, "course_id" => course_id}) do + voting_questions = + Question + |> where(type: :voting) + |> where(assessment_id: ^assessment_id) + |> Repo.one() + + if voting_questions do + Assessments.compute_relative_score(voting_questions.id) + text(conn, "CONTEST SCORE CALCULATED") + else + text(conn, "No voting questions found for the given assessment") + end + end + + def dispatch_contest_xp(conn, %{"assessmentid" => assessment_id, "course_id" => course_id}) do + voting_questions = + Question + |> where(type: :voting) + |> where(assessment_id: ^assessment_id) + |> Repo.one() + + if voting_questions do + Assessments.assign_winning_contest_entries_xp(voting_questions.id) + + text(conn, "XP Dispatched") + else + text(conn, "No voting questions found for the given assessment") + end + end + defp check_dates(open_at, close_at, assessment) do if is_nil(open_at) and is_nil(close_at) do {:ok, assessment} @@ -288,7 +327,7 @@ defmodule CadetWeb.AdminAssessmentsController do swagger_path :get_score_leaderboard do get("/courses/{course_id}/admin/assessments/:assessmentid/scoreLeaderboard") - summary("get the top 10 contest entries based on score") + summary("get the top X contest entries based on score") security([%{JWT: []}]) diff --git a/lib/cadet_web/admin_controllers/admin_courses_controller.ex b/lib/cadet_web/admin_controllers/admin_courses_controller.ex index 7220a4d80..830cb06f9 100644 --- a/lib/cadet_web/admin_controllers/admin_courses_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_courses_controller.ex @@ -106,6 +106,10 @@ defmodule CadetWeb.AdminCoursesController do viewable(:body, :boolean, "Course viewability") enable_game(:body, :boolean, "Enable game") enable_achievements(:body, :boolean, "Enable achievements") + enable_overall_leaderboard(:body, :boolean, "Enable overall leaderboard") + enable_contest_leaderboard(:body, :boolean, "Enable contest leaderboard") + top_leaderboard_display(:body, :integer, "Top Leaderboard Display") + top_contest_leaderboard_display(:body, :integer, "Top Contest Leaderboard Display") enable_sourcecast(:body, :boolean, "Enable sourcecast") enable_stories(:body, :boolean, "Enable stories") sublanguage(:body, Schema.ref(:AdminSublanguage), "sublanguage object") diff --git a/lib/cadet_web/controllers/assessments_controller.ex b/lib/cadet_web/controllers/assessments_controller.ex index bb9a563f2..fc556ca4d 100644 --- a/lib/cadet_web/controllers/assessments_controller.ex +++ b/lib/cadet_web/controllers/assessments_controller.ex @@ -3,7 +3,11 @@ defmodule CadetWeb.AssessmentsController do use PhoenixSwagger + import Ecto.Query, only: [where: 2] + alias Cadet.Assessments + alias Cadet.Assessments.{Question, Assessment} + alias Cadet.{Assessments, Repo} # These roles can save and finalise answers for closed assessments and # submitted answers @@ -67,6 +71,50 @@ defmodule CadetWeb.AssessmentsController do end end + def combined_total_xp_for_all_users(conn, %{"course_id" => course_id}) do + users_with_xp = Assessments.all_user_total_xp(course_id) + json(conn, %{users: users_with_xp}) + end + + def paginated_total_xp_for_leaderboard_display(conn, %{"course_id" => course_id, "page" => page, "page_size" => page_size}) do + offset = (String.to_integer(page) - 1) * String.to_integer(page_size) + paginated_display = Assessments.all_user_total_xp(course_id, offset, page_size) + json(conn, %{users: paginated_display}) + end + + def get_contest_popular_scores(conn, %{ + "assessmentid" => assessment_id, + "course_id" => course_id + }) do + contest_id = + Question + |> where(assessment_id: ^assessment_id) + |> Repo.one() + + contestpop = Assessments.fetch_contest_popular_scores(contest_id.id) + voting_id = Assessments.fetch_contest_voting_assesment_id(assessment_id) + json(conn, %{contest_popular: contestpop, voting_id: voting_id}) + end + + def get_contest_relative_scores(conn, %{ + "assessmentid" => assessment_id, + "course_id" => course_id + }) do + contest_id = + Question + |> where(assessment_id: ^assessment_id) + |> Repo.one() + + contestscore = Assessments.fetch_contest_relative_scores(contest_id.id) + voting_id = Assessments.fetch_contest_voting_assesment_id(assessment_id) + json(conn, %{contest_score: contestscore, voting_id: voting_id}) + end + + def get_all_contests(conn, %{"course_id" => course_id}) do + contests = Assessments.fetch_all_contests(course_id) + json(conn, contests) + end + swagger_path :submit do post("/courses/{course_id}/assessments/{assessmentId}/submit") summary("Finalise submission for an assessment") diff --git a/lib/cadet_web/controllers/courses_controller.ex b/lib/cadet_web/controllers/courses_controller.ex index e6555bd7a..cb8cf68dd 100644 --- a/lib/cadet_web/controllers/courses_controller.ex +++ b/lib/cadet_web/controllers/courses_controller.ex @@ -54,6 +54,14 @@ defmodule CadetWeb.CoursesController do viewable(:body, :boolean, "Course viewability", required: true) enable_game(:body, :boolean, "Enable game", required: true) enable_achievements(:body, :boolean, "Enable achievements", required: true) + enable_overall_leaderboard(:body, :boolean, "Enable overall leaderboard", required: true) + enable_contest_leaderboard(:body, :boolean, "Enable contest leaderboard", required: true) + top_leaderboard_display(:body, :number, "Top leaderboard display", required: true) + + top_contest_leaderboard_display(:body, :number, "Top contest leaderboard display", + required: true + ) + enable_sourcecast(:body, :boolean, "Enable sourcecast", required: true) enable_stories(:body, :boolean, "Enable stories", required: true) source_chapter(:body, :number, "Default source chapter", required: true) @@ -95,6 +103,14 @@ defmodule CadetWeb.CoursesController do viewable(:boolean, "Course viewability", required: true) enable_game(:boolean, "Enable game", required: true) enable_achievements(:boolean, "Enable achievements", required: true) + enable_overall_leaderboard(:boolean, "Enable overall leaderboard", required: true) + enable_contest_leaderboard(:boolean, "Enable contest leaderboard", required: true) + top_leaderboard_display(:boolean, "Top leaderboard display", required: true) + + top_contest_leaderboard_display(:boolean, "Top contest leaderboard display", + required: true + ) + enable_sourcecast(:boolean, "Enable sourcecast", required: true) enable_stories(:boolean, "Enable stories", required: true) source_chapter(:integer, "Source Chapter number from 1 to 4", required: true) @@ -109,6 +125,10 @@ defmodule CadetWeb.CoursesController do viewable: true, enable_game: true, enable_achievements: true, + enable_overall_leaderboard: true, + enable_contest_leaderboard: true, + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10, enable_sourcecast: true, enable_stories: false, source_chapter: 1, diff --git a/lib/cadet_web/controllers/user_controller.ex b/lib/cadet_web/controllers/user_controller.ex index cfc162652..02abbc05f 100644 --- a/lib/cadet_web/controllers/user_controller.ex +++ b/lib/cadet_web/controllers/user_controller.ex @@ -315,6 +315,14 @@ defmodule CadetWeb.UserController do viewable(:boolean, "Course viewability", required: true) enable_game(:boolean, "Enable game", required: true) enable_achievements(:boolean, "Enable achievements", required: true) + enable_overall_leaderboard(:boolean, "Enable overall leaderboard", required: true) + enable_contest_leaderboard(:boolean, "Enable contest leadeboard", required: true) + top_leaderboard_display(:integer, "Top leaderboard display", required: true) + + top_contest_leaderboard_display(:integer, "Top contest leaderboard display", + required: true + ) + enable_sourcecast(:boolean, "Enable sourcecast", required: true) enable_stories(:boolean, "Enable stories", required: true) source_chapter(:integer, "Source Chapter number from 1 to 4", required: true) @@ -330,6 +338,10 @@ defmodule CadetWeb.UserController do viewable: true, enable_game: true, enable_achievements: true, + enable_overall_leaderboard: true, + enable_contest_leaderboard: true, + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10, enable_sourcecast: true, enable_stories: false, source_chapter: 1, diff --git a/lib/cadet_web/helpers/assessments_helpers.ex b/lib/cadet_web/helpers/assessments_helpers.ex index 967df1131..310e69083 100644 --- a/lib/cadet_web/helpers/assessments_helpers.ex +++ b/lib/cadet_web/helpers/assessments_helpers.ex @@ -107,7 +107,8 @@ defmodule CadetWeb.AssessmentsHelpers do transform_map_for_view(leaderboard_ans, %{ submission_id: :submission_id, answer: :answer, - student_name: :student_name + student_name: :student_name, + rank: :rank }), "final_score", Float.round(leaderboard_ans.relative_score, 2) @@ -119,7 +120,8 @@ defmodule CadetWeb.AssessmentsHelpers do transform_map_for_view(leaderboard_ans, %{ submission_id: :submission_id, answer: :answer, - student_name: :student_name + student_name: :student_name, + rank: :rank }), "final_score", Float.round(leaderboard_ans.popular_score, 2) diff --git a/lib/cadet_web/router.ex b/lib/cadet_web/router.ex index 1ee304c01..de48f5ee4 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -118,6 +118,23 @@ defmodule CadetWeb.Router do put("/user/game_states", UserController, :update_game_states) put("/user/research_agreement", UserController, :update_research_agreement) + get("/all_users_xp", AssessmentsController, :combined_total_xp_for_all_users) + get("/get_paginated_display/:page/:page_size", AssessmentsController, :paginated_total_xp_for_leaderboard_display) + + get( + "/leaderboard/contests/:assessmentid/get_score_leaderboard", + AssessmentsController, + :get_contest_relative_scores + ) + + get( + "/leaderboard/contests/:assessmentid/get_popular_vote_leaderboard", + AssessmentsController, + :get_contest_popular_scores + ) + + get("/all_contests", AssessmentsController, :get_all_contests) + get("/config", CoursesController, :index) get("/team/:assessmentid", TeamController, :index) @@ -179,14 +196,26 @@ defmodule CadetWeb.Router do resources("/sourcecast", AdminSourcecastController, only: [:create, :delete]) + post( + "/assessments/:assessmentid/calculateContestScore", + AdminAssessmentsController, + :calculate_contest_score + ) + + post( + "/assessments/:assessmentid/dispatchContestXp", + AdminAssessmentsController, + :dispatch_contest_xp + ) + get( - "/assessments/:assessmentid/popularVoteLeaderboard", + "/assessments/:assessmentid/:visibleentries/popularVoteLeaderboard", AdminAssessmentsController, :get_popular_leaderboard ) get( - "/assessments/:assessmentid/scoreLeaderboard", + "/assessments/:assessmentid/:visibleentries/scoreLeaderboard", AdminAssessmentsController, :get_score_leaderboard ) diff --git a/lib/cadet_web/views/assessments_view.ex b/lib/cadet_web/views/assessments_view.ex index 7542c25c9..b9e9d54f6 100644 --- a/lib/cadet_web/views/assessments_view.ex +++ b/lib/cadet_web/views/assessments_view.ex @@ -77,7 +77,8 @@ defmodule CadetWeb.AssessmentsView do %{ student_name: :student_name, answer: & &1.answer["code"], - final_score: "final_score" + final_score: "final_score", + rank: :rank } ) end diff --git a/lib/cadet_web/views/courses_view.ex b/lib/cadet_web/views/courses_view.ex index a6ae9c4fa..b1db95a07 100644 --- a/lib/cadet_web/views/courses_view.ex +++ b/lib/cadet_web/views/courses_view.ex @@ -10,6 +10,10 @@ defmodule CadetWeb.CoursesView do viewable: :viewable, enableGame: :enable_game, enableAchievements: :enable_achievements, + enableOverallLeaderboard: :enable_overall_leaderboard, + enableContestLeaderboard: :enable_contest_leaderboard, + topLeaderboardDisplay: :top_leaderboard_display, + topContestLeaderboardDisplay: :top_contest_leaderboard_display, enableSourcecast: :enable_sourcecast, enableStories: :enable_stories, sourceChapter: :source_chapter, diff --git a/lib/cadet_web/views/user_view.ex b/lib/cadet_web/views/user_view.ex index c324d4bf0..4a497465b 100644 --- a/lib/cadet_web/views/user_view.ex +++ b/lib/cadet_web/views/user_view.ex @@ -103,6 +103,10 @@ defmodule CadetWeb.UserView do viewable: :viewable, enableGame: :enable_game, enableAchievements: :enable_achievements, + enableOverallLeaderboard: :enable_overall_leaderboard, + enableContestLeaderboard: :enable_contest_leaderboard, + topLeaderboardDisplay: :top_leaderboard_display, + topContestLeaderboardDisplay: :top_contest_leaderboard_display, enableSourcecast: :enable_sourcecast, enableStories: :enable_stories, sourceChapter: :source_chapter, diff --git a/priv/repo/migrations/20250429081534_add_leaderboard_display_columns.exs b/priv/repo/migrations/20250429081534_add_leaderboard_display_columns.exs new file mode 100644 index 000000000..6dedc2abc --- /dev/null +++ b/priv/repo/migrations/20250429081534_add_leaderboard_display_columns.exs @@ -0,0 +1,21 @@ +defmodule Cadet.Repo.Migrations.AddLeaderboardDisplayColumns do + use Ecto.Migration + + def change do + alter table(:courses) do + add(:enable_overall_leaderboard, :boolean, null: false, default: true) + add(:enable_contest_leaderboard, :boolean, null: false, default: true) + add(:top_leaderboard_display, :integer, default: 100) + add(:top_contest_leaderboard_display, :integer, default: 10) + end + + execute(fn -> + repo().update_all("courses", set: [ + enable_overall_leaderboard: true, + enable_contest_leaderboard: true, + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 + ]) + end) + end +end diff --git a/test/cadet/courses/course_test.exs b/test/cadet/courses/course_test.exs index 4e852596e..74bfed6fe 100644 --- a/test/cadet/courses/course_test.exs +++ b/test/cadet/courses/course_test.exs @@ -9,7 +9,9 @@ defmodule Cadet.Courses.CourseTest do %{ course_name: "Data Structures and Algorithms", source_chapter: 1, - source_variant: "default" + source_variant: "default", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -19,7 +21,9 @@ defmodule Cadet.Courses.CourseTest do course_short_name: "CS2040S", course_name: "Data Structures and Algorithms", source_chapter: 1, - source_variant: "default" + source_variant: "default", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -29,7 +33,9 @@ defmodule Cadet.Courses.CourseTest do viewable: false, course_name: "Data Structures and Algorithms", source_chapter: 1, - source_variant: "default" + source_variant: "default", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -39,7 +45,9 @@ defmodule Cadet.Courses.CourseTest do enable_game: false, course_name: "Data Structures and Algorithms", source_chapter: 1, - source_variant: "default" + source_variant: "default", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -49,7 +57,9 @@ defmodule Cadet.Courses.CourseTest do enable_achievements: false, course_name: "Data Structures and Algorithms", source_chapter: 1, - source_variant: "default" + source_variant: "default", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -59,7 +69,9 @@ defmodule Cadet.Courses.CourseTest do enable_sourcecast: false, course_name: "Data Structures and Algorithms", source_chapter: 1, - source_variant: "default" + source_variant: "default", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -69,7 +81,9 @@ defmodule Cadet.Courses.CourseTest do module_help_text: "", course_name: "Data Structures and Algorithms", source_chapter: 1, - source_variant: "default" + source_variant: "default", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -79,7 +93,9 @@ defmodule Cadet.Courses.CourseTest do module_help_text: "Module help text", course_name: "Data Structures and Algorithms", source_chapter: 1, - source_variant: "default" + source_variant: "default", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -91,7 +107,9 @@ defmodule Cadet.Courses.CourseTest do enable_sourcecast: true, course_name: "Data Structures and Algorithms", source_chapter: 1, - source_variant: "default" + source_variant: "default", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -104,7 +122,9 @@ defmodule Cadet.Courses.CourseTest do enable_stories: false, course_name: "Data Structures and Algorithms", source_chapter: 1, - source_variant: "default" + source_variant: "default", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -113,7 +133,9 @@ defmodule Cadet.Courses.CourseTest do %{ source_chapter: 1, source_variant: "wasm", - course_name: "Data Structures and Algorithms" + course_name: "Data Structures and Algorithms", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -122,7 +144,9 @@ defmodule Cadet.Courses.CourseTest do %{ source_chapter: 2, source_variant: "lazy", - course_name: "Data Structures and Algorithms" + course_name: "Data Structures and Algorithms", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -131,7 +155,9 @@ defmodule Cadet.Courses.CourseTest do %{ source_chapter: 3, source_variant: "non-det", - course_name: "Data Structures and Algorithms" + course_name: "Data Structures and Algorithms", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -140,7 +166,9 @@ defmodule Cadet.Courses.CourseTest do %{ source_chapter: 3, source_variant: "native", - course_name: "Data Structures and Algorithms" + course_name: "Data Structures and Algorithms", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -149,7 +177,9 @@ defmodule Cadet.Courses.CourseTest do %{ source_chapter: 2, source_variant: "typed", - course_name: "Data Structures and Algorithms" + course_name: "Data Structures and Algorithms", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 }, :valid ) @@ -159,7 +189,31 @@ defmodule Cadet.Courses.CourseTest do source_chapter: 4, source_variant: "default", enable_achievements: true, - course_name: "Data Structures and Algorithms" + course_name: "Data Structures and Algorithms", + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10 + }, + :valid + ) + + assert_changeset( + %{ + course_name: "Data Structures and Algorithms", + source_chapter: 1, + source_variant: "default", + top_leaderboard_display: 200, + top_contest_leaderboard_display: 10 + }, + :valid + ) + + assert_changeset( + %{ + course_name: "Data Structures and Algorithms", + source_chapter: 1, + source_variant: "default", + top_leaderboard_display: 350, + top_contest_leaderboard_display: 10 }, :valid ) diff --git a/test/cadet/courses/courses_test.exs b/test/cadet/courses/courses_test.exs index fc8880f79..0a0e3a91c 100644 --- a/test/cadet/courses/courses_test.exs +++ b/test/cadet/courses/courses_test.exs @@ -19,6 +19,10 @@ defmodule Cadet.CoursesTest do viewable: true, enable_game: true, enable_achievements: true, + enable_overall_leaderboard: true, + enable_contest_leaderboard: true, + top_leaderboard_display: 100, + top_contest_leaderboard_display: 10, enable_sourcecast: true, enable_stories: false, source_chapter: 1, diff --git a/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs b/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs index cfd590925..0601f4eaf 100644 --- a/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs @@ -159,7 +159,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do end end - describe "GET /:assessment_id/popularVoteLeaderboard, unauthenticated" do + describe "GET /:assessment_id/:visibleentries/popularVoteLeaderboard, unauthenticated" do test "unauthorized", %{conn: conn, courses: %{course1: course1}} do config = insert(:assessment_config, %{course: course1}) assessment = insert(:assessment, %{course: course1, config: config}) @@ -170,7 +170,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do end end - describe "GET /:assessment_id/popularVoteLeaderboard, student only" do + describe "GET /:assessment_id/:visibleentries/popularVoteLeaderboard, student only" do @tag authenticate: :student test "Forbidden", %{conn: conn} do test_cr = conn.assigns.test_cr @@ -184,7 +184,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do end end - describe "GET /:assessment_id/popularVoteLeaderboard" do + describe "GET /:assessment_id/:visibleentries/popularVoteLeaderboard" do @tag authenticate: :staff test "successful", %{conn: conn} do test_cr = conn.assigns.test_cr @@ -239,7 +239,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do end end - describe "GET /:assessment_id/scoreLeaderboard, unauthenticated" do + describe "GET /:assessment_id/:visibleentries/scoreLeaderboard, unauthenticated" do test "unauthorized", %{conn: conn, courses: %{course1: course1}} do config = insert(:assessment_config, %{course: course1}) assessment = insert(:assessment, %{course: course1, config: config}) @@ -250,7 +250,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do end end - describe "GET /:assessment_id/scoreLeaderboard, student only" do + describe "GET /:assessment_id/:visibleentries/scoreLeaderboard, student only" do @tag authenticate: :student test "Forbidden", %{conn: conn} do test_cr = conn.assigns.test_cr @@ -264,7 +264,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do end end - describe "GET /:assessment_id/scoreLeaderboard" do + describe "GET /:assessment_id/:visibleentries/scoreLeaderboard" do @tag authenticate: :staff test "successful", %{conn: conn} do test_cr = conn.assigns.test_cr @@ -985,10 +985,10 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do do: "/v2/courses/#{course_id}/admin/users/#{course_reg_id}/assessments" defp build_popular_leaderboard_url(course_id, assessment_id), - do: "#{build_url(course_id, assessment_id)}/popularVoteLeaderboard" + do: "#{build_url(course_id, assessment_id)}/10/popularVoteLeaderboard" defp build_score_leaderboard_url(course_id, assessment_id), - do: "#{build_url(course_id, assessment_id)}/scoreLeaderboard" + do: "#{build_url(course_id, assessment_id)}/10/scoreLeaderboard" defp open_at_asc_comparator(x, y), do: Timex.before?(x.open_at, y.open_at) diff --git a/test/cadet_web/controllers/courses_controller_test.exs b/test/cadet_web/controllers/courses_controller_test.exs index 1a24caebf..876c0166e 100644 --- a/test/cadet_web/controllers/courses_controller_test.exs +++ b/test/cadet_web/controllers/courses_controller_test.exs @@ -26,6 +26,10 @@ defmodule CadetWeb.CoursesControllerTest do "viewable" => "true", "enable_game" => "true", "enable_achievements" => "true", + "enable_overall_leaderboard" => "true", + "enable_contest_leaderboard" => "true", + "top_leaderboard_display" => "100", + "top_contest_leaderboard_display" => "10", "enable_sourcecast" => "true", "enable_stories" => "true", "source_chapter" => "1", @@ -118,6 +122,10 @@ defmodule CadetWeb.CoursesControllerTest do "viewable" => "true", "enable_game" => "true", "enable_achievements" => "true", + "enable_overall_leaderboard" => "true", + "enable_contest_leaderboard" => "true", + "top_leaderboard_display" => "100", + "top_contest_leaderboard_display" => "10", "enable_sourcecast" => "true", "enable_stories" => "true", "source_chapter" => "1", diff --git a/test/cadet_web/controllers/user_controller_test.exs b/test/cadet_web/controllers/user_controller_test.exs index ceb1cc300..4cf471297 100644 --- a/test/cadet_web/controllers/user_controller_test.exs +++ b/test/cadet_web/controllers/user_controller_test.exs @@ -114,6 +114,10 @@ defmodule CadetWeb.UserControllerTest do "sourceChapter" => 1, "sourceVariant" => "default", "viewable" => true, + "enableContestLeaderboard" => true, + "enableOverallLeaderboard" => true, + "topLeaderboardDisplay" => 100, + "topContestLeaderboardDisplay" => 10, "assetsPrefix" => Courses.assets_prefix(course) }, "assessmentConfigurations" => [ @@ -328,6 +332,10 @@ defmodule CadetWeb.UserControllerTest do "sourceChapter" => 1, "sourceVariant" => "default", "viewable" => true, + "enableContestLeaderboard" => true, + "enableOverallLeaderboard" => true, + "topLeaderboardDisplay" => 100, + "topContestLeaderboardDisplay" => 10, "assetsPrefix" => Courses.assets_prefix(course) }, "assessmentConfigurations" => [] diff --git a/test/factories/assessments/question_factory.ex b/test/factories/assessments/question_factory.ex index cfd088dd1..783db1180 100644 --- a/test/factories/assessments/question_factory.ex +++ b/test/factories/assessments/question_factory.ex @@ -94,7 +94,8 @@ defmodule Cadet.Assessments.QuestionFactory do template: Faker.Lorem.Shakespeare.as_you_like_it(), contest_number: contest_assessment.number, reveal_hours: 48, - token_divider: 50 + token_divider: 50, + xp_values: [500, 400, 300] } } end @@ -108,7 +109,8 @@ defmodule Cadet.Assessments.QuestionFactory do template: Faker.Lorem.Shakespeare.as_you_like_it(), contest_number: contest_assessment.number, reveal_hours: 48, - token_divider: 50 + token_divider: 50, + xp_values: [500, 400, 300] } end end