483 lines
14 KiB
Crystal
483 lines
14 KiB
Crystal
require "commander"
|
|
require "tallboy"
|
|
require "wannabe_bool"
|
|
require "csv"
|
|
|
|
module Backend
|
|
alias TeacherVote = {student: Int32, priority: Int32}
|
|
alias Assignment = {teacher: Int32, priority: Int32}
|
|
|
|
def do_assignment(roll_count : UInt32) : Time::Span
|
|
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
|
|
)
|
|
|
|
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
|
|
|
|
t1 = Time.utc
|
|
best : {assignment: Hash(Int32, Assignment), priority_score: Int64, teacher_score: Int64}? = nil
|
|
empty_assignment_count = Hash.zip(teachers.map(&.id), [0] * teachers.size)
|
|
roll_count.times.each do |i|
|
|
p! i
|
|
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
|
|
|
|
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
|
|
end
|
|
t2 = Time.utc
|
|
|
|
pp! best
|
|
|
|
# Db::Assignment.import(best.not_nil![:assignment].map { |s, a| Db::Assignment.new({student_id: s, teacher_id: a[:teacher]}) })
|
|
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
|
|
)
|
|
|
|
t2 - t1
|
|
end
|
|
|
|
CLI = Commander::Command.new do |cmd|
|
|
cmd.use = "backend"
|
|
cmd.short = "Mentorenwahl backend CLI"
|
|
|
|
cmd.run do
|
|
puts cmd.help
|
|
end
|
|
|
|
cmd.commands.add do |c|
|
|
c.use = "version"
|
|
c.short = "Prints version"
|
|
c.long = c.short
|
|
|
|
c.run do
|
|
puts VERSION
|
|
end
|
|
end
|
|
|
|
cmd.commands.add do |c|
|
|
c.use = "authors"
|
|
c.short = "Prints authors"
|
|
c.long = c.short
|
|
|
|
c.run do
|
|
puts AUTHORS.join("\n")
|
|
end
|
|
end
|
|
|
|
cmd.commands.add do |c|
|
|
c.use = "licenses"
|
|
c.short = "Prints licenses of projects used by this programs"
|
|
c.long = c.short
|
|
|
|
c.run do
|
|
puts LICENSES
|
|
end
|
|
end
|
|
|
|
cmd.commands.add do |c|
|
|
c.use = "run"
|
|
c.short = "Run the backend"
|
|
c.long = c.short
|
|
|
|
c.run do
|
|
Runner.new.run
|
|
end
|
|
end
|
|
|
|
cmd.commands.add do |c|
|
|
c.use = "schema"
|
|
c.short = "Prints out GraphQL schema"
|
|
c.long = c.short
|
|
|
|
c.run do
|
|
puts Api::Schema::SCHEMA.document.to_s
|
|
end
|
|
end
|
|
|
|
cmd.commands.add do |c|
|
|
c.use = "user:import"
|
|
c.short = "Imports users from Untis"
|
|
c.long = c.short
|
|
|
|
c.run do
|
|
users = Auth.users
|
|
|
|
users.classes.each do |cl|
|
|
c_db = Db::Class.create!({name: cl.name})
|
|
cl.students.each do |s|
|
|
password = Password.generate(Password::DEFAULT_LEN)
|
|
user = Db::User.create!({
|
|
username: s.username,
|
|
password: password,
|
|
initial_password: password,
|
|
password_changed: false,
|
|
first_name: s.first_name,
|
|
last_name: s.last_name,
|
|
role: Db::UserRole::Student,
|
|
admin: false,
|
|
})
|
|
Db::Student.create!({
|
|
user_id: user.id,
|
|
class_id: c_db.id,
|
|
})
|
|
end
|
|
end
|
|
Db::User.import(
|
|
users.teachers.map do |t|
|
|
password = Password.generate(Password::DEFAULT_LEN)
|
|
Db::User.new({
|
|
username: t.username,
|
|
password: password,
|
|
initial_password: password,
|
|
password_changed: false,
|
|
first_name: t.first_name,
|
|
last_name: t.last_name,
|
|
role: Db::UserRole::Teacher,
|
|
admin: false,
|
|
})
|
|
end
|
|
)
|
|
end
|
|
end
|
|
|
|
# ameba:disable Lint/ShadowingOuterLocalVar
|
|
cmd.commands.add do |cmd|
|
|
cmd.use = "user:list"
|
|
cmd.short = "Lists all users"
|
|
cmd.long = cmd.short
|
|
|
|
cmd.run do
|
|
users = Db::User.query.to_a
|
|
table = Tallboy.table do
|
|
header ["id", "username", "first_name", "last_name", "role", "admin"]
|
|
|
|
users.each do |user|
|
|
row [
|
|
user.id,
|
|
user.username,
|
|
user.first_name,
|
|
user.last_name,
|
|
user.role,
|
|
user.admin,
|
|
]
|
|
end
|
|
end
|
|
|
|
puts table
|
|
end
|
|
end
|
|
|
|
# ameba:disable Lint/ShadowingOuterLocalVar
|
|
cmd.commands.add do |cmd|
|
|
cmd.use = "user:export"
|
|
cmd.short = "Generates report for all users"
|
|
cmd.long = cmd.short
|
|
|
|
cmd.run do
|
|
time = Time.local
|
|
|
|
students = [] of Templates::Users::Student
|
|
teachers = [] of Templates::Users::User
|
|
Db::User.query
|
|
.with_student(&.with_class_model)
|
|
.with_teacher
|
|
.each do |user|
|
|
password = user.password_changed ? nil : user.initial_password
|
|
case user.role.to_api
|
|
in Api::Schema::UserRole::Student
|
|
students << Templates::Users::Student.new(
|
|
class_name: user.student.not_nil!.class_model.name,
|
|
user: Templates::Users::User.new(
|
|
first_name: user.first_name,
|
|
last_name: user.last_name,
|
|
username: user.username,
|
|
password: password
|
|
)
|
|
)
|
|
in Api::Schema::UserRole::Teacher
|
|
teachers << Templates::Users::User.new(
|
|
first_name: user.first_name,
|
|
last_name: user.last_name,
|
|
username: user.username,
|
|
password: password
|
|
)
|
|
end
|
|
end
|
|
|
|
html = Templates::Users.new(
|
|
time,
|
|
Db::Class.query.with_students.to_a.map { |cl| {cl.name, cl.students.count.to_i32} },
|
|
students,
|
|
teachers
|
|
).to_s
|
|
puts "Filepath: #{Auth.generate_pdf(html).filename}"
|
|
end
|
|
end
|
|
|
|
# ameba:disable Lint/ShadowingOuterLocalVar
|
|
cmd.commands.add do |cmd|
|
|
cmd.use = "user:admin <id> <admin>"
|
|
cmd.short = "Gives or removed admin rights"
|
|
cmd.long = cmd.short
|
|
|
|
cmd.run do |_opts, args|
|
|
user = Db::User.find!(args[0].to_i)
|
|
user.admin = args[1].to_b
|
|
user.save!
|
|
|
|
table = Tallboy.table do
|
|
header ["id", "username", "first_name", "last_name", "role", "admin"]
|
|
|
|
row [
|
|
user.id,
|
|
user.username,
|
|
user.first_name,
|
|
user.last_name,
|
|
user.role,
|
|
user.admin,
|
|
]
|
|
end
|
|
|
|
puts table
|
|
end
|
|
end
|
|
|
|
# ameba:disable Lint/ShadowingOuterLocalVar
|
|
cmd.commands.add do |cmd|
|
|
cmd.use = "user:reset <id>"
|
|
cmd.short = "Reset user's password"
|
|
cmd.long = cmd.short
|
|
|
|
cmd.run do |_opts, args|
|
|
user = Db::User.find!(args[0].to_i)
|
|
puts "Changing password for user:"
|
|
table = Tallboy.table do
|
|
header ["id", "username", "first_name", "last_name", "role", "admin"]
|
|
|
|
row [
|
|
user.id,
|
|
user.username,
|
|
user.first_name,
|
|
user.last_name,
|
|
user.role,
|
|
user.admin,
|
|
]
|
|
end
|
|
puts table
|
|
|
|
print "New password: "
|
|
new_password = gets(chomp: true).not_nil!
|
|
puts "New password is: \"#{Regex.escape(new_password)}\""
|
|
print "Reenter new password: "
|
|
if gets(chomp: true).not_nil! == new_password
|
|
user.password = new_password
|
|
user.password_changed = true
|
|
user.save!
|
|
|
|
Db::Token.query
|
|
.where { user_id == user.id }
|
|
.to_update
|
|
.set(active: false)
|
|
.execute
|
|
end
|
|
end
|
|
end
|
|
|
|
# ameba:disable Lint/ShadowingOuterLocalVar
|
|
cmd.commands.add do |cmd|
|
|
cmd.use = "stats"
|
|
cmd.short = "Runs tests and outputs statistics for analysis"
|
|
cmd.long = cmd.short
|
|
|
|
cmd.run do
|
|
result = CSV.build do |csv|
|
|
csv.row "roll_count", "students", "time", "priority_score", "teacher_score"
|
|
|
|
(2..100).each do |i|
|
|
p! i
|
|
p! student_count = 0...i * 10
|
|
p! teacher_count = 0...i * 5
|
|
|
|
Db::User.import(
|
|
student_count.map do |x|
|
|
Db::User.new({
|
|
username: "student#{x}",
|
|
password_hash: "$2a$12$eEkFG9OAfaAgwPSHTIVfMedH9VIijpRIz1jddkxuJnbe5zwfVIQ6y",
|
|
initial_password: "12345",
|
|
password_changed: false,
|
|
first_name: "Student#{x}",
|
|
last_name: "Student#{x}",
|
|
role: Db::UserRole::Student,
|
|
admin: false,
|
|
})
|
|
end.concat(
|
|
teacher_count.map do |x|
|
|
Db::User.new({
|
|
username: "teacher#{x}",
|
|
password_hash: "$2a$12$eEkFG9OAfaAgwPSHTIVfMedH9VIijpRIz1jddkxuJnbe5zwfVIQ6y",
|
|
initial_password: "12345",
|
|
password_changed: false,
|
|
first_name: "Teacher#{x}",
|
|
last_name: "Teacher#{x}",
|
|
role: Db::UserRole::Teacher,
|
|
admin: false,
|
|
})
|
|
end
|
|
)
|
|
)
|
|
|
|
class_id = Db::Class.create!({name: "Default class"}).id
|
|
student_user_ids = Db::User.query.select(:id).where { role == Db::UserRole::Student }.map(&.id)
|
|
Db::Student.import(
|
|
student_user_ids.map do |id|
|
|
Db::Student.new({
|
|
user_id: id,
|
|
class_id: class_id,
|
|
})
|
|
end
|
|
)
|
|
student_ids = Db::Student.query.select(:id).map(&.id)
|
|
|
|
teacher_user_ids = Db::User.query.select(:id).where { role == Db::UserRole::Teacher }.map(&.id)
|
|
Db::Teacher.import(
|
|
teacher_user_ids.map do |id|
|
|
Db::Teacher.new({
|
|
user_id: id,
|
|
max_students: id % 5,
|
|
})
|
|
end
|
|
)
|
|
teacher_ids = Db::Teacher.query.select(:id).map(&.id)
|
|
|
|
Db::Vote.import(
|
|
student_ids.map do |id|
|
|
Db::Vote.new({student_id: id})
|
|
end
|
|
)
|
|
vote_ids = Db::Vote.query.select(:id).map(&.id)
|
|
Db::TeacherVote.import(
|
|
vote_ids.flat_map do |id|
|
|
size = 6 + id % 4
|
|
tvs = [] of Int32
|
|
while tvs.size < size
|
|
t_id = teacher_ids.sample
|
|
tvs << t_id unless t_id.in?(tvs)
|
|
end
|
|
|
|
tvs.map_with_index do |t, j|
|
|
Db::TeacherVote.new({
|
|
vote_id: id,
|
|
teacher_id: t,
|
|
priority: j,
|
|
})
|
|
end
|
|
end
|
|
)
|
|
|
|
dt = do_assignment(Backend.config.assignment_possibility_count)
|
|
a = Db::Assignment.query.where { active }.first!
|
|
csv.row Backend.config.assignment_possibility_count, student_ids.size, dt.seconds, a.priority_score, a.teacher_score
|
|
|
|
puts csv
|
|
|
|
Db::StudentAssignment.query.to_delete.execute
|
|
Db::Assignment.query.to_delete.execute
|
|
Db::TeacherVote.query.to_delete.execute
|
|
Db::Vote.query.to_delete.execute
|
|
Db::Teacher.query.to_delete.execute
|
|
Db::Student.query.to_delete.execute
|
|
Db::Class.query.to_delete.execute
|
|
Db::User.query.to_delete.execute
|
|
end
|
|
end
|
|
|
|
puts result
|
|
File.write("stats.csv", result.to_s)
|
|
end
|
|
end
|
|
end
|
|
end
|