187 lines
6.7 KiB
Crystal
187 lines
6.7 KiB
Crystal
# Mentorenwahl: A fullstack application for assigning mentors to students based on their whishes.
|
|
# Copyright (C) 2022 Dominic Grimm
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
module Backend
|
|
module Worker
|
|
module Jobs
|
|
# Assigns students to teachers when all students voted
|
|
class AssignmentJob < Mosquito::QueuedJob
|
|
alias TeacherVote = {student: Int32, priority: Int32}
|
|
alias Assignment = {teacher: Int32, priority: Int32}
|
|
|
|
def rescheduleable?
|
|
false
|
|
end
|
|
|
|
# :ditto:
|
|
def perform : Nil
|
|
if Db::Config.query.where { active }.first!.can_vote
|
|
log "Voting still allowed, skipping assignment"
|
|
fail
|
|
end
|
|
|
|
teacher_count = Db::Teacher.query.count
|
|
student_count = Db::Student.query.count
|
|
vote_count = Db::Vote.query.count
|
|
if teacher_count == 0
|
|
log "No teachers found, skipping assignment"
|
|
fail
|
|
elsif student_count == 0
|
|
log "No students found, skipping assignment"
|
|
fail
|
|
elsif student_count != Db::User.query.where(role: Db::UserRole::Student).count
|
|
log "Not all students registered, skipping assignment"
|
|
fail
|
|
elsif vote_count < student_count
|
|
log "Not all students voted, skipping assignment"
|
|
fail
|
|
end
|
|
|
|
if Db::Teacher.query.sum(:max_students, Int64) < student_count
|
|
log "Capacity too low, teachers need to adopt more students, skipping assignment"
|
|
fail
|
|
end
|
|
|
|
teachers = Db::Teacher.query
|
|
.where do
|
|
raw("EXISTS (SELECT 1 FROM teacher_votes WHERE teacher_id = teachers.id)") &
|
|
(max_students > 0)
|
|
end
|
|
.with_teacher_votes
|
|
.to_a
|
|
teacher_ids = teachers.map(&.id)
|
|
teacher_max_students = Hash.zip(teacher_ids, teachers.map(&.max_students))
|
|
teacher_votes : Hash(Int32, Array(TeacherVote)) = Hash.zip(
|
|
teacher_ids,
|
|
teachers.map do |t|
|
|
t.teacher_votes.map do |tv|
|
|
{
|
|
student: tv.vote.student.id,
|
|
priority: tv.priority,
|
|
}
|
|
end
|
|
end
|
|
)
|
|
log "Got teachers"
|
|
|
|
students = Db::Student.query
|
|
.with_vote(&.with_teacher_votes(&.order_by(priority: :desc)))
|
|
.to_a
|
|
student_ids = students.map(&.id)
|
|
votes = Hash.zip(
|
|
student_ids,
|
|
students.map do |s|
|
|
s.vote.not_nil!.teacher_votes
|
|
.to_a
|
|
.select! { |tv| teacher_votes.has_key?(tv.teacher.id) }
|
|
.map do |tv|
|
|
{
|
|
teacher: tv.teacher.id,
|
|
priority: tv.priority,
|
|
}
|
|
end
|
|
end
|
|
)
|
|
votes_a = votes.to_a
|
|
log "Got students' votes"
|
|
|
|
best : {assignment: Hash(Int32, Assignment), priority_score: Int64, teacher_score: Int64}? = nil
|
|
empty_assignment_count = Hash.zip(teachers.map(&.id), [0] * teachers.size)
|
|
log "Starting assignment"
|
|
valid_count = 0_u64
|
|
invalid_count = 0_u64
|
|
|
|
max_span = Time::Span.new(minutes: Backend.config.assignment_run_time)
|
|
start_time = Time.utc
|
|
|
|
loop do
|
|
assignment = {} of Int32 => Assignment
|
|
assignment_count = empty_assignment_count.clone
|
|
votes_a.shuffle(Random::Secure).each do |s, tvs|
|
|
tvs.each do |tv|
|
|
if assignment_count[tv[:teacher]] < teacher_max_students[tv[:teacher]]
|
|
if assignment[s]?.nil?
|
|
assignment_count[tv[:teacher]] += 1
|
|
assignment[s] = {teacher: tv[:teacher], priority: tv[:priority]}
|
|
else
|
|
assignment_count[assignment[s][:teacher]] -= 1
|
|
assignment_count[tv[:teacher]] += 1
|
|
assignment[s] = {teacher: tv[:teacher], priority: tv[:priority]}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if assignment.size != student_count
|
|
invalid_count += 1
|
|
log "Invalid combination, next (#{assignment.size} != #{student_count}, valid: #{valid_count} invalid: #{invalid_count})"
|
|
else
|
|
log "Valid combination (valid: #{valid_count} invalid: #{invalid_count})!"
|
|
|
|
priority_score = 0_i64
|
|
assignment.each do |_, a|
|
|
priority_score += a[:priority] ** 2
|
|
end
|
|
|
|
teacher_score = 0_i64
|
|
assignment_count.each do |t, c|
|
|
teacher_score += (teacher_max_students[t] ** c) * teacher_max_students[t]
|
|
end
|
|
|
|
if best.nil?
|
|
best = {
|
|
assignment: assignment,
|
|
priority_score: priority_score,
|
|
teacher_score: teacher_score,
|
|
}
|
|
elsif priority_score < best.not_nil![:priority_score] && teacher_score < best.not_nil![:teacher_score]
|
|
best = {
|
|
assignment: assignment,
|
|
priority_score: priority_score,
|
|
teacher_score: teacher_score,
|
|
}
|
|
end
|
|
|
|
valid_count += 1
|
|
end
|
|
|
|
break if (Time.utc - start_time) > max_span && valid_count > 0
|
|
end
|
|
|
|
pp! best
|
|
|
|
log "Saving best assignment into database"
|
|
Db::Assignment.query.where { active }.to_update.set(active: false).execute
|
|
assignment_id = Db::Assignment.create!({
|
|
active: true,
|
|
priority_score: best.not_nil![:priority_score],
|
|
teacher_score: best.not_nil![:teacher_score],
|
|
}).id
|
|
Db::StudentAssignment.import(
|
|
best.not_nil![:assignment].map do |s, a|
|
|
Db::StudentAssignment.new({
|
|
assignment_id: assignment_id,
|
|
student_id: s,
|
|
teacher_id: a[:teacher],
|
|
})
|
|
end
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|