# 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 . 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