Did stuff
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Dominic Grimm 2022-07-28 14:05:10 +02:00
parent 48b25adb07
commit 1c72c81b85
No known key found for this signature in database
GPG key ID: A6C051C716D2CE65
29 changed files with 312 additions and 88 deletions

View file

@ -23,6 +23,7 @@ POSTGRES_PASSWORD=
# Backend
BACKEND_MINIMUM_TEACHER_SELECTION_COUNT=6
BACKEND_ASSIGNMENT_POSSIBILITY_COUNT=500
BACKEND_URL=URL
# Backend - API
BACKEND_API_GRAPHQL_PLAYGROUND=false

View file

@ -78,6 +78,7 @@ services:
environment:
BACKEND_URL: ${URL}
BACKEND_MINIMUM_TEACHER_SELECTION_COUNT: ${BACKEND_MINIMUM_TEACHER_SELECTION_COUNT}
BACKEND_ASSIGNMENT_POSSIBILITY_COUNT: ${BACKEND_ASSIGNMENT_POSSIBILITY_COUNT}
BACKEND_API_GRAPHQL_PLAYGROUND: ${BACKEND_API_GRAPHQL_PLAYGROUND}
BACKEND_API_JWT_SECRET: ${BACKEND_API_JWT_SECRET}
BACKEND_API_JWT_EXPIRATION: ${BACKEND_API_JWT_EXPIRATION}

View file

@ -14,12 +14,12 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
FROM crystallang/crystal:1.4-alpine as micrate-deps
FROM crystallang/crystal:1.5-alpine as micrate-deps
WORKDIR /src
COPY ./micrate/shard.yml ./micrate/shard.lock ./
RUN shards install --production
FROM crystallang/crystal:1.4-alpine as micrate-builder
FROM crystallang/crystal:1.5-alpine as micrate-builder
WORKDIR /src
# RUN apk add --no-cache sqlite-static
COPY --from=micrate-deps /src/shard.yml /src/shard.lock ./
@ -30,16 +30,14 @@ RUN shards build --release --static --verbose -s -p -t
FROM tdewolff/minify as public
WORKDIR /src
COPY ./public ./src
RUN minify -r -o ./tmp_dist ./src
RUN mv ./tmp_dist/src ./dist
RUN rm -rf ./tmp_dist
RUN minify -r -o ./dist ./src
FROM crystallang/crystal:1.4-alpine as deps
FROM crystallang/crystal:1.5-alpine as deps
WORKDIR /src
COPY ./shard.yml ./shard.lock ./
RUN shards install --production
FROM crystallang/crystal:1.4-alpine as builder
FROM crystallang/crystal:1.5-alpine as builder
ARG BUILD_ENV
WORKDIR /src/mentorenwahl
RUN apk add --no-cache pcre2-dev

View file

@ -25,4 +25,4 @@ prod:
shards build --static --release --verbose -s -p -t
docs:
crystal docs --project-name "Mentorenwahl Backend" -D granite_docs
crystal docs --project-name "Mentorenwahl Backend"

View file

@ -20,7 +20,7 @@
CREATE TYPE user_roles AS ENUM ('teacher', 'student');
CREATE TABLE users(
id BIGSERIAL PRIMARY KEY,
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
role user_roles NOT NULL,
skif BOOLEAN NOT NULL,
@ -28,43 +28,34 @@ CREATE TABLE users(
);
CREATE TABLE teachers(
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE REFERENCES users(id),
id SERIAL PRIMARY KEY,
user_id INT NOT NULL UNIQUE REFERENCES users(id),
max_students INT NOT NULL
);
CREATE TABLE students(
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE REFERENCES users(id)
id SERIAL PRIMARY KEY,
user_id INT NOT NULL UNIQUE REFERENCES users(id)
);
ALTER TABLE
users
ADD
COLUMN teacher_id BIGINT UNIQUE REFERENCES teachers(id);
ALTER TABLE
users
ADD
COLUMN student_id BIGINT UNIQUE REFERENCES students(id);
CREATE TABLE votes(
id BIGSERIAL PRIMARY KEY,
student_id BIGINT NOT NULL UNIQUE REFERENCES students(id)
id SERIAL PRIMARY KEY,
student_id INT NOT NULL UNIQUE REFERENCES students(id)
);
ALTER TABLE
students
ADD
COLUMN vote_id BIGINT UNIQUE REFERENCES votes(id);
CREATE TABLE teacher_votes(
id BIGSERIAL PRIMARY KEY,
vote_id BIGINT NOT NULL REFERENCES votes(id),
teacher_id BIGINT NOT NULL REFERENCES teachers(id),
id SERIAL PRIMARY KEY,
vote_id INT NOT NULL REFERENCES votes(id),
teacher_id INT NOT NULL REFERENCES teachers(id),
priority INT NOT NULL
);
CREATE TABLE assignments(
id SERIAL PRIMARY KEY,
student_id INT NOT NULL REFERENCES students(id),
teacher_id INT NOT NULL REFERENCES teachers(id)
);
-- +micrate Down
-- SQL section ' Down ' is executed when this migration is rolled back
DROP TABLE teacher_votes;

View file

@ -8,7 +8,7 @@ targets:
micrate:
main: src/micrate.cr
crystal: 1.4.0
crystal: 1.5.0
dependencies:
micrate:

View file

@ -6,35 +6,39 @@ shards:
athena:
git: https://github.com/athena-framework/framework.git
version: 0.16.0
version: 0.17.0
athena-config:
git: https://github.com/athena-framework/config.git
version: 0.3.0
version: 0.3.1
athena-dependency_injection:
git: https://github.com/athena-framework/dependency-injection.git
version: 0.3.2
version: 0.3.3
athena-event_dispatcher:
git: https://github.com/athena-framework/event-dispatcher.git
version: 0.1.3
version: 0.1.4
athena-image_size:
git: https://github.com/athena-framework/image-size.git
version: 0.1.1
athena-negotiation:
git: https://github.com/athena-framework/negotiation.git
version: 0.1.1
version: 0.1.2
athena-routing:
git: https://github.com/athena-framework/routing.git
version: 0.1.1
version: 0.1.2
athena-serializer:
git: https://github.com/athena-framework/serializer.git
version: 0.2.10
version: 0.3.0
athena-validator:
git: https://github.com/athena-framework/validator.git
version: 0.1.7
version: 0.2.0
baked_file_system:
git: https://github.com/schovi/baked_file_system.git
@ -60,6 +64,10 @@ shards:
git: https://github.com/crystal-lang/crystal-db.git
version: 0.11.0
docker:
git: https://github.com/repomaa/docker.cr.git
version: 0.1.2
email:
git: https://github.com/arcage/crystal-email.git
version: 0.6.4
@ -100,17 +108,13 @@ shards:
git: https://git.dergrimm.net/dergrimm/ldap_escape.git
version: 0.1.0
micrate:
git: https://github.com/amberframework/micrate.git
version: 0.3.3
mosquito:
git: https://github.com/mosquito-cr/mosquito.git
version: 1.0.0.rc1+git.commit.afd53dd241447b60ece9232b6c71669b192baaa4
version: 0.11.2
openssl_ext:
git: https://github.com/spider-gazelle/openssl_ext.git
version: 2.1.5
version: 2.2.0
pg:
git: https://github.com/will/crystal-pg.git

View file

@ -26,7 +26,7 @@ targets:
backend:
main: src/cli/backend.cr
crystal: 1.4.0
crystal: 1.5.0
dependencies:
clear:
@ -40,7 +40,6 @@ dependencies:
github: mrrooijen/commander
mosquito:
github: mosquito-cr/mosquito
branch: master
quartz_mailer:
github: amberframework/quartz-mailer
kilt:
@ -67,3 +66,5 @@ dependencies:
github: schovi/baked_file_system
compiled_license:
git: https://git.dergrimm.net/dergrimm/compiled_license.git
docker:
github: repomaa/docker.cr

View file

@ -22,19 +22,28 @@ module Backend
# GraphQL request context class
class Context < GraphQL::Context
# Development mode
getter development : Bool
getter development
# Authenticated user
getter user : Db::User?
getter user
# User is admin
getter admin : Bool?
getter admin
# User's role
getter role : Schema::UserRole?
getter role
# User's external object
getter external : (Db::Teacher | Db::Student)?
getter external
def initialize(
@development : Bool,
@user : Db::User?,
@admin : Bool?,
@role : Schema::UserRole?,
@external : (Db::Teacher | Db::Student)?
)
end
def initialize(headers : HTTP::Headers, @development : Bool, *rest)
super(*rest)
@ -47,8 +56,8 @@ module Backend
return unless @user = Db::User.find(data["user_id"].as_i)
if @user
@admin = @user.not_nil!.admin
@role = @user.not_nil!.role.to_api
@admin = user.not_nil!.admin
@role = user.not_nil!.role.to_api
@external =
case @role.not_nil!
when .teacher?
@ -92,7 +101,7 @@ module Backend
return true if @role == role &&
if external_check
role ==
case @external.not_nil!
case @external.not_nil! # TODO: Simplify with Germanium in future but for now with macro iteration over `#resolve#constants`
when Db::Teacher
Schema::UserRole::Teacher
when Db::Student

View file

@ -27,7 +27,7 @@ module Backend
raise "Auth failed" if username.empty? || password.empty?
user = Db::User.query.find { var(:username) == username }
raise "Auth failed" unless user && Ldap.authenticate?(Ldap::Constructor.uid(username), password)
raise "Auth failed" unless user && Ldap.authenticate?(Ldap::DN.uid(username), password)
LoginPayload.new(
user: User.new(user),
@ -153,6 +153,7 @@ module Backend
def create_vote(context : Context, input : VoteCreateInput) : Vote
context.student!
raise "Duplicate teachers" if input.teacher_ids.uniq.size != input.teacher_ids.size
raise "Not enough teachers" if input.teacher_ids.size < Backend.config.minimum_teacher_selection_count
teacher_role_count = Db::User.query.where(role: Db::UserRole::Teacher).count
raise "Teachers not registered" if teacher_role_count != Db::Teacher.query.count || teacher_role_count.zero?

View file

@ -55,6 +55,13 @@ module Backend
Db::User.query.map { |user| User.new(user) }
end
@[GraphQL::Field]
def user_by_username(context : Context, username : String) : User
context.admin!
User.new(Db::User.query.find { var(:username) == username }.not_nil!)
end
@[GraphQL::Field]
# All admins
def admins(context : Context) : Array(User)
@ -112,7 +119,10 @@ module Backend
def all_students_voted(context : Context) : Bool
context.admin!
Db::Vote.query.count >= Db::Student.query.count
votes = Db::Vote.query.count
students = Db::Student.query.count
students > 0 && votes >= students
end
@[GraphQL::Field]

View file

@ -35,6 +35,12 @@ module Backend
def max_students : Int32
@model.max_students
end
@[GraphQL::Field]
# Students' votes
def teacher_votes : Array(TeacherVote)
Db::TeacherVote.query.where { teacher_id == @model.id }.map { |tv| TeacherVote.new(tv) }
end
end
@[GraphQL::InputObject]

View file

@ -35,6 +35,12 @@ module Backend
def priority : Int32
@model.priority
end
@[GraphQL::Field]
# Student's vote
def vote : Vote
Vote.new(@model.vote)
end
end
@[GraphQL::InputObject]

View file

@ -41,6 +41,8 @@ module Backend
db_object Db::User
@ldap : Ldap::User?
# LDAP user data
def ldap : Ldap::User
unless raw_cache = Redis::CLIENT.get("ldap:user:#{id}")

View file

@ -0,0 +1,95 @@
require "commander"
require "compiled_license"
module Backend
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 CompiledLicense::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 = "register <username> <role>"
c.short = "Seeds the database with required data"
c.long = c.short
c.flags.add do |f|
f.name = "skif"
f.long = "--skif"
f.default = false
f.description = "User at SKIF"
end
c.flags.add do |f|
f.name = "admin"
f.long = "--admin"
f.default = false
f.description = "Register as admin"
end
c.flags.add do |f|
f.name = "yes"
f.short = "-y"
f.long = "--yes"
f.default = false
f.description = "Answer yes to all questions"
end
c.run do |opts, args|
username = args[0]
role = Db::UserRole.from_string(args[1].underscore)
unless opts.bool["yes"]
print "Register '#{username}' as '#{role.to_api}'#{opts.bool["admin"] ? " with admin privileges" : nil}? [y/N] "
abort unless gets(chomp: true).not_nil!.strip.downcase == "y"
end
user = Db::User.create!(username: username, role: role.to_s, skif: opts.bool["skif"], admin: opts.bool["admin"])
Worker::Jobs::CacheLdapUserJob.new(user.id).enqueue
puts "Done!"
end
end
end
end

View file

@ -61,6 +61,9 @@ module Backend
# Minimum teacher selection count
getter minimum_teacher_selection_count : Int32
# Assignment possibility count
getter assignment_possibility_count : Int32
@[EnvConfig::Setting(key: "api")]
# Configuration for `Api`
getter api : ApiConfig

View file

@ -0,0 +1,29 @@
# 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 Db
class Assignment
include Clear::Model
self.table = :assignments
primary_key type: :serial
belongs_to student : Student
belongs_to teacher : Teacher
end
end
end

View file

@ -9,6 +9,7 @@ module Backend
belongs_to user : User
has_one vote : Vote?, foreign_key: :student_id
has_one assignment : Assignment?, foreign_key: :student_id
end
end
end

View file

@ -10,7 +10,8 @@ module Backend
column max_students : Int32
has_many teacher_votes : TeacherVote
has_many teacher_votes : TeacherVote, foreign_key: :teacher_id
has_many assignments : Assignment, foreign_key: :teacher_id
end
end
end

View file

@ -26,9 +26,7 @@ module Backend
module Ldap
extend self
CLIENT = ConnectionPool.new do
create_client
end
CLIENT = ConnectionPool.new(capacity: 25) { create_client }
# Creates a new LDAP connection
private def create_client : LDAP::Client
@ -37,7 +35,7 @@ module Backend
# Checks if credentials are valid
def authenticate?(dn : String, password : String) : Bool
!!CLIENT.connection(&.authenticate(dn, password))
!!create_client.authenticate(dn, password)
rescue LDAP::Client::AuthError
false
end

View file

@ -17,7 +17,7 @@
module Backend
module Ldap
# DN construction utilities
module Constructor
module DN
extend self
# Constructs a CN DN from a username

View file

@ -67,7 +67,7 @@ module Backend
# Creates user data from LDAP username
def self.from_username(username : String) : self
from_dn(Ldap::Constructor.uid(username))
from_dn(Ldap::DN.uid(username))
end
# Creates user data from DB entry

View file

@ -36,9 +36,6 @@ module Backend
# Run the backend
def run : self
Signal::INT.trap { exit }
Signal::TERM.trap { exit }
{% if flag?(:development) %}
Log.warn { "Backend is running in development mode! Do not use this in production!" }
{% end %}

View file

@ -15,6 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
require "http/headers"
require "mime"
module Backend
module Web
@ -25,12 +26,12 @@ module Backend
@[ARTA::Get("")]
def playground : ATH::Response | ATH::Exceptions::HTTPException
{% if flag?(:development) %}
ATH::StreamedResponse.new(headers: HTTP::Headers{"content-type" => "text/html"}) do |io|
ATH::StreamedResponse.new(headers: HTTP::Headers{"content-type" => MIME.from_extension(".html")}) do |io|
IO.copy(Public.get("index.html"), io)
end
{% else %}
if Backend.config.api.graphql_playground_fully_enabled?
ATH::StreamedResponse.new(headers: HTTP::Headers{"content-type" => "text/html"}) do |io|
ATH::StreamedResponse.new(headers: HTTP::Headers{"content-type" => MIME.from_extension(".html")}) do |io|
IO.copy(Public.get("index.html"), io)
end
else
@ -62,7 +63,7 @@ module Backend
query = GraphQLQueryData.from_json(request.body.not_nil!)
ATH::StreamedResponse.new(headers: HTTP::Headers{"content-type" => "application/json"}) do |io|
ATH::StreamedResponse.new(headers: HTTP::Headers{"content-type" => MIME.from_extension(".json")}) do |io|
Api::Schema::SCHEMA.execute(
io,
query.query,

View file

@ -24,7 +24,7 @@ module Backend
Log = ::Log.for(self)
# Runs web service
def run(_unit : ::Service::Unit) : ::Service::Unit?
def run(unit : ::Service::Unit) : ::Service::Unit?
Log.info { "Starting web service..." }
ATH.run(80)
Log.info { "Web service stopped." }

View file

@ -14,28 +14,101 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
require "random/secure"
module Backend
module Worker
module Jobs
# Assigns students to teachers when all students voted
class AssignStudentsJob < Mosquito::QueuedJob
def rescheduleable?
false
end
# :ditto:
def perform : Nil
if Db::User.query.count == 0
user_count = Db::User.query.count
teacher_count = Db::Teacher.query.count
student_count = Db::Student.query.count
vote_count = Db::Vote.query.count
if user_count == 0
log "No users found, skipping assignment"
fail
elsif Db::Teacher.query.count == 0
elsif teacher_count == 0
log "No teachers found, skipping assignment"
fail
elsif Db::Student.query.count == 0
elsif student_count == 0
log "No students found, skipping assignment"
fail
elsif Db::Student.query.count != Db::User.query.where(role: Db::UserRole::Student).count
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
pp! Db::Teacher.query.to_a, Db::Student.query.to_a
# possibilities = [] of Possibility
# teachers = Db::Teacher.query.to_a.select! { |t| t.assignments.count < t.max_students }
# empty_assignment = Hash.zip(teachers.map(&.id), [[] of StudentAssignment] * teachers.size)
# random_votes = Db::Student.query.to_a.select!(&.vote).map do |s|
# {
# student: s.id,
# teachers: s.vote.not_nil!.teacher_votes.to_a
# .sort_by!(&.priority)
# .reverse!
# .map(&.teacher.id),
# }
# end
# Backend.config.assignment_retry_count.times do
# pp! random_votes.shuffle!(Random::Secure)
# a = empty_assignment.clone
# random_votes.
# possibilities << {assignment: a, weighting: 0}
# end
# pp! possibilities
teacher_ids = Db::Teacher.query
.select("id")
.where { raw("(SELECT COUNT(*) FROM assignments WHERE teacher_id = teachers.id)") < max_students }
.map(&.id)
students = Db::Student.query
.where do
raw("NOT EXISTS (SELECT 1 FROM assignments WHERE student_id = students.id)") &
raw("EXISTS (SELECT 1 FROM votes WHERE student_id = students.id)")
end
.with_vote(&.with_teacher_votes(&.order_by(:priority, :asc)))
# student_ids = students.map(&.id)
# votes = Hash.zip(
# student_ids,
# students.map do |s|
# s.vote.not_nil!.teacher_votes.map(&.teacher_id)
# end,
# )
# pp! votes, student_ids
random_votes = students.map do |s|
{
student: s.id,
teachers: s.vote.not_nil!.teacher_votes.map(&.teacher_id),
}
end
assignments = [] of {assignment: Hash(Int32, Array(Int32)), weighting: Int32}
empty_assignment = Hash.zip(teacher_ids, [[] of {assignment: Hash(Int32, Array(Int32)), weighting: Int32}] * teacher_ids.size)
Backend.config.assignment_possibility_count.times do
random_votes.shuffle!(Random::Secure)
votes = random_votes.dup
a = empty_assignment.clone
end
pp! assignments
end
end
end

View file

@ -24,7 +24,7 @@ module Backend
Log = ::Log.for(self)
# Runs worker service
def run(_unit : ::Service::Unit) : ::Service::Unit?
def run(unit : ::Service::Unit) : ::Service::Unit?
Log.info { "Starting worker service..." }
Mosquito::Runner.start
Log.info { "Worker service stopped." }

View file

@ -15,9 +15,12 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
require "commander"
require "../backend"
require "compiled_license"
require "docker"
require "../backend"
Docker.setup
Backend::Db.init
cli = Commander::Command.new do |cmd|

View file

@ -1,7 +0,0 @@
require "clear"
require "../backend"
require "log"
Backend::Db.init(Log::Severity::Debug)
Clear::CLI.run(false)