diff --git a/.example.env b/.example.env index 5591344..2aed8e7 100644 --- a/.example.env +++ b/.example.env @@ -37,6 +37,7 @@ BACKEND_SMTP_NAME= BACKEND_SMTP_USERNAME= BACKEND_SMTP_PASSWORD= # Backend - Db +BACKEND_DB_ALLOW_OLD_SCHEMA=false # Backend - LDAP BACKEND_LDAP_HOST="ldap.example.com" 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_BIND_DN="cn=admin,dc=ldap,dc=example,dc=com" BACKEND_LDAP_BIND_PASSWORD= -BACKEND_LDAP_CACHE_REFRESH_INTERVAL=60 +BACKEND_LDAP_CACHE_REFRESH_INTERVAL=720 diff --git a/docker-compose.yml b/docker-compose.yml index 3632aa6..e524460 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,6 +88,7 @@ services: BACKEND_SMTP_USERNAME: ${BACKEND_SMTP_USERNAME} BACKEND_SMTP_PASSWORD: ${BACKEND_SMTP_PASSWORD} 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_PORT: 6379 BACKEND_LDAP_HOST: ${BACKEND_LDAP_HOST} diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index e59a176..8363686 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -43,7 +43,6 @@ RUN if [ "${BUILD_ENV}" = "development" ]; then \ FROM scratch as runner COPY --from=builder /src/bin /bin -COPY ./db /db EXPOSE 80 ENTRYPOINT [ "backend" ] CMD [ "run" ] diff --git a/docker/backend/Makefile b/docker/backend/Makefile index 1b0388a..24f9f2c 100644 --- a/docker/backend/Makefile +++ b/docker/backend/Makefile @@ -22,7 +22,7 @@ dev: shards build -Ddevelopment --static --verbose -s -p -t prod: - shards build --static --release --no-debug --verbose -s -p -t + shards build --static --release --verbose -s -p -t docs: crystal docs --project-name "Mentorenwahl Backend" -D granite_docs diff --git a/docker/backend/db/migrations/20220205143534_init.sql b/docker/backend/db/migrations/20220205143534_init.sql deleted file mode 100644 index f0d53e5..0000000 --- a/docker/backend/db/migrations/20220205143534_init.sql +++ /dev/null @@ -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 . - */ --- +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; \ No newline at end of file diff --git a/docker/backend/shard.lock b/docker/backend/shard.lock index f5fa21b..5cf043e 100644 --- a/docker/backend/shard.lock +++ b/docker/backend/shard.lock @@ -1,5 +1,9 @@ version: 2.0 shards: + admiral: + git: https://github.com/jwaldrip/admiral.cr.git + version: 1.12.1 + athena: git: https://github.com/athena-framework/framework.git version: 0.16.0 @@ -40,13 +44,17 @@ shards: git: https://github.com/spider-gazelle/bindata.git version: 1.10.0 + clear: + git: https://github.com/vici37/clear.git + version: 0.9+git.commit.2139d151d966b1119fd75c97d3b4d40a269592b9 + commander: git: https://github.com/mrrooijen/commander.git version: 0.4.0 db: git: https://github.com/crystal-lang/crystal-db.git - version: 0.10.1 + version: 0.11.0 email: git: https://github.com/arcage/crystal-email.git @@ -56,18 +64,22 @@ shards: git: https://github.com/repomaa/env_config.cr.git version: 0.1.0+git.commit.a3ef5b955f27e2c65de2fe0ff41718e2eea7c06f - granite: - git: https://github.com/amberframework/granite.git - version: 0.23.0 + generate: + git: https://github.com/anykeyh/generate.cr.git + version: 0.1.0+git.commit.f5dafc934a70e0ee2f246dddf3df44686f844da2 graphql: git: https://github.com/graphql-crystal/graphql.git - version: 0.3.2+git.commit.f49615eb286e90cfa9041107706a50d2c95e988d + version: 0.4.0 habitat: git: https://github.com/luckyframework/habitat.git version: 0.4.7 + inflector: + git: https://github.com/anykeyh/inflector.cr.git + version: 0.1.8+git.commit.dc5c898b0a834617d8b3ff73ac5a2239bd9fc019 + jwt: git: https://github.com/crystal-community/jwt.git version: 1.6.0 @@ -84,10 +96,6 @@ shards: git: https://git.dergrimm.net/dergrimm/ldap_escape.git version: 0.1.0 - micrate: - git: https://github.com/juanedi/micrate.git - version: 0.12.0 - mosquito: git: https://github.com/mosquito-cr/mosquito.git version: 1.0.0.rc1+git.commit.afd53dd241447b60ece9232b6c71669b192baaa4 @@ -98,7 +106,7 @@ shards: pg: git: https://github.com/will/crystal-pg.git - version: 0.25.0 + version: 0.26.0 pool: git: https://github.com/ysbaddaden/pool.git diff --git a/docker/backend/shard.yml b/docker/backend/shard.yml index 89402b8..b910d60 100644 --- a/docker/backend/shard.yml +++ b/docker/backend/shard.yml @@ -25,25 +25,21 @@ license: GNU GPLv3 targets: backend: main: src/cli/backend.cr - micrate: - main: src/cli/micrate.cr + clear: + main: src/cli/clear.cr crystal: 1.3.2 dependencies: - granite: - github: amberframework/granite - pg: - github: will/crystal-pg + clear: + github: vici37/clear + branch: master graphql: github: graphql-crystal/graphql - branch: main jwt: github: crystal-community/jwt commander: github: mrrooijen/commander - micrate: - github: juanedi/micrate mosquito: github: mosquito-cr/mosquito branch: master diff --git a/docker/backend/src/backend/api/context.cr b/docker/backend/src/backend/api/context.cr index 5ccd524..1c714c5 100644 --- a/docker/backend/src/backend/api/context.cr +++ b/docker/backend/src/backend/api/context.cr @@ -89,13 +89,15 @@ module Backend return false unless authenticated? roles.each do |role| - return true if @role == role && if external_check - role == case @external.not_nil! - when Db::Teacher - Schema::UserRole::Teacher - when Db::Student - Schema::UserRole::Student - end + return true if @role == role && + if external_check + role == + case @external.not_nil! + when Db::Teacher + Schema::UserRole::Teacher + when Db::Student + Schema::UserRole::Student + end else true end @@ -130,6 +132,8 @@ module Backend def student!(external_check = true) : Bool role! external_check, Schema::UserRole::Student end + + # TODO: Custom error handler end end end diff --git a/docker/backend/src/backend/api/schema/helpers.cr b/docker/backend/src/backend/api/schema/helpers.cr index 7d29ba9..46d8ea7 100644 --- a/docker/backend/src/backend/api/schema/helpers.cr +++ b/docker/backend/src/backend/api/schema/helpers.cr @@ -19,25 +19,13 @@ module Backend module Schema # Schema helper macros 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 macro db_object(type) - private property model - def initialize(@model : {{ type }}) end def self.from_id(id : Int32) : self - new({{ type }}.find!(id)) + new({{ type }}.query.find! { var(:id) == id }) end {% space_name = type.names.last.underscore.gsub(/_/, " ").capitalize %} @@ -45,7 +33,7 @@ module Backend @[GraphQL::Field] # {{ space_name }}'s ID def id : Int32 - @model.id.not_nil!.to_i + @model.id end end end diff --git a/docker/backend/src/backend/api/schema/mutation.cr b/docker/backend/src/backend/api/schema/mutation.cr index 7fe2992..d9e4f89 100644 --- a/docker/backend/src/backend/api/schema/mutation.cr +++ b/docker/backend/src/backend/api/schema/mutation.cr @@ -26,7 +26,7 @@ module Backend def login(username : String, password : String) : LoginPayload 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) LoginPayload.new( @@ -48,7 +48,7 @@ module Backend rescue LDAP::Client::AuthError true 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 User.new(user) @@ -60,7 +60,7 @@ module Backend context.admin! user = Db::User.find!(id) - user.destroy! + user.delete id end @@ -90,7 +90,7 @@ module Backend context.admin! teacher = Db::Teacher.find!(id) - teacher.destroy! + teacher.delete id end @@ -101,7 +101,7 @@ module Backend context.teacher! external_check: false 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 @@ -111,7 +111,7 @@ module Backend context.admin! 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.new(student) @@ -123,18 +123,18 @@ module Backend context.admin! student = Db::Student.find!(id) - student.destroy! + student.delete id end @[GraphQL::Field] # Self register as student - def register_student(context : Context, input : StudentInput) : Student + def register_student(context : Context) : Student context.student! external_check: false 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 @@ -144,18 +144,16 @@ module Backend context.student! 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) - raise "Teachers not registered" if teacher_role_count != Db::Teacher.count || - teacher_role_count.zero? + 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? - skif = context.external.as(Db::Student).skif input.teacher_ids.each do |id| teacher = Db::Teacher.find(id) if teacher.nil? raise "Teachers not found" - elsif teacher.skif != skif - if teacher.skif + elsif teacher.user.skif != context.user.not_nil!.skif + if teacher.user.skif raise "Teacher is SKIF, student is not" else raise "Teacher is not SKIF, student is" @@ -165,7 +163,7 @@ module Backend student = context.external.not_nil!.as(Db::Student) 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) end diff --git a/docker/backend/src/backend/api/schema/query.cr b/docker/backend/src/backend/api/schema/query.cr index fec8e58..01a7b6c 100644 --- a/docker/backend/src/backend/api/schema/query.cr +++ b/docker/backend/src/backend/api/schema/query.cr @@ -52,7 +52,7 @@ module Backend def users(context : Context) : Array(User) context.admin! - Db::User.all.map { |user| User.new(user) } + Db::User.query.map { |user| User.new(user) } end @[GraphQL::Field] @@ -60,7 +60,7 @@ module Backend def admins(context : Context) : Array(User) context.admin! - Db::User.where(admin: true).map { |user| User.new(user) } + Db::User.query.where(admin: true).map { |user| User.new(user) } end @[GraphQL::Field] @@ -72,7 +72,7 @@ module Backend @[GraphQL::Field] # All teachers def teachers : Array(Teacher) - Db::Teacher.all.map { |teacher| Teacher.new(teacher) } + Db::Teacher.query.map { |teacher| Teacher.new(teacher) } end @[GraphQL::Field] @@ -88,7 +88,7 @@ module Backend def students(context : Context) : Array(Student) context.admin! - Db::Student.all.map { |student| Student.new(student) } + Db::Student.query.map { |student| Student.new(student) } end @[GraphQL::Field] @@ -104,7 +104,7 @@ module Backend def votes(context : Context) : Array(Vote) context.admin! - Db::Vote.all.map { |vote| Vote.new(vote) } + Db::Vote.query.map { |vote| Vote.new(vote) } end @[GraphQL::Field] @@ -120,7 +120,7 @@ module Backend def teacher_votes(context : Context) : Array(TeacherVote) context.admin! - Db::TeacherVote.all.map { |vote| TeacherVote.new(vote) } + Db::TeacherVote.query.map { |vote| TeacherVote.new(vote) } end end end diff --git a/docker/backend/src/backend/api/schema/student.cr b/docker/backend/src/backend/api/schema/student.cr index 5dad9a7..60daa47 100644 --- a/docker/backend/src/backend/api/schema/student.cr +++ b/docker/backend/src/backend/api/schema/student.cr @@ -30,12 +30,6 @@ module Backend User.new(@model.user) end - @[GraphQL::Field] - # Student at SKIF - def skif : Bool - @model.skif - end - @[GraphQL::Field] # Student's vote def vote : Vote? @@ -43,26 +37,22 @@ module Backend end end - @[GraphQL::InputObject] - # Student base input - class StudentInput < GraphQL::BaseInputObject - # Student at SKIF - getter skif - - @[GraphQL::Field] - def initialize(@skif : Bool) - end - end + # @[GraphQL::InputObject] + # # Student base input + # class StudentInput < GraphQL::BaseInputObject + # @[GraphQL::Field] + # def initialize + # end + # end @[GraphQL::InputObject] # Student creation input - class StudentCreateInput < StudentInput + class StudentCreateInput < GraphQL::BaseInputObject # Student's user ID getter user_id @[GraphQL::Field] - def initialize(@user_id : Int32, skif : Bool) - super(skif) + def initialize(@user_id : Int32) end end end diff --git a/docker/backend/src/backend/api/schema/teacher.cr b/docker/backend/src/backend/api/schema/teacher.cr index 7b15bab..3c01e85 100644 --- a/docker/backend/src/backend/api/schema/teacher.cr +++ b/docker/backend/src/backend/api/schema/teacher.cr @@ -35,12 +35,6 @@ module Backend def max_students : Int32 @model.max_students end - - @[GraphQL::Field] - # Teacher is at SKIF - def skif : Bool - @model.skif - end end @[GraphQL::InputObject] @@ -49,11 +43,8 @@ module Backend # Teacher's max students getter max_students - # Teacher at SKIF - getter skif - @[GraphQL::Field] - def initialize(@max_students : Int32, @skif : Bool) + def initialize(@max_students : Int32) end end @@ -64,8 +55,8 @@ module Backend getter user_id @[GraphQL::Field] - def initialize(@user_id : Int32, max_students : Int32, skif : Bool) - super(max_students, skif) + def initialize(@user_id : Int32, max_students : Int32) + super(max_students) end end end diff --git a/docker/backend/src/backend/api/schema/user.cr b/docker/backend/src/backend/api/schema/user.cr index 7f09494..b68c113 100644 --- a/docker/backend/src/backend/api/schema/user.cr +++ b/docker/backend/src/backend/api/schema/user.cr @@ -25,12 +25,12 @@ module Backend # DB representation of the enum def to_db : Db::UserRole - Db::UserRole.parse(self.to_s) + Db::UserRole.from_string(self.to_s.underscore) end # GraphQL representation of the DB enum def self.from_db(role : Db::UserRole) : self - Db::UserRole.to_api + role.to_api end end @@ -42,29 +42,31 @@ module Backend db_object Db::User # 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 - def refresh_ldap : Ldap::User - (@ldap ||= Ldap::User.from_json(Redis::CLIENT.get("ldap:user:#{id}").as(String))).not_nil! + (@ldap ||= Ldap::User.from_json(raw_cache.not_nil!)).not_nil! end @[GraphQL::Field] # User's first name def first_name : String - refresh_ldap.first_name + ldap.first_name end @[GraphQL::Field] # User's last name def last_name : String - refresh_ldap.last_name + ldap.last_name end @[GraphQL::Field] # User's full name def name : String - refresh_ldap.name + ldap.name end @[GraphQL::Field] @@ -76,7 +78,7 @@ module Backend @[GraphQL::Field] # User's email def email : String - refresh_ldap.email + ldap.email end @[GraphQL::Field] @@ -91,10 +93,16 @@ module Backend @model.role.to_api end + @[GraphQL::Field] + # User is at SKIF + def skif : Bool + @model.skif + end + @[GraphQL::Field] # User's external ID def external_id : Int32? - case @model.role + case @model.role.to_api when .teacher? @model.teacher when .student? @@ -127,12 +135,14 @@ module Backend class UserCreateInput < GraphQL::BaseInputObject getter username getter role + getter skif getter admin @[GraphQL::Field] def initialize( @username : String, @role : UserRole, + @skif : Bool, @admin : Bool = false ) end diff --git a/docker/backend/src/backend/config.cr b/docker/backend/src/backend/config.cr index 77d9da9..d6803ae 100644 --- a/docker/backend/src/backend/config.cr +++ b/docker/backend/src/backend/config.cr @@ -132,6 +132,9 @@ module Backend # Database URL getter url : String + + # Allow old database migrations to be used + getter allow_old_schema : Bool end # Configuration for `REDIS` diff --git a/docker/backend/src/backend/db.cr b/docker/backend/src/backend/db.cr index f4c2701..c81b8d4 100644 --- a/docker/backend/src/backend/db.cr +++ b/docker/backend/src/backend/db.cr @@ -14,8 +14,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -require "granite" -require "granite/adapter/pg" +require "clear" +require "log" +require "db" +require "retriable" require "./db/*" @@ -24,17 +26,23 @@ module Backend module Db 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 schema_up_to_date_compare : Int32? - migrations = Dir["db/migrations/*.sql"].map { |f| Path[f].basename }.sort! - return unless latest_migration = migrations.try(&.last.match(/\d+/).try(&.to_a.first.not_nil!.to_u64)) + def init(severity = {% if flag?(:development) %} ::Log::Severity::Debug {% else %} ::Log::Severity::Info {% end %}) : Nil + ::Log.builder.bind "clear.*", severity, ::Log::IOBackend.new + Retriable.retry(on: DB::ConnectionRefused, backoff: false) do + Clear::SQL.init(Backend.config.db.url) + end + end - begin - MicrateDbVersion.order(tstamp: :desc).limit(1).assembler.select.run.first.version_id <=> latest_migration - rescue PQ::PQError - nil + def schema_up_to_date? : Bool + last_migration = ClearMetadata.query.last!.value + + if last_migration == "-1" + false + else + last_migration == MIGRATIONS.last end end end diff --git a/docker/backend/src/backend/db/clear_metadata.cr b/docker/backend/src/backend/db/clear_metadata.cr new file mode 100644 index 0000000..973fdd5 --- /dev/null +++ b/docker/backend/src/backend/db/clear_metadata.cr @@ -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 diff --git a/docker/backend/src/backend/db/micrate_db_version.cr b/docker/backend/src/backend/db/micrate_db_version.cr index f319075..91d5c1e 100644 --- a/docker/backend/src/backend/db/micrate_db_version.cr +++ b/docker/backend/src/backend/db/micrate_db_version.cr @@ -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 . - module Backend module Db - # Micrate DB migrator model / configuration - class MicrateDbVersion < Granite::Base - table micrate_db_version + class MicrateDbVersion + include Clear::Model + self.table = :micrate_db_version + + primary_key type: :serial - column id : Int32, primary: true column version_id : Int64 column is_applied : Bool column tstamp : Time? diff --git a/docker/backend/src/backend/db/migrations.cr b/docker/backend/src/backend/db/migrations.cr new file mode 100644 index 0000000..1d57346 --- /dev/null +++ b/docker/backend/src/backend/db/migrations.cr @@ -0,0 +1,9 @@ +require "./migrations/*" + +module Backend + module Db + # DB SQL migrations + module Migration + end + end +end diff --git a/docker/backend/src/backend/db/migrations/1_create_users.cr b/docker/backend/src/backend/db/migrations/1_create_users.cr new file mode 100644 index 0000000..4b64460 --- /dev/null +++ b/docker/backend/src/backend/db/migrations/1_create_users.cr @@ -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 diff --git a/docker/backend/src/backend/db/student.cr b/docker/backend/src/backend/db/student.cr index 64ad955..78b98cd 100644 --- a/docker/backend/src/backend/db/student.cr +++ b/docker/backend/src/backend/db/student.cr @@ -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 . - module Backend module Db - # Student model - class Student < Granite::Base - table students + class Student + include Clear::Model + self.table = :students - belongs_to :user - has_one :vote + primary_key type: :serial - # Student's ID - column id : Int64, primary: true + belongs_to user : User - # Student is at SKIF - column skif : Bool + has_one vote : Vote?, foreign_key: :student_id end end end diff --git a/docker/backend/src/backend/db/teacher.cr b/docker/backend/src/backend/db/teacher.cr index 4eed0d5..d1ecf61 100644 --- a/docker/backend/src/backend/db/teacher.cr +++ b/docker/backend/src/backend/db/teacher.cr @@ -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 . - module Backend module Db - # Teacher model - class Teacher < Granite::Base - table teachers + class Teacher + include Clear::Model + self.table = :teachers - belongs_to :user - has_many teacher_votes : TeacherVote + primary_key type: :serial - # Teacher's ID - column id : Int64, primary: true + belongs_to user : User - # Teacher's max students count column max_students : Int32 - # Teacher is at SKIF - column skif : Bool + has_many teacher_votes : TeacherVote end end end diff --git a/docker/backend/src/backend/db/teacher_vote.cr b/docker/backend/src/backend/db/teacher_vote.cr index d0d9c3c..8464099 100644 --- a/docker/backend/src/backend/db/teacher_vote.cr +++ b/docker/backend/src/backend/db/teacher_vote.cr @@ -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 . - module Backend module Db - # Teacher vote model - class TeacherVote < Granite::Base - table teacher_votes + class TeacherVote + include Clear::Model + self.table = :teacher_votes - belongs_to :vote - belongs_to :teacher + primary_key type: :serial - # Teacher votes's ID - column id : Int64, primary: true + belongs_to vote : Vote + belongs_to teacher : Teacher - # Teacher vote's priority 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 diff --git a/docker/backend/src/backend/db/user.cr b/docker/backend/src/backend/db/user.cr index 949b1ce..8b9e45e 100644 --- a/docker/backend/src/backend/db/user.cr +++ b/docker/backend/src/backend/db/user.cr @@ -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 . - module Backend module Db - # Possible roles a user can have - enum UserRole - Teacher - Student + Clear.enum UserRole, :teacher, :student + struct UserRole # API representation of the enum def to_api : Api::Schema::UserRole Api::Schema::UserRole.parse(self.to_s) @@ -32,24 +14,19 @@ module Backend end end - # User model - class User < Granite::Base - table users + class User + include Clear::Model + self.table = :users - has_one :teacher - has_one :student + primary_key type: :serial - # User's ID - column id : Int64, primary: true - - # User's LDAP username column username : String - - # User's role - column role : UserRole, converter: Granite::Converters::Enum(Backend::Db::UserRole, String) - - # User is admin + column role : UserRole 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 diff --git a/docker/backend/src/backend/db/vote.cr b/docker/backend/src/backend/db/vote.cr index 6808ad8..9a30b99 100644 --- a/docker/backend/src/backend/db/vote.cr +++ b/docker/backend/src/backend/db/vote.cr @@ -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 . - module Backend module Db - class Vote < Granite::Base - table votes + class Vote + include Clear::Model + self.table = :votes - belongs_to :student - has_many teacher_votes : TeacherVote + primary_key type: :serial - column id : Int64, primary: true + belongs_to student : Student + + has_many teacher_votes : TeacherVote, foreign_key: :vote_id end end end diff --git a/docker/backend/src/backend/ldap/user.cr b/docker/backend/src/backend/ldap/user.cr index a5bb5e3..f4967ab 100644 --- a/docker/backend/src/backend/ldap/user.cr +++ b/docker/backend/src/backend/ldap/user.cr @@ -77,7 +77,7 @@ module Backend # Creates user data from DB entry index 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 diff --git a/docker/backend/src/backend/macros/migrations.cr b/docker/backend/src/backend/macros/migrations.cr new file mode 100644 index 0000000..7625bfc --- /dev/null +++ b/docker/backend/src/backend/macros/migrations.cr @@ -0,0 +1 @@ +print Dir[ARGV[0]].map { |f| File.basename(f).split("_", 2).first }.uniq!.sort!.join("\n") diff --git a/docker/backend/src/backend/runner.cr b/docker/backend/src/backend/runner.cr index 87cf9c6..c43e11f 100644 --- a/docker/backend/src/backend/runner.cr +++ b/docker/backend/src/backend/runner.cr @@ -17,6 +17,7 @@ require "service" require "log" require "retriable" +require "clear" module Backend # Backend runner @@ -44,18 +45,22 @@ module Backend Log.info { "Checking if DB schema is up to date..." } Retriable.retry(backoff: false, base_interval: 10.seconds, multiplier: 1.0) do - case Retriable.retry(on: DB::ConnectionRefused, backoff: false) do - Db.schema_up_to_date_compare - end - when nil - Log.fatal { "No database schema is applied. Please run `bash scripts/micrate.sh up` urgently!" } - raise Exception.new - when -1 - Log.warn { "Database schema is not up to date. Please run `bash scripts/micrate.sh up`." } - when 0 + ex : Clear::SQL::Error? = nil + if begin + Db.schema_up_to_date? + rescue exc : Clear::SQL::Error + ex = exc + + false + end && ex.nil? Log.info { "Database schema is up to date." } 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 diff --git a/docker/backend/src/backend/web/controllers/api_controller.cr b/docker/backend/src/backend/web/controllers/api_controller.cr index c1ecf5b..a8455a3 100644 --- a/docker/backend/src/backend/web/controllers/api_controller.cr +++ b/docker/backend/src/backend/web/controllers/api_controller.cr @@ -39,14 +39,29 @@ module Backend {% 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("")] @[ATHA::QueryParam("development")] - @[ATHA::ParamConverter("query", converter: ATH::RequestBodyConverter)] - def endpoint(query : Backend::Web::GraphQLQueryData, request : ATH::Request, development : Bool = false) : ATH::Response + def endpoint(request : ATH::Request, development : Bool = false) : ATH::Response {% if flag?(:development) %} Log.notice { "Development request icoming" } if development {% end %} + query = GraphQLQueryData.from_json(request.body.not_nil!) + ATH::StreamedResponse.new(headers: HTTP::Headers{"content-type" => "application/json"}) do |io| Api::Schema::SCHEMA.execute( io, diff --git a/docker/backend/src/backend/web/graphql_query_data.cr b/docker/backend/src/backend/web/graphql_query_data.cr deleted file mode 100644 index d749ae6..0000000 --- a/docker/backend/src/backend/web/graphql_query_data.cr +++ /dev/null @@ -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 . - -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 diff --git a/docker/backend/src/backend/worker/jobs/cache_ldap_user_job.cr b/docker/backend/src/backend/worker/jobs/cache_ldap_user_job.cr index 2a2b8d9..73afcce 100644 --- a/docker/backend/src/backend/worker/jobs/cache_ldap_user_job.cr +++ b/docker/backend/src/backend/worker/jobs/cache_ldap_user_job.cr @@ -24,11 +24,11 @@ module Backend # :ditto: def perform : Nil key = "ldap:user:#{id}" - user = Db::User.find(id) + user = Db::User.find!(id) if user log "Caching user ##{id}..." 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 log "User ##{id} not found. Deleting cache..." Redis::CLIENT.del(key) diff --git a/docker/backend/src/backend/worker/jobs/cache_ldap_users_job.cr b/docker/backend/src/backend/worker/jobs/cache_ldap_users_job.cr deleted file mode 100644 index 2562e77..0000000 --- a/docker/backend/src/backend/worker/jobs/cache_ldap_users_job.cr +++ /dev/null @@ -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 . - -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 diff --git a/docker/backend/src/backend/worker/jobs/send_teachers_registration_email_job.cr b/docker/backend/src/backend/worker/jobs/send_teachers_registration_email_job.cr index 6545f36..21508c4 100644 --- a/docker/backend/src/backend/worker/jobs/send_teachers_registration_email_job.cr +++ b/docker/backend/src/backend/worker/jobs/send_teachers_registration_email_job.cr @@ -21,14 +21,14 @@ module Backend class SendTeachersRegistrationEmailJob < Mosquito::QueuedJob # :ditto: def perform : Nil - users = Db::User.where(role: Db::UserRole::Teacher.to_s, teacher_id: nil) - count = users.count.run.as(Int64).to_i + users = Db::User.query.where { (x.role == Db::UserRole::Teacher) & (x.teacher_id == nil) } + count = users.count.to_i channel = Channel(Nil).new(count) users.each do |user| spawn do - fail unless user.role.teacher? + fail unless user.role.to_api.teacher? ldap_user = Ldap::User.from_username(user.username) log "Sending teacher registration email to #{ldap_user.email} ##{user.id}" diff --git a/docker/backend/src/cli/backend.cr b/docker/backend/src/cli/backend.cr index be59c09..b041659 100644 --- a/docker/backend/src/cli/backend.cr +++ b/docker/backend/src/cli/backend.cr @@ -17,6 +17,8 @@ require "commander" require "../backend" +Backend::Db.init + cli = Commander::Command.new do |cmd| cmd.use = "backend" cmd.short = "Mentorenwahl backend CLI" @@ -70,6 +72,13 @@ cli = Commander::Command.new do |cmd| 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" @@ -77,22 +86,26 @@ cli = Commander::Command.new do |cmd| 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 = Backend::Db::UserRole.parse(args[1].downcase) - print "Register '#{username}' as '#{role}'#{opts.bool["admin"] ? " with admin privileges" : nil}? [y/N] " - abort unless (gets(chomp: true) || "").strip.downcase == "y" + role = Backend::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 = Backend::Db::User.create!(username: username, role: role.to_s, admin: opts.bool["admin"]) - Backend::Worker::Jobs::CacheLdapUserJob.new(user.id.not_nil!.to_i).enqueue + 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).enqueue puts "Done!" - - puts "---" - puts "User: #{user.id}" - puts "Role: #{user.role}" - puts "Admin: #{user.admin}" - puts "---" end end end diff --git a/docker/backend/src/cli/clear.cr b/docker/backend/src/cli/clear.cr new file mode 100644 index 0000000..4c88f6e --- /dev/null +++ b/docker/backend/src/cli/clear.cr @@ -0,0 +1,7 @@ +require "clear" +require "../backend" +require "log" + +Backend::Db.init(Log::Severity::Debug) + +Clear::CLI.run(false) diff --git a/docker/backend/src/cli/micrate.cr b/docker/backend/src/cli/micrate.cr deleted file mode 100644 index 041d287..0000000 --- a/docker/backend/src/cli/micrate.cr +++ /dev/null @@ -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 . - -require "micrate" -require "pg" - -Micrate::DB.connection_url = ENV["BACKEND_DB_URL"]? -Micrate::Cli.run diff --git a/docker/frontend/.dockerignore b/docker/frontend/.dockerignore index 229835d..da9c4db 100644 --- a/docker/frontend/.dockerignore +++ b/docker/frontend/.dockerignore @@ -11,3 +11,4 @@ Dockerfile .dockerignore .gitignore README.md +.nvmrc diff --git a/docker/frontend/.nvmrc b/docker/frontend/.nvmrc new file mode 100644 index 0000000..d9f8800 --- /dev/null +++ b/docker/frontend/.nvmrc @@ -0,0 +1 @@ +16.14.2 diff --git a/scripts/backend.sh b/scripts/backend.sh index b89a048..391f6a6 100644 --- a/scripts/backend.sh +++ b/scripts/backend.sh @@ -16,4 +16,4 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -docker-compose exec backend ./bin/backend "$@" +docker-compose exec backend backend "$@" diff --git a/scripts/micrate.sh b/scripts/clear.sh similarity index 94% rename from scripts/micrate.sh rename to scripts/clear.sh index f958f6b..08b3d57 100644 --- a/scripts/micrate.sh +++ b/scripts/clear.sh @@ -16,4 +16,4 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -docker-compose exec backend ./bin/micrate "$@" +docker-compose exec backend clear "$@"