Literally rewrote everything database related for clear ORM
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing

This commit is contained in:
Dominic Grimm 2022-04-05 20:26:22 +02:00
parent f02151bd5d
commit c75cad99f7
No known key found for this signature in database
GPG key ID: 27C59510125F3C8A
40 changed files with 294 additions and 492 deletions

View file

@ -37,6 +37,7 @@ BACKEND_SMTP_NAME=
BACKEND_SMTP_USERNAME= BACKEND_SMTP_USERNAME=
BACKEND_SMTP_PASSWORD= BACKEND_SMTP_PASSWORD=
# Backend - Db # Backend - Db
BACKEND_DB_ALLOW_OLD_SCHEMA=false
# Backend - LDAP # Backend - LDAP
BACKEND_LDAP_HOST="ldap.example.com" BACKEND_LDAP_HOST="ldap.example.com"
BACKEND_LDAP_PORT=389 BACKEND_LDAP_PORT=389
@ -44,4 +45,4 @@ BACKEND_LDAP_BASE_DN="dc=ldap,dc=example,dc=com"
BACKEND_LDAP_BASE_USER_DN="ou=users,dc=ldap,dc=example,dc=com" BACKEND_LDAP_BASE_USER_DN="ou=users,dc=ldap,dc=example,dc=com"
BACKEND_LDAP_BIND_DN="cn=admin,dc=ldap,dc=example,dc=com" BACKEND_LDAP_BIND_DN="cn=admin,dc=ldap,dc=example,dc=com"
BACKEND_LDAP_BIND_PASSWORD= BACKEND_LDAP_BIND_PASSWORD=
BACKEND_LDAP_CACHE_REFRESH_INTERVAL=60 BACKEND_LDAP_CACHE_REFRESH_INTERVAL=720

View file

@ -88,6 +88,7 @@ services:
BACKEND_SMTP_USERNAME: ${BACKEND_SMTP_USERNAME} BACKEND_SMTP_USERNAME: ${BACKEND_SMTP_USERNAME}
BACKEND_SMTP_PASSWORD: ${BACKEND_SMTP_PASSWORD} BACKEND_SMTP_PASSWORD: ${BACKEND_SMTP_PASSWORD}
BACKEND_DB_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_USER} BACKEND_DB_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_USER}
BACKEND_DB_ALLOW_OLD_SCHEMA: ${BACKEND_DB_ALLOW_OLD_SCHEMA}
BACKEND_REDIS_HOST: redis BACKEND_REDIS_HOST: redis
BACKEND_REDIS_PORT: 6379 BACKEND_REDIS_PORT: 6379
BACKEND_LDAP_HOST: ${BACKEND_LDAP_HOST} BACKEND_LDAP_HOST: ${BACKEND_LDAP_HOST}

View file

@ -43,7 +43,6 @@ RUN if [ "${BUILD_ENV}" = "development" ]; then \
FROM scratch as runner FROM scratch as runner
COPY --from=builder /src/bin /bin COPY --from=builder /src/bin /bin
COPY ./db /db
EXPOSE 80 EXPOSE 80
ENTRYPOINT [ "backend" ] ENTRYPOINT [ "backend" ]
CMD [ "run" ] CMD [ "run" ]

View file

@ -22,7 +22,7 @@ dev:
shards build -Ddevelopment --static --verbose -s -p -t shards build -Ddevelopment --static --verbose -s -p -t
prod: prod:
shards build --static --release --no-debug --verbose -s -p -t shards build --static --release --verbose -s -p -t
docs: docs:
crystal docs --project-name "Mentorenwahl Backend" -D granite_docs crystal docs --project-name "Mentorenwahl Backend" -D granite_docs

View file

@ -1,83 +0,0 @@
/*
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/>.
*/
-- +micrate Up
-- SQL in section ' Up ' is executed when this migration is applied
CREATE TYPE user_role AS ENUM ('Teacher', 'Student');
CREATE TABLE users(
id BIGSERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
role user_role NOT NULL,
admin BOOLEAN NOT NULL
);
CREATE TABLE teachers(
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE REFERENCES users(id),
max_students INT NOT NULL,
skif BOOLEAN NOT NULL
);
CREATE TABLE students(
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE REFERENCES users(id),
skif BOOLEAN NOT NULL
);
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)
);
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),
priority INT NOT NULL
);
-- +micrate Down
-- SQL section ' Down ' is executed when this migration is rolled back
DROP TABLE teacher_votes;
DROP TABLE votes;
DROP TABLE admins;
DROP TABLE teachers;
DROP TABLE students;
DROP TABLE users;
DROP TYPE user_roles;

View file

@ -1,5 +1,9 @@
version: 2.0 version: 2.0
shards: shards:
admiral:
git: https://github.com/jwaldrip/admiral.cr.git
version: 1.12.1
athena: athena:
git: https://github.com/athena-framework/framework.git git: https://github.com/athena-framework/framework.git
version: 0.16.0 version: 0.16.0
@ -40,13 +44,17 @@ shards:
git: https://github.com/spider-gazelle/bindata.git git: https://github.com/spider-gazelle/bindata.git
version: 1.10.0 version: 1.10.0
clear:
git: https://github.com/vici37/clear.git
version: 0.9+git.commit.2139d151d966b1119fd75c97d3b4d40a269592b9
commander: commander:
git: https://github.com/mrrooijen/commander.git git: https://github.com/mrrooijen/commander.git
version: 0.4.0 version: 0.4.0
db: db:
git: https://github.com/crystal-lang/crystal-db.git git: https://github.com/crystal-lang/crystal-db.git
version: 0.10.1 version: 0.11.0
email: email:
git: https://github.com/arcage/crystal-email.git git: https://github.com/arcage/crystal-email.git
@ -56,18 +64,22 @@ shards:
git: https://github.com/repomaa/env_config.cr.git git: https://github.com/repomaa/env_config.cr.git
version: 0.1.0+git.commit.a3ef5b955f27e2c65de2fe0ff41718e2eea7c06f version: 0.1.0+git.commit.a3ef5b955f27e2c65de2fe0ff41718e2eea7c06f
granite: generate:
git: https://github.com/amberframework/granite.git git: https://github.com/anykeyh/generate.cr.git
version: 0.23.0 version: 0.1.0+git.commit.f5dafc934a70e0ee2f246dddf3df44686f844da2
graphql: graphql:
git: https://github.com/graphql-crystal/graphql.git git: https://github.com/graphql-crystal/graphql.git
version: 0.3.2+git.commit.f49615eb286e90cfa9041107706a50d2c95e988d version: 0.4.0
habitat: habitat:
git: https://github.com/luckyframework/habitat.git git: https://github.com/luckyframework/habitat.git
version: 0.4.7 version: 0.4.7
inflector:
git: https://github.com/anykeyh/inflector.cr.git
version: 0.1.8+git.commit.dc5c898b0a834617d8b3ff73ac5a2239bd9fc019
jwt: jwt:
git: https://github.com/crystal-community/jwt.git git: https://github.com/crystal-community/jwt.git
version: 1.6.0 version: 1.6.0
@ -84,10 +96,6 @@ shards:
git: https://git.dergrimm.net/dergrimm/ldap_escape.git git: https://git.dergrimm.net/dergrimm/ldap_escape.git
version: 0.1.0 version: 0.1.0
micrate:
git: https://github.com/juanedi/micrate.git
version: 0.12.0
mosquito: mosquito:
git: https://github.com/mosquito-cr/mosquito.git git: https://github.com/mosquito-cr/mosquito.git
version: 1.0.0.rc1+git.commit.afd53dd241447b60ece9232b6c71669b192baaa4 version: 1.0.0.rc1+git.commit.afd53dd241447b60ece9232b6c71669b192baaa4
@ -98,7 +106,7 @@ shards:
pg: pg:
git: https://github.com/will/crystal-pg.git git: https://github.com/will/crystal-pg.git
version: 0.25.0 version: 0.26.0
pool: pool:
git: https://github.com/ysbaddaden/pool.git git: https://github.com/ysbaddaden/pool.git

View file

@ -25,25 +25,21 @@ license: GNU GPLv3
targets: targets:
backend: backend:
main: src/cli/backend.cr main: src/cli/backend.cr
micrate: clear:
main: src/cli/micrate.cr main: src/cli/clear.cr
crystal: 1.3.2 crystal: 1.3.2
dependencies: dependencies:
granite: clear:
github: amberframework/granite github: vici37/clear
pg: branch: master
github: will/crystal-pg
graphql: graphql:
github: graphql-crystal/graphql github: graphql-crystal/graphql
branch: main
jwt: jwt:
github: crystal-community/jwt github: crystal-community/jwt
commander: commander:
github: mrrooijen/commander github: mrrooijen/commander
micrate:
github: juanedi/micrate
mosquito: mosquito:
github: mosquito-cr/mosquito github: mosquito-cr/mosquito
branch: master branch: master

View file

@ -89,13 +89,15 @@ module Backend
return false unless authenticated? return false unless authenticated?
roles.each do |role| roles.each do |role|
return true if @role == role && if external_check return true if @role == role &&
role == case @external.not_nil! if external_check
when Db::Teacher role ==
Schema::UserRole::Teacher case @external.not_nil!
when Db::Student when Db::Teacher
Schema::UserRole::Student Schema::UserRole::Teacher
end when Db::Student
Schema::UserRole::Student
end
else else
true true
end end
@ -130,6 +132,8 @@ module Backend
def student!(external_check = true) : Bool def student!(external_check = true) : Bool
role! external_check, Schema::UserRole::Student role! external_check, Schema::UserRole::Student
end end
# TODO: Custom error handler
end end
end end
end end

View file

@ -19,25 +19,13 @@ module Backend
module Schema module Schema
# Schema helper macros # Schema helper macros
module Helpers module Helpers
# Defines field property and GraphQL specific getter
macro field(type)
property {{ type.var }} {% if type.value %} = {{ type.value }}{% end %}
@[GraphQL::Field]
def {{ type.var }} : {{ type.type }}
@{{ type.var }}
end
end
# Defines DB model field helper functions # Defines DB model field helper functions
macro db_object(type) macro db_object(type)
private property model
def initialize(@model : {{ type }}) def initialize(@model : {{ type }})
end end
def self.from_id(id : Int32) : self def self.from_id(id : Int32) : self
new({{ type }}.find!(id)) new({{ type }}.query.find! { var(:id) == id })
end end
{% space_name = type.names.last.underscore.gsub(/_/, " ").capitalize %} {% space_name = type.names.last.underscore.gsub(/_/, " ").capitalize %}
@ -45,7 +33,7 @@ module Backend
@[GraphQL::Field] @[GraphQL::Field]
# {{ space_name }}'s ID # {{ space_name }}'s ID
def id : Int32 def id : Int32
@model.id.not_nil!.to_i @model.id
end end
end end
end end

View file

@ -26,7 +26,7 @@ module Backend
def login(username : String, password : String) : LoginPayload def login(username : String, password : String) : LoginPayload
raise "Auth failed" if username.empty? || password.empty? raise "Auth failed" if username.empty? || password.empty?
user = Db::User.find_by(username: username) 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::Constructor.uid(username), password)
LoginPayload.new( LoginPayload.new(
@ -48,7 +48,7 @@ module Backend
rescue LDAP::Client::AuthError rescue LDAP::Client::AuthError
true true
end end
user = Db::User.create!(username: input.username, role: input.role.to_s, admin: input.admin) user = Db::User.create!(username: input.username, role: input.role.to_db, skif: input.skif, admin: input.admin)
Worker::Jobs::CacheLdapUserJob.new(user.id.not_nil!.to_i).enqueue Worker::Jobs::CacheLdapUserJob.new(user.id.not_nil!.to_i).enqueue
User.new(user) User.new(user)
@ -60,7 +60,7 @@ module Backend
context.admin! context.admin!
user = Db::User.find!(id) user = Db::User.find!(id)
user.destroy! user.delete
id id
end end
@ -90,7 +90,7 @@ module Backend
context.admin! context.admin!
teacher = Db::Teacher.find!(id) teacher = Db::Teacher.find!(id)
teacher.destroy! teacher.delete
id id
end end
@ -101,7 +101,7 @@ module Backend
context.teacher! external_check: false context.teacher! external_check: false
Teacher.new( Teacher.new(
Db::Teacher.create!(user_id: context.user.not_nil!.id, max_students: input.max_students, skif: input.skif) Db::Teacher.create!(user_id: context.user.not_nil!.id, max_students: input.max_students)
) )
end end
@ -111,7 +111,7 @@ module Backend
context.admin! context.admin!
user = Db::User.find!(input.user_id) user = Db::User.find!(input.user_id)
raise "User not a student" unless user.role.student? raise "User not a student" unless user.role.to_api.student?
student = Db::Student.create!(user_id: user.id) student = Db::Student.create!(user_id: user.id)
Student.new(student) Student.new(student)
@ -123,18 +123,18 @@ module Backend
context.admin! context.admin!
student = Db::Student.find!(id) student = Db::Student.find!(id)
student.destroy! student.delete
id id
end end
@[GraphQL::Field] @[GraphQL::Field]
# Self register as student # Self register as student
def register_student(context : Context, input : StudentInput) : Student def register_student(context : Context) : Student
context.student! external_check: false context.student! external_check: false
Student.new( Student.new(
Db::Student.create!(user_id: context.user.not_nil!.id, skif: input.skif) Db::Student.create!(user_id: context.user.not_nil!.id)
) )
end end
@ -144,18 +144,16 @@ module Backend
context.student! context.student!
raise "Not enough teachers" if input.teacher_ids.size < Backend.config.minimum_teacher_selection_count raise "Not enough teachers" if input.teacher_ids.size < Backend.config.minimum_teacher_selection_count
teacher_role_count = Db::User.where(role: Db::UserRole::Teacher.to_s).count.run.as(Int64) teacher_role_count = Db::User.query.where(role: Db::UserRole::Teacher).count
raise "Teachers not registered" if teacher_role_count != Db::Teacher.count || raise "Teachers not registered" if teacher_role_count != Db::Teacher.query.count || teacher_role_count.zero?
teacher_role_count.zero?
skif = context.external.as(Db::Student).skif
input.teacher_ids.each do |id| input.teacher_ids.each do |id|
teacher = Db::Teacher.find(id) teacher = Db::Teacher.find(id)
if teacher.nil? if teacher.nil?
raise "Teachers not found" raise "Teachers not found"
elsif teacher.skif != skif elsif teacher.user.skif != context.user.not_nil!.skif
if teacher.skif if teacher.user.skif
raise "Teacher is SKIF, student is not" raise "Teacher is SKIF, student is not"
else else
raise "Teacher is not SKIF, student is" raise "Teacher is not SKIF, student is"
@ -165,7 +163,7 @@ module Backend
student = context.external.not_nil!.as(Db::Student) student = context.external.not_nil!.as(Db::Student)
vote = Db::Vote.create!(student_id: student.id) vote = Db::Vote.create!(student_id: student.id)
Db::TeacherVote.import(input.teacher_ids.map_with_index { |id, i| Db::TeacherVote.new(vote_id: vote.id, teacher_id: id.to_i64, priority: i) }) Db::TeacherVote.import(input.teacher_ids.map_with_index { |id, i| Db::TeacherVote.new({vote_id: vote.id, teacher_id: id, priority: i}) })
Vote.new(vote) Vote.new(vote)
end end

View file

@ -52,7 +52,7 @@ module Backend
def users(context : Context) : Array(User) def users(context : Context) : Array(User)
context.admin! context.admin!
Db::User.all.map { |user| User.new(user) } Db::User.query.map { |user| User.new(user) }
end end
@[GraphQL::Field] @[GraphQL::Field]
@ -60,7 +60,7 @@ module Backend
def admins(context : Context) : Array(User) def admins(context : Context) : Array(User)
context.admin! context.admin!
Db::User.where(admin: true).map { |user| User.new(user) } Db::User.query.where(admin: true).map { |user| User.new(user) }
end end
@[GraphQL::Field] @[GraphQL::Field]
@ -72,7 +72,7 @@ module Backend
@[GraphQL::Field] @[GraphQL::Field]
# All teachers # All teachers
def teachers : Array(Teacher) def teachers : Array(Teacher)
Db::Teacher.all.map { |teacher| Teacher.new(teacher) } Db::Teacher.query.map { |teacher| Teacher.new(teacher) }
end end
@[GraphQL::Field] @[GraphQL::Field]
@ -88,7 +88,7 @@ module Backend
def students(context : Context) : Array(Student) def students(context : Context) : Array(Student)
context.admin! context.admin!
Db::Student.all.map { |student| Student.new(student) } Db::Student.query.map { |student| Student.new(student) }
end end
@[GraphQL::Field] @[GraphQL::Field]
@ -104,7 +104,7 @@ module Backend
def votes(context : Context) : Array(Vote) def votes(context : Context) : Array(Vote)
context.admin! context.admin!
Db::Vote.all.map { |vote| Vote.new(vote) } Db::Vote.query.map { |vote| Vote.new(vote) }
end end
@[GraphQL::Field] @[GraphQL::Field]
@ -120,7 +120,7 @@ module Backend
def teacher_votes(context : Context) : Array(TeacherVote) def teacher_votes(context : Context) : Array(TeacherVote)
context.admin! context.admin!
Db::TeacherVote.all.map { |vote| TeacherVote.new(vote) } Db::TeacherVote.query.map { |vote| TeacherVote.new(vote) }
end end
end end
end end

View file

@ -30,12 +30,6 @@ module Backend
User.new(@model.user) User.new(@model.user)
end end
@[GraphQL::Field]
# Student at SKIF
def skif : Bool
@model.skif
end
@[GraphQL::Field] @[GraphQL::Field]
# Student's vote # Student's vote
def vote : Vote? def vote : Vote?
@ -43,26 +37,22 @@ module Backend
end end
end end
@[GraphQL::InputObject] # @[GraphQL::InputObject]
# Student base input # # Student base input
class StudentInput < GraphQL::BaseInputObject # class StudentInput < GraphQL::BaseInputObject
# Student at SKIF # @[GraphQL::Field]
getter skif # def initialize
# end
@[GraphQL::Field] # end
def initialize(@skif : Bool)
end
end
@[GraphQL::InputObject] @[GraphQL::InputObject]
# Student creation input # Student creation input
class StudentCreateInput < StudentInput class StudentCreateInput < GraphQL::BaseInputObject
# Student's user ID # Student's user ID
getter user_id getter user_id
@[GraphQL::Field] @[GraphQL::Field]
def initialize(@user_id : Int32, skif : Bool) def initialize(@user_id : Int32)
super(skif)
end end
end end
end end

View file

@ -35,12 +35,6 @@ module Backend
def max_students : Int32 def max_students : Int32
@model.max_students @model.max_students
end end
@[GraphQL::Field]
# Teacher is at SKIF
def skif : Bool
@model.skif
end
end end
@[GraphQL::InputObject] @[GraphQL::InputObject]
@ -49,11 +43,8 @@ module Backend
# Teacher's max students # Teacher's max students
getter max_students getter max_students
# Teacher at SKIF
getter skif
@[GraphQL::Field] @[GraphQL::Field]
def initialize(@max_students : Int32, @skif : Bool) def initialize(@max_students : Int32)
end end
end end
@ -64,8 +55,8 @@ module Backend
getter user_id getter user_id
@[GraphQL::Field] @[GraphQL::Field]
def initialize(@user_id : Int32, max_students : Int32, skif : Bool) def initialize(@user_id : Int32, max_students : Int32)
super(max_students, skif) super(max_students)
end end
end end
end end

View file

@ -25,12 +25,12 @@ module Backend
# DB representation of the enum # DB representation of the enum
def to_db : Db::UserRole def to_db : Db::UserRole
Db::UserRole.parse(self.to_s) Db::UserRole.from_string(self.to_s.underscore)
end end
# GraphQL representation of the DB enum # GraphQL representation of the DB enum
def self.from_db(role : Db::UserRole) : self def self.from_db(role : Db::UserRole) : self
Db::UserRole.to_api role.to_api
end end
end end
@ -42,29 +42,31 @@ module Backend
db_object Db::User db_object Db::User
# LDAP user data # LDAP user data
getter ldap : Ldap::User? def ldap : Ldap::User
unless raw_cache = Redis::CLIENT.get("ldap:user:#{id}")
Worker::Jobs::CacheLdapUserJob.new(id).perform
raw_cache = Redis::CLIENT.get("ldap:user:#{id}").not_nil!
end
# Refreshes LDAP user data (@ldap ||= Ldap::User.from_json(raw_cache.not_nil!)).not_nil!
def refresh_ldap : Ldap::User
(@ldap ||= Ldap::User.from_json(Redis::CLIENT.get("ldap:user:#{id}").as(String))).not_nil!
end end
@[GraphQL::Field] @[GraphQL::Field]
# User's first name # User's first name
def first_name : String def first_name : String
refresh_ldap.first_name ldap.first_name
end end
@[GraphQL::Field] @[GraphQL::Field]
# User's last name # User's last name
def last_name : String def last_name : String
refresh_ldap.last_name ldap.last_name
end end
@[GraphQL::Field] @[GraphQL::Field]
# User's full name # User's full name
def name : String def name : String
refresh_ldap.name ldap.name
end end
@[GraphQL::Field] @[GraphQL::Field]
@ -76,7 +78,7 @@ module Backend
@[GraphQL::Field] @[GraphQL::Field]
# User's email # User's email
def email : String def email : String
refresh_ldap.email ldap.email
end end
@[GraphQL::Field] @[GraphQL::Field]
@ -91,10 +93,16 @@ module Backend
@model.role.to_api @model.role.to_api
end end
@[GraphQL::Field]
# User is at SKIF
def skif : Bool
@model.skif
end
@[GraphQL::Field] @[GraphQL::Field]
# User's external ID # User's external ID
def external_id : Int32? def external_id : Int32?
case @model.role case @model.role.to_api
when .teacher? when .teacher?
@model.teacher @model.teacher
when .student? when .student?
@ -127,12 +135,14 @@ module Backend
class UserCreateInput < GraphQL::BaseInputObject class UserCreateInput < GraphQL::BaseInputObject
getter username getter username
getter role getter role
getter skif
getter admin getter admin
@[GraphQL::Field] @[GraphQL::Field]
def initialize( def initialize(
@username : String, @username : String,
@role : UserRole, @role : UserRole,
@skif : Bool,
@admin : Bool = false @admin : Bool = false
) )
end end

View file

@ -132,6 +132,9 @@ module Backend
# Database URL # Database URL
getter url : String getter url : String
# Allow old database migrations to be used
getter allow_old_schema : Bool
end end
# Configuration for `REDIS` # Configuration for `REDIS`

View file

@ -14,8 +14,10 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
require "granite" require "clear"
require "granite/adapter/pg" require "log"
require "db"
require "retriable"
require "./db/*" require "./db/*"
@ -24,17 +26,23 @@ module Backend
module Db module Db
extend self extend self
Granite::Connections << Granite::Adapter::Pg.new(name: "pg", url: Backend.config.db.url) # Migration UIDs
MIGRATIONS = {{ run("./macros/migrations.cr", "#{__DIR__}/db/migrations/*.cr").stringify.split("\n") }}
# Checks if database schema is up to date def init(severity = {% if flag?(:development) %} ::Log::Severity::Debug {% else %} ::Log::Severity::Info {% end %}) : Nil
def schema_up_to_date_compare : Int32? ::Log.builder.bind "clear.*", severity, ::Log::IOBackend.new
migrations = Dir["db/migrations/*.sql"].map { |f| Path[f].basename }.sort! Retriable.retry(on: DB::ConnectionRefused, backoff: false) do
return unless latest_migration = migrations.try(&.last.match(/\d+/).try(&.to_a.first.not_nil!.to_u64)) Clear::SQL.init(Backend.config.db.url)
end
end
begin def schema_up_to_date? : Bool
MicrateDbVersion.order(tstamp: :desc).limit(1).assembler.select.run.first.version_id <=> latest_migration last_migration = ClearMetadata.query.last!.value
rescue PQ::PQError
nil if last_migration == "-1"
false
else
last_migration == MIGRATIONS.last
end end
end end
end end

View file

@ -0,0 +1,11 @@
module Backend
module Db
class ClearMetadata
include Clear::Model
self.table = :__clear_metadatas
column metatype : String, primary: true
column value : String, primary: true
end
end
end

View file

@ -1,26 +1,11 @@
# 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 Backend
module Db module Db
# Micrate DB migrator model / configuration class MicrateDbVersion
class MicrateDbVersion < Granite::Base include Clear::Model
table micrate_db_version self.table = :micrate_db_version
primary_key type: :serial
column id : Int32, primary: true
column version_id : Int64 column version_id : Int64
column is_applied : Bool column is_applied : Bool
column tstamp : Time? column tstamp : Time?

View file

@ -0,0 +1,9 @@
require "./migrations/*"
module Backend
module Db
# DB SQL migrations
module Migration
end
end
end

View file

@ -0,0 +1,44 @@
module Backend
module Db
module Migrations
class CreateUsers1
include Clear::Migration
def change(dir) : Nil
create_enum :user_role, %w(teacher student)
create_table :users, id: false do |t| # We create the table users
t.column :id, :serial, primary: true, null: false
t.column :username, :string, unique: true, index: true, null: false
t.column :role, :user_role, null: false
t.column :skif, :bool, null: false
t.column :admin, :bool, default: false, null: false
end
create_table :teachers, id: false do |t|
t.column :id, :serial, primary: true, null: false
t.references to: "users", on_delete: :cascade, null: false
t.column :max_students, :int, null: false
end
create_table :students, id: false do |t|
t.column :id, :serial, primary: true, null: false
t.references to: "users", on_delete: :cascade, null: false
end
create_table :votes, id: false do |t|
t.column :id, :serial, primary: true, null: false
t.references to: "students", on_delete: :cascade, null: false
end
create_table :teacher_votes, id: false do |t|
t.column :id, :serial, primary: true, null: false
t.references to: "votes", on_delete: :cascade, null: false
t.references to: "teachers", on_delete: :cascade, null: false
t.column :priority, :int, null: false
end
end
end
end
end
end

View file

@ -1,33 +1,14 @@
# 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 Backend
module Db module Db
# Student model class Student
class Student < Granite::Base include Clear::Model
table students self.table = :students
belongs_to :user primary_key type: :serial
has_one :vote
# Student's ID belongs_to user : User
column id : Int64, primary: true
# Student is at SKIF has_one vote : Vote?, foreign_key: :student_id
column skif : Bool
end end
end end
end end

View file

@ -1,36 +1,16 @@
# 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 Backend
module Db module Db
# Teacher model class Teacher
class Teacher < Granite::Base include Clear::Model
table teachers self.table = :teachers
belongs_to :user primary_key type: :serial
has_many teacher_votes : TeacherVote
# Teacher's ID belongs_to user : User
column id : Int64, primary: true
# Teacher's max students count
column max_students : Int32 column max_students : Int32
# Teacher is at SKIF has_many teacher_votes : TeacherVote
column skif : Bool
end end
end end
end end

View file

@ -1,49 +1,15 @@
# 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 Backend
module Db module Db
# Teacher vote model class TeacherVote
class TeacherVote < Granite::Base include Clear::Model
table teacher_votes self.table = :teacher_votes
belongs_to :vote primary_key type: :serial
belongs_to :teacher
# Teacher votes's ID belongs_to vote : Vote
column id : Int64, primary: true belongs_to teacher : Teacher
# Teacher vote's priority
column priority : Int32 column priority : Int32
validate :teacher, "must be vote unique" do |teacher_vote|
self.where(vote_id: teacher_vote.vote.id, teacher_id: teacher_vote.teacher.not_nil!.id).count.run.as(Int64).zero?
end
validate :priority, "must be positive" do |teacher_vote|
teacher_vote.priority >= 0
end
validate :priority, "must be less than the number of teachers" do |teacher_vote|
teacher_vote.priority < Teacher.count
end
validate :priority, "must be vote unique" do |teacher_vote|
self.where(vote_id: teacher_vote.vote.id, priority: teacher_vote.priority).count.run.as(Int64).zero?
end
end end
end end
end end

View file

@ -1,26 +1,8 @@
# 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 Backend
module Db module Db
# Possible roles a user can have Clear.enum UserRole, :teacher, :student
enum UserRole
Teacher
Student
struct UserRole
# API representation of the enum # API representation of the enum
def to_api : Api::Schema::UserRole def to_api : Api::Schema::UserRole
Api::Schema::UserRole.parse(self.to_s) Api::Schema::UserRole.parse(self.to_s)
@ -32,24 +14,19 @@ module Backend
end end
end end
# User model class User
class User < Granite::Base include Clear::Model
table users self.table = :users
has_one :teacher primary_key type: :serial
has_one :student
# User's ID
column id : Int64, primary: true
# User's LDAP username
column username : String column username : String
column role : UserRole
# User's role
column role : UserRole, converter: Granite::Converters::Enum(Backend::Db::UserRole, String)
# User is admin
column admin : Bool = false column admin : Bool = false
column skif : Bool
has_one student : Student?, foreign_key: :user_id
has_one teacher : Teacher?, foreign_key: :user_id
end end
end end
end end

View file

@ -1,28 +1,14 @@
# 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 Backend
module Db module Db
class Vote < Granite::Base class Vote
table votes include Clear::Model
self.table = :votes
belongs_to :student primary_key type: :serial
has_many teacher_votes : TeacherVote
column id : Int64, primary: true belongs_to student : Student
has_many teacher_votes : TeacherVote, foreign_key: :vote_id
end end
end end
end end

View file

@ -77,7 +77,7 @@ module Backend
# Creates user data from DB entry index # Creates user data from DB entry index
def self.from_id(id : Int32) : self def self.from_id(id : Int32) : self
from_db(Db::User.find!(id)) from_username(Db::User.query.first(id).select(:id))
end end
end end
end end

View file

@ -0,0 +1 @@
print Dir[ARGV[0]].map { |f| File.basename(f).split("_", 2).first }.uniq!.sort!.join("\n")

View file

@ -17,6 +17,7 @@
require "service" require "service"
require "log" require "log"
require "retriable" require "retriable"
require "clear"
module Backend module Backend
# Backend runner # Backend runner
@ -44,18 +45,22 @@ module Backend
Log.info { "Checking if DB schema is up to date..." } Log.info { "Checking if DB schema is up to date..." }
Retriable.retry(backoff: false, base_interval: 10.seconds, multiplier: 1.0) do Retriable.retry(backoff: false, base_interval: 10.seconds, multiplier: 1.0) do
case Retriable.retry(on: DB::ConnectionRefused, backoff: false) do ex : Clear::SQL::Error? = nil
Db.schema_up_to_date_compare if begin
end Db.schema_up_to_date?
when nil rescue exc : Clear::SQL::Error
Log.fatal { "No database schema is applied. Please run `bash scripts/micrate.sh up` urgently!" } ex = exc
raise Exception.new
when -1 false
Log.warn { "Database schema is not up to date. Please run `bash scripts/micrate.sh up`." } end && ex.nil?
when 0
Log.info { "Database schema is up to date." } Log.info { "Database schema is up to date." }
else else
Log.warn { "Database schema is maybe up to date but not consistent. Please run `bash scripts/micrate.sh up` to be safe." } Log.warn { "Database schema is not up to date. Please run `bash scripts/clear.sh migrate`." }
if ex
raise ex
else
raise Exception.new("Database schema is not up to date.") unless Backend.config.db.allow_old_schema
end
end end
end end

View file

@ -39,14 +39,29 @@ module Backend
{% end %} {% end %}
end end
# GraphQL query request data
struct GraphQLQueryData
include JSON::Serializable
# Raw query
getter query : String
# Variables
getter variables = {} of String => JSON::Any
# Operation name
getter operation_name : String?
end
@[ARTA::Post("")] @[ARTA::Post("")]
@[ATHA::QueryParam("development")] @[ATHA::QueryParam("development")]
@[ATHA::ParamConverter("query", converter: ATH::RequestBodyConverter)] def endpoint(request : ATH::Request, development : Bool = false) : ATH::Response
def endpoint(query : Backend::Web::GraphQLQueryData, request : ATH::Request, development : Bool = false) : ATH::Response
{% if flag?(:development) %} {% if flag?(:development) %}
Log.notice { "Development request icoming" } if development Log.notice { "Development request icoming" } if development
{% end %} {% end %}
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" => "application/json"}) do |io|
Api::Schema::SCHEMA.execute( Api::Schema::SCHEMA.execute(
io, io,

View file

@ -1,32 +0,0 @@
# 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 Web
# GraphQL query request data
struct GraphQLQueryData
include AVD::Validatable
include ASR::Serializable
@[Assert::NotBlank]
getter query : String
getter variables : Hash(String, JSON::Any)?
getter operation_name : String?
end
end
end

View file

@ -24,11 +24,11 @@ module Backend
# :ditto: # :ditto:
def perform : Nil def perform : Nil
key = "ldap:user:#{id}" key = "ldap:user:#{id}"
user = Db::User.find(id) user = Db::User.find!(id)
if user if user
log "Caching user ##{id}..." log "Caching user ##{id}..."
ldap_user = Ldap::User.from_username(user.username) ldap_user = Ldap::User.from_username(user.username)
Redis::CLIENT.set(key, ldap_user.to_json) Redis::CLIENT.set(key, ldap_user.to_json, ex: (Time.utc + Backend.config.ldap.cache_refresh_interval.minutes).to_unix)
else else
log "User ##{id} not found. Deleting cache..." log "User ##{id} not found. Deleting cache..."
Redis::CLIENT.del(key) Redis::CLIENT.del(key)

View file

@ -1,41 +0,0 @@
# 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
# Peridically caches user data in redis cache
class CacheLdapUsersJob < Mosquito::PeriodicJob
run_every Backend.config.ldap.cache_refresh_interval.minutes
# :ditto:
def perform : Nil
Redis::CLIENT.keys("ldap:user:*")
.map(&.as(String).split(":")[2].to_i)
.concat(Db::User.all.map(&.id.not_nil!.to_i))
.uniq!
.each do |id|
spawn do
CacheLdapUserJob.new(id).enqueue
end
end
Fiber.yield
end
end
end
end
end

View file

@ -21,14 +21,14 @@ module Backend
class SendTeachersRegistrationEmailJob < Mosquito::QueuedJob class SendTeachersRegistrationEmailJob < Mosquito::QueuedJob
# :ditto: # :ditto:
def perform : Nil def perform : Nil
users = Db::User.where(role: Db::UserRole::Teacher.to_s, teacher_id: nil) users = Db::User.query.where { (x.role == Db::UserRole::Teacher) & (x.teacher_id == nil) }
count = users.count.run.as(Int64).to_i count = users.count.to_i
channel = Channel(Nil).new(count) channel = Channel(Nil).new(count)
users.each do |user| users.each do |user|
spawn do spawn do
fail unless user.role.teacher? fail unless user.role.to_api.teacher?
ldap_user = Ldap::User.from_username(user.username) ldap_user = Ldap::User.from_username(user.username)
log "Sending teacher registration email to #{ldap_user.email} ##{user.id}" log "Sending teacher registration email to #{ldap_user.email} ##{user.id}"

View file

@ -17,6 +17,8 @@
require "commander" require "commander"
require "../backend" require "../backend"
Backend::Db.init
cli = Commander::Command.new do |cmd| cli = Commander::Command.new do |cmd|
cmd.use = "backend" cmd.use = "backend"
cmd.short = "Mentorenwahl backend CLI" cmd.short = "Mentorenwahl backend CLI"
@ -70,6 +72,13 @@ cli = Commander::Command.new do |cmd|
c.short = "Seeds the database with required data" c.short = "Seeds the database with required data"
c.long = c.short 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| c.flags.add do |f|
f.name = "admin" f.name = "admin"
f.long = "--admin" f.long = "--admin"
@ -77,22 +86,26 @@ cli = Commander::Command.new do |cmd|
f.description = "Register as admin" f.description = "Register as admin"
end 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| c.run do |opts, args|
username = args[0] username = args[0]
role = Backend::Db::UserRole.parse(args[1].downcase) role = Backend::Db::UserRole.from_string(args[1].underscore)
print "Register '#{username}' as '#{role}'#{opts.bool["admin"] ? " with admin privileges" : nil}? [y/N] " unless opts.bool["yes"]
abort unless (gets(chomp: true) || "").strip.downcase == "y" 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 = Backend::Db::User.create!(username: username, role: role.to_s, admin: opts.bool["admin"]) user = Backend::Db::User.create!(username: username, role: role.to_s, skif: opts.bool["skif"], admin: opts.bool["admin"])
Backend::Worker::Jobs::CacheLdapUserJob.new(user.id.not_nil!.to_i).enqueue Backend::Worker::Jobs::CacheLdapUserJob.new(user.id).enqueue
puts "Done!" puts "Done!"
puts "---"
puts "User: #{user.id}"
puts "Role: #{user.role}"
puts "Admin: #{user.admin}"
puts "---"
end end
end end
end end

View file

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

View file

@ -1,21 +0,0 @@
# 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/>.
require "micrate"
require "pg"
Micrate::DB.connection_url = ENV["BACKEND_DB_URL"]?
Micrate::Cli.run

View file

@ -11,3 +11,4 @@ Dockerfile
.dockerignore .dockerignore
.gitignore .gitignore
README.md README.md
.nvmrc

1
docker/frontend/.nvmrc Normal file
View file

@ -0,0 +1 @@
16.14.2

View file

@ -16,4 +16,4 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
docker-compose exec backend ./bin/backend "$@" docker-compose exec backend backend "$@"

View file

@ -16,4 +16,4 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
docker-compose exec backend ./bin/micrate "$@" docker-compose exec backend clear "$@"