Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix issue with contest voting pooling #1000

Merged
merged 20 commits into from
Sep 17, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ config :cadet, Cadet.Jobs.Scheduler,
# Compute contest leaderboard that close in the previous day at 00:01
{"1 0 * * *", {Cadet.Assessments, :update_final_contest_leaderboards, []}},
# Compute rolling leaderboard every 2 hours
{"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}}
{"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}},
# Collate contest entries that close in the previous day at 00:01
{"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}}
]

# Configures the endpoint
Expand Down
223 changes: 135 additions & 88 deletions lib/cadet/assessments/assessments.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ defmodule Cadet.Assessments do
alias Cadet.ProgramAnalysis.Lexer
alias Ecto.Multi
alias Cadet.Incentives.Achievements
alias Timex.Duration

require Decimal

Expand Down Expand Up @@ -500,6 +501,35 @@ defmodule Cadet.Assessments do
Question.changeset(%Question{}, params_with_assessment_id)
end

def update_final_contest_entries do
# 1435 = 1 day - 5 minutes
if Log.log_execution("update_final_contest_entries", Duration.from_minutes(1435)) do
Logger.info("Started update of contest entry pools")
questions = fetch_unassigned_voting_questions()

for q <- questions do
insert_voting(q.course_id, q.question.contest_number, q.question_id)
end

Logger.info("Successfully update contest entry pools")
end
end

# fetch voting questions where entries have not been assigned
def fetch_unassigned_voting_questions do
voting_assigned_question_ids =
SubmissionVotes
|> select([v], v.question_id)
|> Repo.all()

Question
|> where(type: :voting)
|> where([q], q.id not in ^voting_assigned_question_ids)
|> join(:inner, [q], asst in assoc(q, :assessment))
|> select([q, asst], %{course_id: asst.course_id, question: q.question, question_id: q.id})
|> Repo.all()
end

@doc """
Generates and assigns contest entries for users with given usernames.
"""
Expand All @@ -522,102 +552,119 @@ defmodule Cadet.Assessments do

{:error, error_changeset}
else
# Returns contest submission ids with answers that contain "return"
contest_submission_ids =
Submission
|> join(:inner, [s], ans in assoc(s, :answers))
|> join(:inner, [s, ans], cr in assoc(s, :student))
|> where([s, ans, cr], cr.role == "student")
|> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted")
|> where(
[_, ans, cr],
fragment(
"?->>'code' like ?",
ans.answer,
"%return%"
)
if Timex.compare(contest_assessment.close_at, Timex.now()) < 0 do
compile_entries(course_id, contest_assessment, question_id)
else
# contest has not closed, do nothing
{:ok, nil}
end
end
end

def compile_entries(
course_id,
contest_assessment,
question_id
) do
# Returns contest submission ids with answers that contain "return"
contest_submission_ids =
Submission
|> join(:inner, [s], ans in assoc(s, :answers))
|> join(:inner, [s, ans], cr in assoc(s, :student))
|> where([s, ans, cr], cr.role == "student")
|> where([s, _], s.assessment_id == ^contest_assessment.id and s.status == "submitted")
|> where(
[_, ans, cr],
fragment(
"?->>'code' like ?",
ans.answer,
"%return%"
)
|> select([s, _ans], {s.student_id, s.id})
|> Repo.all()
|> Enum.into(%{})
)
|> select([s, _ans], {s.student_id, s.id})
|> Repo.all()
|> Enum.into(%{})

contest_submission_ids_length = Enum.count(contest_submission_ids)
contest_submission_ids_length = Enum.count(contest_submission_ids)

voter_ids =
CourseRegistration
|> where(role: "student", course_id: ^course_id)
|> select([cr], cr.id)
|> Repo.all()
voter_ids =
CourseRegistration
|> where(role: "student", course_id: ^course_id)
|> select([cr], cr.id)
|> Repo.all()

votes_per_user = min(contest_submission_ids_length, 10)
votes_per_user = min(contest_submission_ids_length, 10)

votes_per_submission =
if Enum.empty?(contest_submission_ids) do
0
else
trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length))
end
votes_per_submission =
if Enum.empty?(contest_submission_ids) do
0
else
trunc(Float.ceil(votes_per_user * length(voter_ids) / contest_submission_ids_length))
end

submission_id_list =
contest_submission_ids
|> Enum.map(fn {_, s_id} -> s_id end)
|> Enum.shuffle()
|> List.duplicate(votes_per_submission)
|> List.flatten()

{_submission_map, submission_votes_changesets} =
voter_ids
|> Enum.reduce({submission_id_list, []}, fn voter_id, acc ->
{submission_list, submission_votes} = acc

user_contest_submission_id = Map.get(contest_submission_ids, voter_id)

{votes, rest} =
submission_list
|> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc ->
{user_votes, submissions} = acc

max_votes =
if votes_per_user == contest_submission_ids_length and
not is_nil(user_contest_submission_id) do
# no. of submssions is less than 10. Unable to find
votes_per_user - 1
else
votes_per_user
end

if MapSet.size(user_votes) < max_votes do
if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do
new_user_votes = MapSet.put(user_votes, s_id)
new_submissions = List.delete(submissions, s_id)
{:cont, {new_user_votes, new_submissions}}
else
{:cont, {user_votes, submissions}}
end
submission_id_list =
contest_submission_ids
|> Enum.map(fn {_, s_id} -> s_id end)
|> Enum.shuffle()
|> List.duplicate(votes_per_submission)
|> List.flatten()

{_submission_map, submission_votes_changesets} =
voter_ids
|> Enum.reduce({submission_id_list, []}, fn voter_id, acc ->
{submission_list, submission_votes} = acc

user_contest_submission_id = Map.get(contest_submission_ids, voter_id)

{votes, rest} =
submission_list
|> Enum.reduce_while({MapSet.new(), submission_list}, fn s_id, acc ->
{user_votes, submissions} = acc

max_votes =
if votes_per_user == contest_submission_ids_length and
not is_nil(user_contest_submission_id) do
# no. of submssions is less than 10. Unable to find
votes_per_user - 1
else
{:halt, acc}
votes_per_user
end
end)

votes = MapSet.to_list(votes)

new_submission_votes =
votes
|> Enum.map(fn s_id ->
%SubmissionVotes{voter_id: voter_id, submission_id: s_id, question_id: question_id}
end)
|> Enum.concat(submission_votes)

{rest, new_submission_votes}
end)

submission_votes_changesets
|> Enum.with_index()
|> Enum.reduce(Multi.new(), fn {changeset, index}, multi ->
Multi.insert(multi, Integer.to_string(index), changeset)
if MapSet.size(user_votes) < max_votes do
if s_id != user_contest_submission_id and not MapSet.member?(user_votes, s_id) do
new_user_votes = MapSet.put(user_votes, s_id)
new_submissions = List.delete(submissions, s_id)
{:cont, {new_user_votes, new_submissions}}
else
{:cont, {user_votes, submissions}}
end
else
{:halt, acc}
end
end)

votes = MapSet.to_list(votes)

new_submission_votes =
votes
|> Enum.map(fn s_id ->
%SubmissionVotes{
voter_id: voter_id,
submission_id: s_id,
question_id: question_id
}
end)
|> Enum.concat(submission_votes)

{rest, new_submission_votes}
end)
|> Repo.transaction()
end

submission_votes_changesets
|> Enum.with_index()
|> Enum.reduce(Multi.new(), fn {changeset, index}, multi ->
Multi.insert(multi, Integer.to_string(index), changeset)
end)
|> Repo.transaction()
end

def update_assessment(id, params) when is_ecto_id(id) do
Expand Down Expand Up @@ -1026,7 +1073,7 @@ defmodule Cadet.Assessments do
"""
def update_rolling_contest_leaderboards do
# 115 = 2 hours - 5 minutes is default.
if Log.log_execution("update_rolling_contest_leaderboards", Timex.Duration.from_minutes(115)) do
if Log.log_execution("update_rolling_contest_leaderboards", Duration.from_minutes(115)) do
Logger.info("Started update_rolling_contest_leaderboards")

voting_questions_to_update = fetch_active_voting_questions()
Expand All @@ -1053,7 +1100,7 @@ defmodule Cadet.Assessments do
"""
def update_final_contest_leaderboards do
# 1435 = 24 hours - 5 minutes
if Log.log_execution("update_final_contest_leaderboards", Timex.Duration.from_minutes(1435)) do
if Log.log_execution("update_final_contest_leaderboards", Duration.from_minutes(1435)) do
Logger.info("Started update_final_contest_leaderboards")

voting_questions_to_update = fetch_voting_questions_due_yesterday()
Expand Down
32 changes: 26 additions & 6 deletions test/cadet/assessments/assessments_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,19 @@ defmodule Cadet.AssessmentsTest do

describe "contest voting" do
test "inserts votes into submission_votes table" do
contest_question = insert(:programming_question)
contest_assessment = contest_question.assessment
course = contest_question.assessment.course
course = insert(:course)
config = insert(:assessment_config)
# contest assessment that has closed
contest_assessment =
insert(:assessment,
is_published: true,
open_at: Timex.shift(Timex.now(), days: -5),
close_at: Timex.shift(Timex.now(), hours: -1),
course: course,
config: config
)

contest_question = insert(:programming_question, assessment: contest_assessment)
voting_assessment = insert(:assessment, %{course: course})

question =
Expand Down Expand Up @@ -225,9 +235,19 @@ defmodule Cadet.AssessmentsTest do
end

test "deletes submission_votes when assessment is deleted" do
contest_question = insert(:programming_question)
course = contest_question.assessment.course
config = contest_question.assessment.config
course = insert(:course)
config = insert(:assessment_config)
# contest assessment that has closed
contest_assessment =
insert(:assessment,
is_published: true,
open_at: Timex.shift(Timex.now(), days: -5),
close_at: Timex.shift(Timex.now(), hours: -1),
course: course,
config: config
)

contest_question = insert(:programming_question, assessment: contest_assessment)
voting_assessment = insert(:assessment, %{course: course, config: config})
question = insert(:voting_question, assessment: voting_assessment)
students = insert_list(5, :course_registration, %{role: :student, course: course})
Expand Down
Loading