Update ci
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Dominic Grimm 2022-11-21 19:48:53 +01:00
parent 55afb91ece
commit 100f7c8ad6
No known key found for this signature in database
GPG Key ID: 6F294212DEAAC530
52 changed files with 727 additions and 313 deletions

View File

@ -39,31 +39,35 @@ RUN shards install --production
FROM crystallang/crystal:1.6-alpine as builder
WORKDIR /usr/src/mentorenwahl
RUN apk add --no-cache pcre2-dev
RUN mkdir deps
COPY --from=deps /usr/src/mentorenwahl/shard.yml /usr/src/mentorenwahl/shard.lock ./
COPY --from=deps /usr/src/mentorenwahl/lib ./lib
COPY ./LICENSE .
COPY ./Makefile .
COPY ./db ./db
COPY ./src ./src
ARG BUILD_ENV
COPY ./Makefile .
COPY ./LICENSE .
COPY ./db ./db
COPY --from=public /usr/src/public/dist ./public
COPY ./src ./src
RUN if [ "${BUILD_ENV}" = "development" ]; then \
make dev; \
else \
make; \
fi
RUN mkdir deps
RUN if [ "${BUILD_ENV}" = "development" ]; then \
ldd bin/backend | tr -s '[:blank:]' '\n' | grep '^/' | \
xargs -I % sh -c 'mkdir -p $(dirname deps%); cp % deps%;'; \
fi
FROM scratch as runner
LABEL maintainer="Dominic Grimm <dominic@dergrimm.net>" \
org.opencontainers.image.description="Backend of Mentorenwahl" \
org.opencontainers.image.licenses="GNU GPLv3" \
org.opencontainers.image.source="https://git.dergrimm.net/mentorenwahl/mentorenwahl" \
org.opencontainers.image.url="https://git.dergrimm.net/mentorenwahl/mentorenwahl"
WORKDIR /
COPY --from=micrate-builder /usr/src/micrate/bin/micrate ./bin/micrate
COPY --from=builder /usr/src/mentorenwahl/db ./db
COPY --from=builder /usr/src/mentorenwahl/deps /
COPY --from=builder /usr/src/mentorenwahl/bin/backend ./bin/backend
COPY --from=builder /usr/src/mentorenwahl/db ./db
EXPOSE 80
ENTRYPOINT [ "./bin/backend" ]
CMD [ "run" ]
CMD [ "./bin/backend", "run" ]

View File

@ -30,6 +30,7 @@ CREATE TABLE tokens(
id uuid PRIMARY KEY,
iat timestamp NOT NULL,
exp timestamp NOT NULL,
active boolean NOT NULL,
user_id int NOT NULL REFERENCES users(id)
);

View File

@ -18,6 +18,7 @@ require "graphql"
require "http/headers"
require "jwt/errors"
require "json"
require "uuid"
module Backend
module Api
@ -41,13 +42,17 @@ module Backend
# User's external object
getter external
# JTI of request token
getter jti
def initialize(
@development : Bool,
@status : Status,
@user : Db::User?,
@admin : Bool?,
@role : Schema::UserRole?,
@external : (Db::Teacher | Db::Student)?
@external : (Db::Teacher | Db::Student)?,
@jti : UUID?
)
end
@ -63,15 +68,20 @@ module Backend
@status = Status::JWTError
else
if @user = Db::User.find(payload.context.user)
@admin = user.not_nil!.admin
@role = user.not_nil!.role.to_api
@external =
case @role.not_nil!
when .teacher?
@user.not_nil!.teacher
when .student?
@user.not_nil!.student
end
@jti = payload.jti
if Db::Token.query.where { (id == payload.jti) & active }.first.nil?
@status = Status::SessionExpired
else
@admin = user.not_nil!.admin
@role = user.not_nil!.role.to_api
@external =
case @role.not_nil!
when .teacher?
@user.not_nil!.teacher
when .student?
@user.not_nil!.student
end
end
end
end
end

View File

@ -20,7 +20,7 @@ module Backend
# Schema helper macros
module Helpers
# Defines DB model field helper functions
macro db_object(type)
macro db_object(type, id_type)
def initialize(@model : {{ type }})
end
@ -32,7 +32,7 @@ module Backend
@[GraphQL::Field]
# {{ space_name }}'s ID
def id : Int32
def id : {{ id_type }}
@model.id
end
end

View File

@ -36,9 +36,9 @@ module Backend
id: UUID.random(Random::Secure),
iat: Time.utc,
exp: Time.utc + Backend.config.api.jwt_expiration.minutes,
active: true,
user_id: user.id
)
pp! token, typeof(token.id)
LoginPayload.new(
user: User.new(user),
@ -51,6 +51,29 @@ module Backend
)
end
@[GraphQL::Field]
# Logs out of account by revoking token
def logout(context : Context) : Scalars::UUID?
context.authenticated!
jti = context.jti.not_nil!
token = Db::Token.find!(jti)
token.active = false
token.save!
Scalars::UUID.new(jti)
end
@[GraphQL::Field]
def revoke_token(context : Context, token : Scalars::UUID) : Scalars::UUID
context.authenticated!
Db::Token.query.find!(token.value).delete
Scalars::UUID.new(token.value)
end
@[GraphQL::Field]
# Creates user
def create_user(context : Context, input : UserCreateInput, check_ldap : Bool = true) : User?

View File

@ -39,6 +39,16 @@ module Backend
User.new(context.user.not_nil!)
end
@[GraphQL::Field]
# Active tokens of the current user
def tokens(context : Context) : Array(Token)?
context.authenticated!
now = Time.utc
Db::Token.query.where { (user_id == context.user.not_nil!.id) & (exp > now) & active }.map { |t| Token.new(t) }
end
@[GraphQL::Field]
# User by ID
def user(context : Context, id : Int32) : User?

View File

@ -0,0 +1 @@
require "./scalars/*"

View File

@ -0,0 +1,17 @@
module Backend::Api::Schema::Scalars
@[GraphQL::Scalar]
class Time < GraphQL::BaseScalar
property value
def initialize(@value : ::Time)
end
def self.from_json(string_or_io)
self.new(::Time.parse_iso8601(string_or_io))
end
def to_json(builder : JSON::Builder)
builder.scalar(@value.to_rfc3339)
end
end
end

View File

@ -0,0 +1,19 @@
require "uuid"
module Backend::Api::Schema::Scalars
@[GraphQL::Scalar]
class UUID < GraphQL::BaseScalar
property value
def initialize(@value : ::UUID)
end
def self.from_json(string_or_io)
self.new(::UUID.from_json(string_or_io))
end
def to_json(builder : JSON::Builder)
builder.scalar(@value.to_s)
end
end
end

View File

@ -22,7 +22,7 @@ module Backend
class Student < GraphQL::BaseObject
include Helpers
db_object Db::Student
db_object Db::Student, Int32
@[GraphQL::Field]
# Student's user

View File

@ -22,7 +22,7 @@ module Backend
class Teacher < GraphQL::BaseObject
include Helpers
db_object Db::Teacher
db_object Db::Teacher, Int32
@[GraphQL::Field]
# Teacher's user

View File

@ -22,7 +22,7 @@ module Backend
class TeacherVote < GraphQL::BaseObject
include Helpers
db_object Db::TeacherVote
db_object Db::TeacherVote, Int32
@[GraphQL::Field]
# Voted teacher

View File

@ -0,0 +1,34 @@
module Backend::Api::Schema
@[GraphQL::Object]
# Token model
class Token < GraphQL::BaseObject
include Helpers
def initialize(@model : Db::Token)
end
@[GraphQL::Field]
# Token ID / JTI
def id : Scalars::UUID
Scalars::UUID.new(@model.id)
end
@[GraphQL::Field]
# Time the token was issued at
def iat : Scalars::Time
Scalars::Time.new(@model.iat)
end
@[GraphQL::Field]
# Time for the token to expire
def exp : Scalars::Time
Scalars::Time.new(@model.exp)
end
@[GraphQL::Field]
# Token is expired
def expired : Bool
@model.exp > Time.utc
end
end
end

View File

@ -44,7 +44,7 @@ module Backend
class User < GraphQL::BaseObject
include Helpers
db_object Db::User
db_object Db::User, Int32
@ldap : Ldap::User?

View File

@ -22,7 +22,7 @@ module Backend
class Vote < GraphQL::BaseObject
include Helpers
db_object Db::Vote
db_object Db::Vote, Int32
@[GraphQL::Field]
# Student who voted

View File

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

View File

@ -1,14 +1,12 @@
module Backend
module Db
class MicrateDbVersion
include Clear::Model
self.table = :micrate_db_version
module Backend::Db
class MicrateDbVersion
include Clear::Model
self.table = :micrate_db_version
primary_key type: :serial
primary_key type: :serial
column version_id : Int64
column is_applied : Bool
column tstamp : Time?
end
column version_id : Int64
column is_applied : Bool
column tstamp : Time?
end
end

View File

@ -1,15 +1,13 @@
module Backend
module Db
class Student
include Clear::Model
self.table = :students
module Backend::Db
class Student
include Clear::Model
self.table = :students
primary_key type: :serial
primary_key type: :serial
belongs_to user : User
belongs_to user : User
has_one vote : Vote?, foreign_key: :student_id
has_one assignment : Assignment?, foreign_key: :student_id
end
has_one vote : Vote?, foreign_key: :student_id
has_one assignment : Assignment?, foreign_key: :student_id
end
end

View File

@ -1,17 +1,15 @@
module Backend
module Db
class Teacher
include Clear::Model
self.table = :teachers
module Backend::Db
class Teacher
include Clear::Model
self.table = :teachers
primary_key type: :serial
primary_key type: :serial
belongs_to user : User
belongs_to user : User
column max_students : Int32
column max_students : Int32
has_many teacher_votes : TeacherVote, foreign_key: :teacher_id
has_many assignments : Assignment, foreign_key: :teacher_id
end
has_many teacher_votes : TeacherVote, foreign_key: :teacher_id
has_many assignments : Assignment, foreign_key: :teacher_id
end
end

View File

@ -1,15 +1,13 @@
module Backend
module Db
class TeacherVote
include Clear::Model
self.table = :teacher_votes
module Backend::Db
class TeacherVote
include Clear::Model
self.table = :teacher_votes
primary_key type: :serial
primary_key type: :serial
belongs_to vote : Vote
belongs_to teacher : Teacher
belongs_to vote : Vote
belongs_to teacher : Teacher
column priority : Int32
end
column priority : Int32
end
end

View File

@ -7,6 +7,7 @@ module Backend::Db
column iat : Time
column exp : Time
column active : Bool
belongs_to user : User
end

View File

@ -1,40 +1,38 @@
module Backend
module Db
Clear.enum UserRole, :teacher, :student
module Backend::Db
Clear.enum UserRole, :teacher, :student
struct UserRole
# API representation of the enum
def to_api : Api::Schema::UserRole
case self
when Student
Api::Schema::UserRole::Student
when Teacher
Api::Schema::UserRole::Teacher
else
raise "Invalid enum value for UserRole"
end
end
# DB representation of the enum
def self.from_api(role : Api::Schema::UserRole) : self
role.to_db
struct UserRole
# API representation of the enum
def to_api : Api::Schema::UserRole
case self
when Student
Api::Schema::UserRole::Student
when Teacher
Api::Schema::UserRole::Teacher
else
raise "Invalid enum value for UserRole"
end
end
class User
include Clear::Model
self.table = :users
primary_key type: :serial
column username : String
column role : UserRole
column admin : Bool = false
has_one student : Student?, foreign_key: :user_id
has_one teacher : Teacher?, foreign_key: :user_id
has_many tokens : Token, foreign_key: :user_id
# DB representation of the enum
def self.from_api(role : Api::Schema::UserRole) : self
role.to_db
end
end
class User
include Clear::Model
self.table = :users
primary_key type: :serial
column username : String
column role : UserRole
column admin : Bool = false
has_one student : Student?, foreign_key: :user_id
has_one teacher : Teacher?, foreign_key: :user_id
has_many tokens : Token, foreign_key: :user_id
end
end

View File

@ -1,14 +1,12 @@
module Backend
module Db
class Vote
include Clear::Model
self.table = :votes
module Backend::Db
class Vote
include Clear::Model
self.table = :votes
primary_key type: :serial
primary_key type: :serial
belongs_to student : Student
belongs_to student : Student
has_many teacher_votes : TeacherVote, foreign_key: :vote_id
end
has_many teacher_votes : TeacherVote, foreign_key: :vote_id
end
end

View File

@ -14,28 +14,27 @@
# 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?
# run_every 1.minute
def rescheduleable? : Bool
false
end
alias TeacherVote = {student: Int32, priority: Int32}
alias Assignment = {teacher: Int32, priority: Int32}
# :ditto:
def perform : Nil
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 teacher_count == 0
if teacher_count == 0
log "No teachers found, skipping assignment"
fail
elsif student_count == 0
@ -47,67 +46,123 @@ module Backend
elsif vote_count < student_count
log "Not all students voted, skipping assignment"
fail
elsif Db::Assignment.query.count > 0
log "Assignment has already run, skipping another assignment"
fail
end
# 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
teachers = Db::Teacher.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)")
raw("EXISTS (SELECT 1 FROM teacher_votes WHERE teacher_id = teachers.id)") &
(max_students > 0)
end
.with_vote(&.with_teacher_votes(&.order_by(:priority, :asc)))
# student_ids = students.map(&.id)
.with_teacher_votes
.to_a
vote_index = Hash.zip(teachers.map(&.id), [0] * teachers.size)
teacher_votes : Hash(Int32, Array(TeacherVote)) = Hash.zip(
teachers.map(&.id),
teachers.map do |t|
t.teacher_votes.map do |tv|
vote_index[t.id] += 1
# votes = Hash.zip(
# student_ids,
# students.map do |s|
# s.vote.not_nil!.teacher_votes.map(&.teacher_id)
# end,
# )
# pp! votes, student_ids
{
student: tv.vote.student.id,
priority: tv.priority,
}
end
end
)
teachers.sort_by! { |t| vote_index[t.id] }
random_votes = students.map do |s|
{
student: s.id,
teachers: s.vote.not_nil!.teacher_votes.map(&.teacher_id),
}
students = Db::Student.query
.with_vote(&.with_teacher_votes(&.order_by(priority: :asc)))
.to_a
student_ids = students.map(&.id)
votes = Hash.zip(
student_ids,
students.map do |s|
s.vote.not_nil!.teacher_votes
.to_a
.select { |tv| teacher_votes.has_key?(tv.teacher.id) }
.map do |tv|
{
teacher: tv.teacher.id,
teacher_max_students: tv.teacher.max_students,
}
end
end
)
best_assignment = {
assignment: {} of Int32 => Assignment,
score: Float32::INFINITY,
}
Backend.config.assignment_possibility_count.times.each do
assignment = {} of Int32 => Assignment
assignment_count = Hash.zip(teachers.map(&.id), [0] * teachers.size)
# teachers.each do |t|
# queue = Deque.new(teacher_votes[t.id].shuffle)
# count = 1
# while count < t.max_students
# break unless x = queue.shift?
# tv = x.not_nil!
# if assignment[tv[:student]]?.nil? || assignment[tv[:student]][:priority] <= tv[:priority]
# assignment[tv[:student]] = {teacher: t.id, priority: tv[:priority]}
# count += 1
# end
# end
# end
votes.to_a.shuffle.each do |s, tvs|
tvs.each_with_index do |tv, i|
if assignment[s]?.nil?
assignment_count[tv[:teacher]] += 1
assignment[s] = {teacher: tv[:teacher], priority: i}
elsif assignment_count[tv[:teacher]] < tv[:teacher_max_students]
assignment_count[assignment[s][:teacher]] -= 1
assignment_count[tv[:teacher]] += 1
assignment[s] = {teacher: tv[:teacher], priority: i}
end
end
end
pp! assignment, assignment_count
score = 0_f32
# positivity = 0
# assignment.each do |s, a|
# ratio = (vote_sizes[s] - a[:priority]) / vote_sizes[s]
# score += 2 ** ratio
# positivity += ratio > 0.5 ? 1 : -1
# end
assignment.each do |s, a|
size = votes[s].size
p! a[:priority], (votes[s].size - a[:priority]) / size
# score += 1 if ((votes[s].size - a[:priority]) / size) >= 0.5
score += a[:priority]
end
# full_score = score ** positivity
if score < best_assignment[:score]
best_assignment = {
assignment: assignment,
score: score,
}
end
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)
pp! best_assignment
# votes = random_votes.dup
# a = empty_assignment.clone
str = String.build do |str|
str << "===========================\n"
best_assignment[:assignment].each do |s, a|
str << "#{Db::Student.query.find!(s).user.username} : #{Db::Teacher.query.find!(a[:teacher]).user.username} (#{a[:priority]} / #{votes[s].size})\n"
end
str << "===========================\n"
end
pp! assignments
print str
end
end
end

View File

@ -15,9 +15,13 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
events {
worker_connections 1024;
}
http {
server_tokens off;
more_clear_headers Server;
server {
location / {
proxy_pass http://frontend/;

View File

@ -18,7 +18,7 @@ version: "3"
services:
nginx:
image: nginx:alpine
image: byjg/nginx-extras
restart: always
volumes:
- ./config/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
@ -58,7 +58,7 @@ services:
- redis:/data
backend:
image: mentorenwahl/backend
image: git.dergrimm.net/mentorenwahl/backend:latest
build:
context: ./backend
args:
@ -96,7 +96,7 @@ services:
BACKEND_LDAP_CACHE_REFRESH_INTERVAL: ${BACKEND_LDAP_CACHE_REFRESH_INTERVAL}
frontend:
image: mentorenwahl/frontend
image: git.dergrimm.net/mentorenwahl/frontend:latest
build:
context: ./frontend
restart: always

View File

@ -36,6 +36,11 @@ WORKDIR /usr/src/public
COPY --from=public /usr/src/public .
RUN find . -name "*.wasm" -type f | xargs -I % wasm-opt % -o % -O --intrinsic-lowering -Oz
FROM nginx:alpine as runner
FROM byjg/nginx-extras as runner
LABEL maintainer="Dominic Grimm <dominic@dergrimm.net>" \
org.opencontainers.image.description="Frontend of Mentorenwahl" \
org.opencontainers.image.licenses="GNU GPLv3" \
org.opencontainers.image.source="https://git.dergrimm.net/mentorenwahl/mentorenwahl" \
org.opencontainers.image.url="https://git.dergrimm.net/mentorenwahl/mentorenwahl"
COPY ./nginx.conf /etc/nginx/nginx.conf
COPY --from=binaryen /usr/src/public /var/www/html

View File

@ -0,0 +1,3 @@
mutation Logout {
logout
}

View File

@ -0,0 +1,3 @@
mutation RevokeToken($token: UUID!) {
revokeToken(token: $token)
}

View File

@ -0,0 +1,7 @@
query Tokens {
tokens {
id
iat
exp
}
}

View File

@ -15,6 +15,7 @@ type Query {
teacherVote(id: Int!): TeacherVote
teacherVotes: [TeacherVote!]
teachers: [Teacher!]!
tokens: [Token!]
user(id: Int!): User
userByUsername(username: String!): User
users: [User!]
@ -42,6 +43,17 @@ type TeacherVote {
vote: Vote!
}
scalar Time
type Token {
exp: Time!
expired: Boolean!
iat: Time!
id: UUID!
}
scalar UUID
type User {
admin: Boolean!
email: String!
@ -79,7 +91,9 @@ type Mutation {
createVote(input: VoteCreateInput!): Vote
deleteUser(id: Int!): Int
login(password: String!, username: String!): LoginPayload
logout: UUID
registerTeacher(input: TeacherInput!): Teacher
revokeToken(token: UUID!): UUID!
}
input TeacherInput {

View File

@ -1,29 +1,10 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
#gzip on;
server_tokens off;
more_clear_headers Server;
server {
listen 80;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/mutations/login.graphql",
response_derives = "Debug"
response_derives = "Debug",
skip_serializing_none
)]
pub struct Login;

View File

@ -0,0 +1,12 @@
use graphql_client::GraphQLQuery;
type UUID = String;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/mutations/logout.graphql",
response_derives = "Debug",
skip_serializing_none
)]
pub struct Logout;

View File

@ -1,3 +1,5 @@
pub mod login;
pub mod logout;
pub mod register_teacher;
pub mod revoke_token;
pub mod vote;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/mutations/register_teacher.graphql",
response_derives = "Debug"
response_derives = "Debug",
skip_serializing_none
)]
pub struct RegisterTeacher;

View File

@ -0,0 +1,12 @@
use graphql_client::GraphQLQuery;
type UUID = String;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/mutations/revoke_token.graphql",
response_derives = "Debug",
skip_serializing_none
)]
pub struct RevokeToken;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/mutations/vote.graphql",
response_derives = "Debug"
response_derives = "Debug",
skip_serializing_none
)]
pub struct Vote;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/config.graphql",
response_derives = "Debug"
response_derives = "Debug",
skip_serializing_none
)]
pub struct Config;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/me.graphql",
response_derives = "Debug"
response_derives = "Debug",
skip_serializing_none
)]
pub struct Me;

View File

@ -3,3 +3,4 @@ pub mod me;
pub mod ok;
pub mod students_can_vote;
pub mod teachers;
pub mod tokens;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/ok.graphql",
response_derives = "Debug"
response_derives = "Debug",
skip_serializing_none
)]
pub struct Ok;
pub struct Ok;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/students_can_vote.graphql",
response_derives = "Debug"
response_derives = "Debug",
skip_serializing_none
)]
pub struct StudentsCanVote;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/teachers.graphql",
response_derives = "Debug"
response_derives = "Debug",
skip_serializing_none
)]
pub struct Teachers;

View File

@ -0,0 +1,12 @@
use graphql_client::GraphQLQuery;
type UUID = String;
type Time = String;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/tokens.graphql",
response_derives = "Debug"
)]
pub struct Tokens;

View File

@ -1,17 +1,21 @@
use graphql_client::reqwest::post_graphql;
use yew::prelude::*;
use yew_agent::{Dispatched, Dispatcher};
use yew_router::prelude::*;
use crate::agents;
use crate::components;
use crate::graphql;
use crate::routes;
pub enum Msg {
LogOut,
LogOutClicked,
LogOut(Option<Vec<String>>),
}
#[derive(Properties, PartialEq)]
pub struct MainProps {
pub token: Option<String>,
pub logged_in: bool,
#[prop_or_default]
pub children: Children,
@ -20,6 +24,7 @@ pub struct MainProps {
pub struct Main {
logged_in: bool,
logged_in_event_bus: Dispatcher<agents::logged_in::EventBus>,
errors: Option<Vec<String>>,
}
impl Component for Main {
@ -30,50 +35,75 @@ impl Component for Main {
Self {
logged_in: ctx.props().logged_in,
logged_in_event_bus: agents::logged_in::EventBus::dispatcher(),
errors: None,
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::LogOut => {
self.logged_in_event_bus
.send(agents::logged_in::Request::LoggedIn(false));
Msg::LogOutClicked => {
let client = graphql::client(ctx.props().token.as_ref()).unwrap();
ctx.link().send_future(async move {
let response = post_graphql::<graphql::mutations::logout::Logout, _>(
&client,
graphql::URL.as_str(),
graphql::mutations::logout::logout::Variables,
)
.await
.unwrap();
Msg::LogOut(components::graphql_errors::convert(response.errors))
});
false
}
Msg::LogOut(errors) => {
if self.errors.is_none() {
self.logged_in_event_bus
.send(agents::logged_in::Request::LoggedIn(false));
false
} else {
self.errors = errors;
true
}
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let logout_onclick = ctx.link().callback(move |_| Msg::LogOut);
html! {
<>
<nav>
<ul>
<li>
<Link<routes::Route> to={routes::Route::Home}>
<button>{ "Home" }</button>
</Link<routes::Route>>
</li>
<li>
if self.logged_in {
<button onclick={logout_onclick}>{ "Logout" }</button>
} else {
<Link<routes::Route> to={routes::Route::Login}>
<button>{ "Login" }</button>
</Link<routes::Route>>
}
</li>
</ul>
</nav>
<>
<components::logged_in_handler::LoggedInHandler logged_in={self.logged_in} />
<nav>
<ul>
<li>
<Link<routes::Route> to={routes::Route::Home}>
<button>{ "Home" }</button>
</Link<routes::Route>>
</li>
<li>
<Link<routes::Route> to={routes::Route::Settings}>
<button>{ "Settings" }</button>
</Link<routes::Route>>
</li>
<li>
if self.logged_in {
<button onclick={ctx.link().callback(move |_| Msg::LogOutClicked)}>{ "Logout" }</button>
} else {
<Link<routes::Route> to={routes::Route::Login}>
<button>{ "Login" }</button>
</Link<routes::Route>>
}
</li>
</ul>
<hr />
<components::logged_in_handler::LoggedInHandler logged_in={self.logged_in} />
<main>
{ for ctx.props().children.iter() }
</main>
</>
<components::graphql_errors::GraphQLErrors errors={self.errors.clone()} />
</nav>
<hr />
<main>
{ for ctx.props().children.iter() }
</main>
</>
}
}
}

View File

@ -22,13 +22,8 @@ pub struct HomeProps {
pub token: Option<String>,
}
enum State {
Fetching,
Done,
}
pub struct Home {
state: State,
fetching: bool,
errors: Option<Vec<String>>,
me: Option<graphql::queries::me::me::MeMe>,
}
@ -37,9 +32,25 @@ impl Component for Home {
type Message = Msg;
type Properties = HomeProps;
fn create(_ctx: &Context<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
let client = graphql::client(ctx.props().token.as_ref()).unwrap();
ctx.link().send_future(async move {
let response = post_graphql::<graphql::queries::me::Me, _>(
&client,
graphql::URL.as_str(),
graphql::queries::me::me::Variables,
)
.await
.unwrap();
Msg::DoneFetching {
errors: components::graphql_errors::convert(response.errors),
me: response.data.unwrap().me.unwrap(),
}
});
Self {
state: State::Fetching,
fetching: true,
errors: None,
me: None,
}
@ -48,7 +59,7 @@ impl Component for Home {
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::DoneFetching { errors, me } => {
self.state = State::Done;
self.fetching = false;
self.errors = errors;
self.me = Some(me);
true
@ -60,62 +71,39 @@ impl Component for Home {
html! {
<>
<Title value="Home" />
{
match self.state {
State::Fetching => {
let client = graphql::client(ctx.props().token.as_ref()).unwrap();
ctx.link().send_future(async move {
let response = post_graphql::<graphql::queries::me::Me, _>(
&client,
graphql::URL.as_str(),
graphql::queries::me::me::Variables,
)
.await
.unwrap();
Msg::DoneFetching {
errors: components::graphql_errors::convert(response.errors),
me: response.data.unwrap().me.unwrap(),
}
});
if self.fetching {
<p>{ "Fetching..." }</p>
} else {
if let Some(errors) = &self.errors {
<components::graphql_errors::GraphQLErrors errors={errors.clone()} />
} else {
{{
let me = self.me.as_ref().unwrap();
html! {
<p>{ "Fetching..." }</p>
}
}
State::Done => {
if let Some(errors) = &self.errors {
html! {
<components::graphql_errors::GraphQLErrors errors={errors.clone()} />
}
} else {
let me = self.me.as_ref().unwrap();
html! {
<>
<h1>{ format!("Hey, {}!", me.first_name) }</h1>
<hr />
{
match me.role {
graphql::queries::me::me::UserRole::Student => html! {
<student_home::StudentHome
token={ctx.props().token.as_ref().unwrap().to_owned()}
voted={me.student.as_ref().unwrap().vote.is_some()}
/>
},
graphql::queries::me::me::UserRole::Teacher => html! {
<teacher_home::TeacherHome
token={ctx.props().token.as_ref().unwrap().to_owned()}
registered={me.teacher.is_some()}
/>
},
graphql::queries::me::me::UserRole::Other(_) => html! {}
}
<>
<h1>{ format!("Hey, {}!", me.first_name) }</h1>
<hr />
{
match me.role {
graphql::queries::me::me::UserRole::Student => html! {
<student_home::StudentHome
token={ctx.props().token.as_ref().unwrap().to_owned()}
voted={me.student.as_ref().unwrap().vote.is_some()}
/>
},
graphql::queries::me::me::UserRole::Teacher => html! {
<teacher_home::TeacherHome
token={ctx.props().token.as_ref().unwrap().to_owned()}
registered={me.teacher.is_some()}
/>
},
graphql::queries::me::me::UserRole::Other(_) => html! {}
}
</>
}
}
</>
}
}
}}
}
}
</>

View File

@ -231,10 +231,8 @@ impl Component for StudentVote {
<form {onsubmit}>
{ for (0..self.slots).map(|i| {
let curr_t = self.votes.get(&i);
if let Some(te) = curr_t {
if let Some(t) = te {
teachers.push(*t);
}
if let Some(Some(t)) = curr_t {
teachers.push(*t);
}
let name = format!("mentors_{}", i);

View File

@ -87,7 +87,7 @@ impl Component for TeacherRegistration {
<label for="max_students">
{ "Maximale Anzahl von Schülern:" }
<br />
<input ref={self.max_students.clone()} type="number" id="max_students" name="max_students" min=1 />
<input ref={self.max_students.clone()} type="number" id="max_students" name="max_students" min=0 />
</label>
</div>

View File

@ -7,11 +7,14 @@ use crate::layouts;
pub mod home;
pub mod login;
pub mod not_found;
pub mod settings;
#[derive(Clone, Routable, PartialEq, Eq)]
pub enum Route {
#[at("/")]
Home,
#[at("/settings")]
Settings,
#[at("/login")]
Login,
#[not_found]
@ -39,19 +42,28 @@ pub fn switch(routes: &Route) -> Html {
Route::Home => {
html! {
<layouts::logged_in::LoggedIn {logged_in}>
<layouts::main::Main {logged_in}>
<layouts::main::Main token={token.to_owned()} {logged_in}>
<home::Home {token} />
</layouts::main::Main>
</layouts::logged_in::LoggedIn>
}
}
Route::Settings => {
html! {
<layouts::logged_in::LoggedIn {logged_in}>
<layouts::main::Main token={token.to_owned()} {logged_in}>
<settings::Settings {token} />
</layouts::main::Main>
</layouts::logged_in::LoggedIn>
}
}
Route::Login => html! {
<layouts::main::Main {logged_in}>
<layouts::main::Main {token} {logged_in}>
<login::Login />
</layouts::main::Main>
},
Route::NotFound => html! {
<layouts::main::Main {logged_in}>
<layouts::main::Main {token} {logged_in}>
<not_found::NotFound />
</layouts::main::Main>
},

View File

@ -0,0 +1,31 @@
use yew::prelude::*;
use yew_side_effect::title::Title;
pub mod tokens;
#[derive(Properties, PartialEq, Eq)]
pub struct SettingsProps {
pub token: Option<String>,
}
pub struct Settings;
impl Component for Settings {
type Message = ();
type Properties = SettingsProps;
fn create(_ctx: &Context<Self>) -> Self {
Self
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<>
<Title value="Settings" />
<section>
<tokens::Tokens token={ctx.props().token.as_ref().unwrap().to_owned()} />
</section>
</>
}
}
}

View File

@ -0,0 +1,130 @@
use graphql_client::reqwest::post_graphql;
use yew::prelude::*;
use crate::components;
use crate::graphql;
pub enum Msg {
DoneFetching {
errors: Option<Vec<String>>,
tokens: Vec<graphql::queries::tokens::tokens::TokensTokens>,
},
Revoke(usize),
RevokeDone {
errors: Option<Vec<String>>,
id: usize,
},
}
#[derive(Properties, PartialEq, Eq)]
pub struct TokensProps {
pub token: String,
}
pub struct Tokens {
fetching: bool,
errors: Option<Vec<String>>,
tokens: Option<Vec<graphql::queries::tokens::tokens::TokensTokens>>,
}
impl Component for Tokens {
type Message = Msg;
type Properties = TokensProps;
fn create(ctx: &Context<Self>) -> Self {
let client = graphql::client(Some(&ctx.props().token)).unwrap();
ctx.link().send_future(async move {
let response = post_graphql::<graphql::queries::tokens::Tokens, _>(
&client,
graphql::URL.as_str(),
graphql::queries::tokens::tokens::Variables,
)
.await
.unwrap();
Msg::DoneFetching {
errors: None,
tokens: response.data.unwrap().tokens.unwrap(),
}
});
Self {
fetching: true,
errors: None,
tokens: None,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::DoneFetching { errors, tokens } => {
self.fetching = false;
self.errors = errors;
self.tokens = Some(tokens);
true
}
Msg::Revoke(id) => {
let client = graphql::client(Some(&ctx.props().token)).unwrap();
let token = self.tokens.as_ref().unwrap()[id].id.to_owned();
ctx.link().send_future(async move {
let response =
post_graphql::<graphql::mutations::revoke_token::RevokeToken, _>(
&client,
graphql::URL.as_str(),
graphql::mutations::revoke_token::revoke_token::Variables { token },
)
.await
.unwrap();
Msg::RevokeDone {
errors: components::graphql_errors::convert(response.errors),
id,
}
});
true
}
Msg::RevokeDone { errors, id } => {
self.errors = errors;
self.tokens.as_mut().unwrap().remove(id);
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<fieldset>
<legend>{ "Tokens" }</legend>
if self.fetching {
<p>{ "Fetching..." }</p>
} else {
<table>
<tr>
<th>{ "ID" }</th>
<th>{ "Issued at" }</th>
<th>{ "Expires at" }</th>
<th>{ "Revoke" }</th>
</tr>
{ for self.tokens.as_ref().unwrap().iter().enumerate().map(|(i, t)| {
html! {
<tr>
<td><code>{ &t.id }</code></td>
<td><code>{ &t.iat }</code></td>
<td><code>{ &t.exp }</code></td>
<td>
<button onclick={ctx.link().callback(move |_| Msg::Revoke(i))}>
{ "Revoke" }
</button>
</td>
</tr>
}
}) }
</table>
<components::graphql_errors::GraphQLErrors errors={self.errors.clone()} />
}
</fieldset>
}
}
}