diff --git a/lib/cadet/assessments/assessments.ex b/lib/cadet/assessments/assessments.ex index 244ebc742..19b260d36 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, Achievements, GoalProgress} import Ecto.Query require Logger @@ -24,8 +25,7 @@ defmodule Cadet.Assessments do alias Cadet.Courses.{Group, AssessmentConfig} alias Cadet.Jobs.Log alias Cadet.ProgramAnalysis.Lexer - alias Ecto.Multi - alias Cadet.Incentives.Achievements + alias Ecto.{Multi, Changeset} alias Timex.Duration require Decimal @@ -146,6 +146,126 @@ 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, + 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, + 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 and p.completed and + p.count == g.target_count, + group_by: [u.id, u.name, u.username, cr.id], + select_merge: %{ + user_id: u.id, + achievements_xp: + fragment( + "CASE WHEN bool_and(?) THEN ? ELSE ? END", + a.is_variable_xp, + sum(p.count), + max(a.xp) + ) + } + ) + + submissions_xp_query = + from( + sub_xp in subquery( + from(cr in CourseRegistration, + join: u in User, + on: cr.user_id == u.id, + full_join: tm in TeamMember, + on: cr.id == tm.student_id, + join: s in Submission, + on: tm.team_id == s.team_id or s.student_id == cr.id, + 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.id, u.name, u.username, s.id, a.xp, a.xp_adjustment], + select: %{ + user_id: u.id, + submission_xp: a.xp + a.xp_adjustment + max(s.xp_bonus) + } + ) + ), + group_by: sub_xp.user_id, + select: %{ + user_id: sub_xp.user_id, + submission_xp: sum(sub_xp.submission_xp) + } + ) + + total_xp_query = + from(bu in subquery(base_user_query), + left_join: ax in subquery(achievements_xp_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 + ) + + count_query = + from(t in subquery(total_xp_query), + select: count(t.user_id) + ) + + {status, {rows, total_count}} = + Repo.transaction(fn -> + users = Repo.all(ranked_xp_query) + count = Repo.one(count_query) + {users, count} + end) + + %{ + users: rows, + total_count: total_count + } + end + defp decimal_to_integer(decimal) do if Decimal.is_decimal(decimal) do Decimal.to_integer(decimal) @@ -287,6 +407,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 +428,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 +1718,8 @@ defmodule Cadet.Assessments do defp load_contest_voting_entries( questions, %CourseRegistration{role: role, course_id: course_id, id: voter_id}, - assessment + assessment, + visible_entries ) do Enum.map( questions, @@ -1607,7 +1735,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, visible_entries) else [] end @@ -1618,7 +1746,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, visible_entries) else [] end @@ -1677,35 +1805,86 @@ 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 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 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, + student_username: student_user.username, + rank: fragment("RANK() OVER (ORDER BY ? DESC)", a.relative_score) + }) + + final_query = + from(r in subquery(subquery), + where: r.rank <= ^number_of_answers + ) + + Repo.all(final_query) end @doc """ @@ -1714,29 +1893,37 @@ 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, + student_username: student_user.username, + rank: fragment("RANK() OVER (ORDER BY ? DESC)", a.popular_score) + }) + + final_query = + from(r in subquery(subquery), + where: r.rank <= ^number_of_answers + ) + + Repo.all(final_query) end @doc """ @@ -1774,13 +1961,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 +1999,123 @@ 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 -> + submission_ids = + Answer + |> where(question_id: ^contest_question_id) + |> select([a], a.submission_id) + |> Repo.all() + + Submission + |> where([s], s.id in ^submission_ids) + |> Repo.update_all(set: [is_grading_published: true]) + + 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_adjustment: 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_adjustment + increment + Repo.update!(Changeset.change(answer, %{xp_adjustment: 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) + + if !is_nil(contest_question_id) do + # reset all scores to 0 first + 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 + 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..4d951f08b 100644 --- a/lib/cadet_web/admin_controllers/admin_assessments_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_assessments_controller.ex @@ -135,42 +135,35 @@ defmodule CadetWeb.AdminAssessmentsController do end end - def get_score_leaderboard(conn, %{"assessmentid" => assessment_id, "course_id" => course_id}) do + 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() - contest_id = Assessments.fetch_associated_contest_question_id(course_id, voting_questions) - - result = - contest_id - |> Assessments.fetch_top_relative_score_answers(10) - |> Enum.map(fn entry -> - AssessmentsHelpers.build_contest_leaderboard_entry(entry) - end) - - render(conn, "leaderboard.json", leaderboard: result) + 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 get_popular_leaderboard(conn, %{"assessmentid" => assessment_id, "course_id" => course_id}) do + 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() - contest_id = Assessments.fetch_associated_contest_question_id(course_id, voting_questions) + if voting_questions do + Assessments.assign_winning_contest_entries_xp(voting_questions.id) - result = - contest_id - |> Assessments.fetch_top_popular_score_answers(10) - |> Enum.map(fn entry -> - AssessmentsHelpers.build_popular_leaderboard_entry(entry) - end) - - render(conn, "leaderboard.json", leaderboard: result) + 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 @@ -288,7 +281,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..725a2e404 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 - alias Cadet.Assessments + import Ecto.Query, only: [where: 2] + + alias Cadet.{Assessments, Repo} + alias Cadet.Assessments.Question + alias CadetWeb.AssessmentsHelpers # These roles can save and finalise answers for closed assessments and # submitted answers @@ -67,6 +71,103 @@ 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.users}) + end + + def paginated_total_xp_for_leaderboard_display(conn, %{"course_id" => course_id}) do + offset = String.to_integer(conn.params["offset"] || "0") + page_size = String.to_integer(conn.params["page_size"] || "25") + paginated_display = Assessments.all_user_total_xp(course_id, offset, page_size) + json(conn, paginated_display) + end + + def get_score_leaderboard(conn, %{ + "assessmentid" => assessment_id, + "course_id" => course_id + }) do + visible_entries = String.to_integer(conn.params["visible_entries"] || "10") + voting_id = Assessments.fetch_contest_voting_assesment_id(assessment_id) + + voting_questions = + Question + |> where(type: :voting) + |> where(assessment_id: ^assessment_id) + |> Repo.one() + |> case do + nil -> + Question + |> where(type: :voting) + |> where(assessment_id: ^voting_id) + |> Repo.one() + + question -> + question + end + + contest_id = Assessments.fetch_associated_contest_question_id(course_id, voting_questions) + + result = + contest_id + |> Assessments.fetch_top_relative_score_answers(visible_entries) + |> Enum.map(fn entry -> + updated_entry = %{ + entry + | answer: entry.answer["code"] + } + + AssessmentsHelpers.build_contest_leaderboard_entry(updated_entry) + end) + + json(conn, %{leaderboard: result, voting_id: voting_id}) + end + + def get_popular_leaderboard(conn, %{ + "assessmentid" => assessment_id, + "course_id" => course_id + }) do + visible_entries = String.to_integer(conn.params["visible_entries"] || "10") + voting_id = Assessments.fetch_contest_voting_assesment_id(assessment_id) + + voting_questions = + Question + |> where(type: :voting) + |> where(assessment_id: ^assessment_id) + |> Repo.one() + |> case do + nil -> + Question + |> where(type: :voting) + |> where(assessment_id: ^voting_id) + |> Repo.one() + + question -> + question + end + + contest_id = Assessments.fetch_associated_contest_question_id(course_id, voting_questions) + + result = + contest_id + |> Assessments.fetch_top_popular_score_answers(visible_entries) + |> Enum.map(fn entry -> + updated_entry = %{ + entry + | answer: entry.answer["code"] + } + + AssessmentsHelpers.build_popular_leaderboard_entry(updated_entry) + end) + + json(conn, %{leaderboard: result, 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..8f2dec32f 100644 --- a/lib/cadet_web/helpers/assessments_helpers.ex +++ b/lib/cadet_web/helpers/assessments_helpers.ex @@ -107,7 +107,9 @@ defmodule CadetWeb.AssessmentsHelpers do transform_map_for_view(leaderboard_ans, %{ submission_id: :submission_id, answer: :answer, - student_name: :student_name + student_name: :student_name, + student_username: :student_username, + rank: :rank }), "final_score", Float.round(leaderboard_ans.relative_score, 2) @@ -119,7 +121,9 @@ defmodule CadetWeb.AssessmentsHelpers do transform_map_for_view(leaderboard_ans, %{ submission_id: :submission_id, answer: :answer, - student_name: :student_name + student_name: :student_name, + student_username: :student_username, + 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..f26ed7691 100644 --- a/lib/cadet_web/router.ex +++ b/lib/cadet_web/router.ex @@ -118,6 +118,28 @@ 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", + AssessmentsController, + :paginated_total_xp_for_leaderboard_display + ) + + get( + "/assessments/:assessmentid/popularVoteLeaderboard", + AssessmentsController, + :get_popular_leaderboard + ) + + get( + "/assessments/:assessmentid/scoreLeaderboard", + AssessmentsController, + :get_score_leaderboard + ) + + get("/all_contests", AssessmentsController, :get_all_contests) + get("/config", CoursesController, :index) get("/team/:assessmentid", TeamController, :index) @@ -179,16 +201,16 @@ defmodule CadetWeb.Router do resources("/sourcecast", AdminSourcecastController, only: [:create, :delete]) - get( - "/assessments/:assessmentid/popularVoteLeaderboard", + post( + "/assessments/:assessmentid/calculateContestScore", AdminAssessmentsController, - :get_popular_leaderboard + :calculate_contest_score ) - get( - "/assessments/:assessmentid/scoreLeaderboard", + post( + "/assessments/:assessmentid/dispatchContestXp", AdminAssessmentsController, - :get_score_leaderboard + :dispatch_contest_xp ) get("/grading", AdminGradingController, :index) 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..be0713cf7 --- /dev/null +++ b/priv/repo/migrations/20250429081534_add_leaderboard_display_columns.exs @@ -0,0 +1,12 @@ +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 + end +end diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 6953144c3..59f866300 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -2,9 +2,8 @@ defmodule Cadet.AssessmentsTest do use Cadet.DataCase import Cadet.{Factory, TestEntityHelper} - alias Cadet.Assessments - alias Cadet.Assessments.{Assessment, Question, SubmissionVotes, Submission} + alias Cadet.Assessments.{Assessment, Question, SubmissionVotes, Submission, Answer} test "create assessments of all types" do course = insert(:course) @@ -1240,11 +1239,11 @@ defmodule Cadet.AssessmentsTest do # does not update scores for voting assessments closed >1 days and those ongoing ago assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(past_question.id, 1) - ) == [0] + ) == [0.0, 0.0, 0.0, 0.0, 0.0] assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(current_question.id, 1) - ) == [0] + ) == [0.0, 0.0, 0.0, 0.0, 0.0] assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(yesterday_question.id, 5) @@ -1262,11 +1261,11 @@ defmodule Cadet.AssessmentsTest do # does not update scores for voting assessments closed >1 days ago assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(past_question.id, 1) - ) == [0] + ) == [0.0, 0.0, 0.0, 0.0, 0.0] assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(yesterday_question.id, 1) - ) == [0] + ) == [0.0, 0.0, 0.0, 0.0, 0.0] assert get_answer_relative_scores( Assessments.fetch_top_relative_score_answers(current_question.id, 5) @@ -3132,6 +3131,241 @@ defmodule Cadet.AssessmentsTest do end end + describe "all_user_total_xp pagination with offset and limit" do + setup do + course = insert(:course) + config = insert(:assessment_config) + + # generate question to award xp + assessment = insert(:assessment, %{course: course, config: config}) + question = insert(:programming_question, assessment: assessment) + + # generate 50 students + student_list = insert_list(50, :course_registration, %{course: course, role: :student}) + + # generate submission for each student + submission_list = + Enum.map( + student_list, + fn student -> + insert( + :submission, + student: student, + assessment: assessment, + status: "submitted", + is_grading_published: true + ) + end + ) + + # generate answer for each student with xp + random_perm = Enum.shuffle(1..50) + + _ans_list = + Enum.map( + Enum.with_index(submission_list), + fn {submission, index} -> + insert( + :answer, + answer: build(:programming_answer), + submission: submission, + question: question, + xp: Enum.at(random_perm, index) + ) + end + ) + + %{course: course} + end + + test "correctly fetches all students with their xp in descending order", %{course: course} do + all_user_xp = Assessments.all_user_total_xp(course.id) + assert get_all_student_xp(all_user_xp) == 50..1 |> Enum.to_list() + end + + test "correctly fetches only relevant students for leaderboard display with potential overflow", + %{course: course} do + Enum.each(1..50, fn x -> + offset = Enum.random(0..49) + limit = Enum.random(1..50) + paginated_user_xp = Assessments.all_user_total_xp(course.id, offset, limit) + + expected_xp_list = + 50..1 + |> Enum.to_list() + |> Enum.slice(offset, limit) + + assert get_all_student_xp(paginated_user_xp) == expected_xp_list + end) + end + end + + describe "automatic xp assignment for contest winners function" do + setup do + course = insert(:course) + config = insert(:assessment_config) + + contest_assessment = insert(:assessment, %{course: course, config: config}) + contest_question = insert(:programming_question, assessment: contest_assessment) + voting_assessment = insert(:assessment, %{course: course, config: config}) + voting_question = insert(:voting_question, assessment: voting_assessment) + + # generate 5 students + student_list = insert_list(5, :course_registration, %{course: course, role: :student}) + + # generate contest submission for each student + submission_list = + Enum.map( + student_list, + fn student -> + insert( + :submission, + student: student, + assessment: contest_assessment, + status: "submitted" + ) + end + ) + + # generate answer for each student + ans_list = + Enum.map( + Enum.with_index(submission_list), + fn {submission, index} -> + insert( + :answer, + answer: build(:programming_answer), + submission: submission, + question: contest_question, + popular_score: index, + relative_score: 10 - index, + xp: 100 + ) + end + ) + + # generate submission votes for each student + _submission_votes = + Enum.map( + student_list, + fn student -> + Enum.map( + Enum.with_index(submission_list), + fn {submission, index} -> + insert( + :submission_vote, + voter: student, + submission: submission, + question: voting_question + ) + end + ) + end + ) + + %{ + course: course, + voting_question: voting_question, + ans_list: ans_list + } + end + + test "correctly assigns xp to winning contest entries with default xp values", %{ + course: course, + voting_question: voting_question, + ans_list: _ans_list + } do + # verify that xp is adjusted correctly + Assessments.assign_winning_contest_entries_xp(voting_question.id) + all_user_xp = Assessments.all_user_total_xp(course.id) + assert get_all_student_xp(all_user_xp) == [700, 600, 600, 500, 500] + end + + test "correctly reassigns xp to winning contest entries upon mutliple calls", %{ + course: course, + voting_question: voting_question, + ans_list: ans_list + } do + # reset scores to 0 + Answer + |> Repo.update_all(set: [popular_score: 0, relative_score: 0, xp: 0]) + + # assign rank 1 xp to all students + Assessments.assign_winning_contest_entries_xp(voting_question.id) + all_user_xp = Assessments.all_user_total_xp(course.id) + assert get_all_student_xp(all_user_xp) == [1000, 1000, 1000, 1000, 1000] + + # reassign scores and xp + ans_list + |> Enum.with_index() + |> Enum.each(fn {answer, index} -> + Answer + |> where([a], a.id == ^answer.id) + |> Repo.update_all( + set: [ + popular_score: index, + relative_score: 10 - index + ] + ) + end) + + # verify that xp is assigned correctly + Assessments.assign_winning_contest_entries_xp(voting_question.id) + all_user_xp = Assessments.all_user_total_xp(course.id) + assert get_all_student_xp(all_user_xp) == [600, 500, 500, 400, 400] + end + + test "correctly assigns xp to tied winning contest entries", %{ + course: course, + voting_question: voting_question, + ans_list: ans_list + } do + # assign tied scores + # score_rank = [3, 3, 3, 1, 1] + # popular_rank = [1, 1, 3, 3, 3] + ans_list + |> Enum.with_index() + |> Enum.each(fn {answer, index} -> + p_score = if index <= 1, do: 1, else: 0 + r_score = if index >= 3, do: 1, else: 0 + + Answer + |> where([a], a.id == ^answer.id) + |> Repo.update_all( + set: [ + popular_score: p_score, + relative_score: r_score + ] + ) + end) + + # verify that xp is assigned correctly to tied entries + Assessments.assign_winning_contest_entries_xp(voting_question.id) + all_user_xp = Assessments.all_user_total_xp(course.id) + assert get_all_student_xp(all_user_xp) == [900, 900, 900, 900, 700] + end + + test "correctly assigns xp to winning contest entries with defined xp values", %{ + course: course, + voting_question: voting_question, + ans_list: ans_list + } do + # update defined xp_values for voting question + Question + |> where([q], q.id == ^voting_question.id) + |> Repo.update_all( + set: [ + question: Map.merge(voting_question.question, %{xp_values: [50, 40, 30, 20, 10]}) + ] + ) + + # verify that xp is assigned correctly with predefined xp_values + Assessments.assign_winning_contest_entries_xp(voting_question.id) + all_user_xp = Assessments.all_user_total_xp(course.id) + assert get_all_student_xp(all_user_xp) == List.duplicate(160, 5) + end + end + defp get_answer_relative_scores(answers) do answers |> Enum.map(fn ans -> ans.relative_score end) end @@ -3147,4 +3381,9 @@ defmodule Cadet.AssessmentsTest do |> Enum.map(fn score -> 10 * score - :math.pow(2, 3 / token_divider) end) |> Enum.take(top_x) end + + defp get_all_student_xp(all_users) do + all_users.users + |> Enum.map(fn user -> user.total_xp 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..e49c5ed63 100644 --- a/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs @@ -159,166 +159,6 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do end end - describe "GET /:assessment_id/popularVoteLeaderboard, unauthenticated" do - test "unauthorized", %{conn: conn, courses: %{course1: course1}} do - config = insert(:assessment_config, %{course: course1}) - assessment = insert(:assessment, %{course: course1, config: config}) - - conn - |> get(build_popular_leaderboard_url(course1.id, assessment.id)) - |> response(401) - end - end - - describe "GET /:assessment_id/popularVoteLeaderboard, student only" do - @tag authenticate: :student - test "Forbidden", %{conn: conn} do - test_cr = conn.assigns.test_cr - course = test_cr.course - config = insert(:assessment_config, %{course: course}) - assessment = insert(:assessment, %{course: course, config: config}) - - conn - |> get(build_popular_leaderboard_url(course.id, assessment.id)) - |> response(403) - end - end - - describe "GET /:assessment_id/popularVoteLeaderboard" do - @tag authenticate: :staff - test "successful", %{conn: conn} do - test_cr = conn.assigns.test_cr - course = test_cr.course - - config = insert(:assessment_config, %{course: course}) - contest_assessment = insert(:assessment, %{course: course, config: config}) - contest_students = insert_list(5, :course_registration, %{course: course, role: :student}) - contest_question = insert(:programming_question, %{assessment: contest_assessment}) - - contest_submissions = - contest_students - |> Enum.map(&insert(:submission, %{assessment: contest_assessment, student: &1})) - - contest_answer = - contest_submissions - |> Enum.map( - &insert(:answer, %{ - question: contest_question, - submission: &1, - popular_score: 10.0, - answer: build(:programming_answer) - }) - ) - - voting_assessment = insert(:assessment, %{course: course, config: config}) - - insert( - :voting_question, - %{ - question: build(:voting_question_content, contest_number: contest_assessment.number), - assessment: voting_assessment - } - ) - - expected = - contest_answer - |> Enum.map( - &%{ - "answer" => &1.answer.code, - "student_name" => &1.submission.student.user.name, - "final_score" => &1.popular_score - } - ) - - resp = - conn - |> get(build_popular_leaderboard_url(course.id, voting_assessment.id)) - |> json_response(200) - - assert expected == resp - end - end - - describe "GET /:assessment_id/scoreLeaderboard, unauthenticated" do - test "unauthorized", %{conn: conn, courses: %{course1: course1}} do - config = insert(:assessment_config, %{course: course1}) - assessment = insert(:assessment, %{course: course1, config: config}) - - conn - |> get(build_popular_leaderboard_url(course1.id, assessment.id)) - |> response(401) - end - end - - describe "GET /:assessment_id/scoreLeaderboard, student only" do - @tag authenticate: :student - test "Forbidden", %{conn: conn} do - test_cr = conn.assigns.test_cr - course = test_cr.course - config = insert(:assessment_config, %{course: course}) - assessment = insert(:assessment, %{course: course, config: config}) - - conn - |> get(build_popular_leaderboard_url(course.id, assessment.id)) - |> response(403) - end - end - - describe "GET /:assessment_id/scoreLeaderboard" do - @tag authenticate: :staff - test "successful", %{conn: conn} do - test_cr = conn.assigns.test_cr - course = test_cr.course - - config = insert(:assessment_config, %{course: course}) - contest_assessment = insert(:assessment, %{course: course, config: config}) - contest_students = insert_list(5, :course_registration, %{course: course, role: :student}) - contest_question = insert(:programming_question, %{assessment: contest_assessment}) - - contest_submissions = - contest_students - |> Enum.map(&insert(:submission, %{assessment: contest_assessment, student: &1})) - - contest_answer = - contest_submissions - |> Enum.map( - &insert(:answer, %{ - question: contest_question, - submission: &1, - relative_score: 10.0, - answer: build(:programming_answer) - }) - ) - - voting_assessment = insert(:assessment, %{course: course, config: config}) - - insert( - :voting_question, - %{ - question: build(:voting_question_content, contest_number: contest_assessment.number), - assessment: voting_assessment - } - ) - - expected = - contest_answer - |> Enum.map( - &%{ - "answer" => &1.answer.code, - "student_name" => &1.submission.student.user.name, - "final_score" => &1.relative_score - } - ) - - resp = - conn - |> get(build_score_leaderboard_url(course.id, voting_assessment.id)) - |> json_response(200) - - assert expected == resp - end - end - describe "POST /, unauthenticated" do test "unauthorized", %{ conn: conn, @@ -984,12 +824,6 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do defp build_user_assessments_url(course_id, course_reg_id), 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" - - defp build_score_leaderboard_url(course_id, assessment_id), - do: "#{build_url(course_id, assessment_id)}/scoreLeaderboard" - defp open_at_asc_comparator(x, y), do: Timex.before?(x.open_at, y.open_at) defp get_assessment_status(course_reg = %CourseRegistration{}, assessment = %Assessment{}) do diff --git a/test/cadet_web/controllers/assessments_controller_test.exs b/test/cadet_web/controllers/assessments_controller_test.exs index 708b27eb8..ce0079781 100644 --- a/test/cadet_web/controllers/assessments_controller_test.exs +++ b/test/cadet_web/controllers/assessments_controller_test.exs @@ -551,10 +551,24 @@ defmodule CadetWeb.AssessmentsControllerTest do "answer" => %{"code" => answer.answer.code}, "final_score" => answer.relative_score, "student_name" => answer.submission.student.user.name, - "submission_id" => answer.submission.id + "submission_id" => answer.submission.id, + "student_username" => answer.submission.student.user.username } end |> Enum.sort_by(& &1["final_score"], &>=/2) + |> Enum.reduce({[], nil, 0, 0}, fn entry, {acc, prev_score, current_rank, index} -> + new_rank = + if entry["final_score"] == prev_score do + current_rank + else + index + 1 + end + + updated_entry = Map.put(entry, "rank", new_rank) + {[updated_entry | acc], entry["final_score"], new_rank, index + 1} + end) + |> elem(0) + |> Enum.reverse() for role <- Role.__enum_map__() do course_reg = Map.get(role_crs, role) @@ -623,10 +637,24 @@ defmodule CadetWeb.AssessmentsControllerTest do "answer" => %{"code" => answer.answer.code}, "final_score" => answer.relative_score, "student_name" => answer.submission.student.user.name, - "submission_id" => answer.submission.id + "submission_id" => answer.submission.id, + "student_username" => answer.submission.student.user.username } end |> Enum.sort_by(& &1["final_score"], &>=/2) + |> Enum.reduce({[], nil, 0, 0}, fn entry, {acc, prev_score, current_rank, index} -> + new_rank = + if entry["final_score"] == prev_score do + current_rank + else + index + 1 + end + + updated_entry = Map.put(entry, "rank", new_rank) + {[updated_entry | acc], entry["final_score"], new_rank, index + 1} + end) + |> elem(0) + |> Enum.reverse() for role <- [:admin, :staff] do course_reg = Map.get(role_crs, role) @@ -1772,6 +1800,164 @@ defmodule CadetWeb.AssessmentsControllerTest do end end + describe "GET /:assessment_id/popularVoteLeaderboard, unauthenticated" do + test "unauthorized", %{conn: conn, courses: %{course1: course1}} do + config = insert(:assessment_config, %{course: course1}) + assessment = insert(:assessment, %{course: course1, config: config}) + + params = %{ + "visible_entries" => 9 + } + + conn + |> get(build_popular_leaderboard_url(course1.id, assessment.id, params)) + |> response(401) + end + end + + describe "GET /:assessment_id/scoreLeaderboard, unauthenticated" do + test "unauthorized", %{conn: conn, courses: %{course1: course1}} do + config = insert(:assessment_config, %{course: course1}) + assessment = insert(:assessment, %{course: course1, config: config}) + + params = %{ + "visible_entries" => 9 + } + + conn + |> get(build_score_leaderboard_url(course1.id, assessment.id, params)) + |> response(401) + end + end + + describe "GET /:assessment_id/popularVoteLeaderboard" do + @tag authenticate: :student + test "successful", %{conn: conn, courses: %{course1: course1}} do + user = conn.assigns[:current_user] + test_cr = insert(:course_registration, %{course: course1, role: :student, user: user}) + conn = assign(conn, :test_cr, test_cr) + course = test_cr.course + + config = insert(:assessment_config, %{course: course}) + contest_assessment = insert(:assessment, %{course: course, config: config}) + contest_students = insert_list(5, :course_registration, %{course: course, role: :student}) + contest_question = insert(:programming_question, %{assessment: contest_assessment}) + + contest_submissions = + contest_students + |> Enum.map(&insert(:submission, %{assessment: contest_assessment, student: &1})) + + contest_answer = + contest_submissions + |> Enum.map( + &insert(:answer, %{ + question: contest_question, + submission: &1, + popular_score: 10.0, + answer: build(:programming_answer) + }) + ) + + voting_assessment = insert(:assessment, %{course: course, config: config}) + + insert( + :voting_question, + %{ + question: build(:voting_question_content, contest_number: contest_assessment.number), + assessment: voting_assessment + } + ) + + expected = + contest_answer + |> Enum.map( + &%{ + "answer" => &1.answer.code, + "student_name" => &1.submission.student.user.name, + "final_score" => &1.popular_score, + "rank" => 1, + "student_username" => &1.submission.student.user.username, + "submission_id" => &1.submission.id + } + ) + + params = %{ + "visible_entries" => 1 + } + + resp = + conn + |> get(build_popular_leaderboard_url(course.id, voting_assessment.id, params)) + |> json_response(200) + + assert expected == resp["leaderboard"] + end + end + + describe "GET /:assessment_id/scoreLeaderboard" do + @tag authenticate: :student + test "successful", %{conn: conn, courses: %{course1: course1}} do + user = conn.assigns[:current_user] + test_cr = insert(:course_registration, %{course: course1, role: :student, user: user}) + conn = assign(conn, :test_cr, test_cr) + course = test_cr.course + + config = insert(:assessment_config, %{course: course}) + contest_assessment = insert(:assessment, %{course: course, config: config}) + contest_students = insert_list(5, :course_registration, %{course: course, role: :student}) + contest_question = insert(:programming_question, %{assessment: contest_assessment}) + + contest_submissions = + contest_students + |> Enum.map(&insert(:submission, %{assessment: contest_assessment, student: &1})) + + contest_answer = + contest_submissions + |> Enum.map( + &insert(:answer, %{ + question: contest_question, + submission: &1, + relative_score: 10.0, + answer: build(:programming_answer) + }) + ) + + voting_assessment = insert(:assessment, %{course: course, config: config}) + + insert( + :voting_question, + %{ + question: build(:voting_question_content, contest_number: contest_assessment.number), + assessment: voting_assessment + } + ) + + expected = + contest_answer + |> Enum.map( + &%{ + "answer" => &1.answer.code, + "student_name" => &1.submission.student.user.name, + "final_score" => &1.relative_score, + "rank" => 1, + "student_username" => &1.submission.student.user.username, + "submission_id" => &1.submission.id + } + ) + + params = %{ + "visible_entries" => 1 + } + + resp = + conn + |> get(build_score_leaderboard_url(course.id, voting_assessment.id, params)) + |> json_response(200) + + assert expected == resp["leaderboard"] + end + end + defp build_url(course_id), do: "/v2/courses/#{course_id}/assessments/" defp build_url(course_id, assessment_id), @@ -1783,6 +1969,28 @@ defmodule CadetWeb.AssessmentsControllerTest do defp build_url_unlock(course_id, assessment_id), do: "/v2/courses/#{course_id}/assessments/#{assessment_id}/unlock" + defp build_popular_leaderboard_url(course_id, assessment_id, params \\ %{}) do + base_url = "#{build_url(course_id, assessment_id)}/popularVoteLeaderboard" + + if params != %{} do + query_string = URI.encode_query(params) + "#{base_url}?#{query_string}" + else + base_url + end + end + + defp build_score_leaderboard_url(course_id, assessment_id, params \\ %{}) do + base_url = "#{build_url(course_id, assessment_id)}/scoreLeaderboard" + + if params != %{} do + query_string = URI.encode_query(params) + "#{base_url}?#{query_string}" + else + base_url + end + end + defp open_at_asc_comparator(x, y), do: Timex.before?(x.open_at, y.open_at) defp get_assessment_status(course_reg = %CourseRegistration{}, assessment = %Assessment{}) do 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