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

This commit is contained in:
Dominic Grimm 2023-01-17 06:56:19 +01:00
parent 6620dea812
commit 8055a5e4db
No known key found for this signature in database
GPG Key ID: 6F294212DEAAC530
41 changed files with 829 additions and 290 deletions

View File

@ -19,10 +19,10 @@
all: prod
dev:
docker-compose build --build-arg BUILD_ENV=development
docker compose build --build-arg BUILD_ENV=development
prod:
docker-compose build
docker compose build
docs:
cd docs && mdbook build

View File

@ -54,12 +54,13 @@ CREATE TABLE teacher_votes(
id serial PRIMARY KEY,
vote_id int NOT NULL REFERENCES votes(id),
teacher_id int NOT NULL REFERENCES teachers(id),
priority int NOT NULL
priority int NOT NULL,
UNIQUE (vote_id, teacher_id, priority)
);
CREATE TABLE assignments(
id serial PRIMARY KEY,
student_id int NOT NULL REFERENCES students(id),
student_id int NOT NULL REFERENCES students(id) UNIQUE,
teacher_id int NOT NULL REFERENCES teachers(id)
);

View File

@ -70,7 +70,12 @@ module Backend
return ATH::Exceptions::BadRequest.new("No request body given") unless request.body
query = GraphQLQuery.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",
"cache-control" => ["no-cache", "no-store", "max-age=0", "must-revalidate"],
}
) do |io|
Api::Schema::SCHEMA.execute(
io,
query.query,

View File

@ -19,11 +19,13 @@ module Backend
module Jobs
# Assigns students to teachers when all students voted
class AssignmentJob < Mosquito::QueuedJob
# run_every 1.minute
alias TeacherVote = {student: Int32, priority: Int32}
alias Assignment = {teacher: Int32, priority: Int32}
def rescheduleable?
false
end
# :ditto:
def perform : Nil
teacher_count = Db::Teacher.query.count

View File

@ -30,7 +30,7 @@ services:
- frontend
db:
image: postgres:alpine
image: docker.io/postgres:alpine
restart: always
networks:
- db
@ -41,7 +41,7 @@ services:
- db:/var/lib/postgresql/data
adminer:
image: adminer:standalone
image: docker.io/adminer:standalone
restart: always
networks:
- default

View File

@ -10,10 +10,10 @@ opt-level = "z"
panic = "abort"
[dependencies]
yew = "0.19.3"
yew = { version = "0.20.0", features = ["csr"] }
wasm-logger = "0.2.0"
log = "0.4.6"
yew-router = "0.16.0"
yew-router = "0.17.0"
wee_alloc = "0.4.5"
graphql_client = { git = "https://github.com/graphql-rust/graphql-client.git", branch = "main", features = ["reqwest"] }
reqwest = "0.11.12"
@ -22,7 +22,7 @@ web-sys = { version = "0.3.60", features = ["Window", "Location"] }
wasm-cookies = "0.1.0"
lazy_static = "1.4.0"
const_format = "0.2.30"
yew-agent = "0.1.0"
yew-side-effect = "0.2.0"
uuid = { version = "1.2.2", features = ["serde"] }
chrono = { version = "0.4.23", features = ["serde"] }
yewdux = "0.9.0"
bounce = { version = "0.6.0", features = ["helmet"] }

View File

@ -0,0 +1,17 @@
query Students {
students {
id
user {
id
firstName
lastName
username
email
role
admin
}
vote {
id
}
}
}

View File

@ -0,0 +1,14 @@
query Teachers {
teachers {
id
user {
id
firstName
lastName
username
email
role
admin
}
}
}

View File

@ -1,7 +1,19 @@
"The `Boolean` scalar type represents `true` or `false`."
scalar Boolean
type Config {
minimumTeacherSelectionCount: Int!
}
"The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point)."
scalar Float
"The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"4\"`) or integer (such as `4`) input value will be accepted as an ID."
scalar ID
"The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1."
scalar Int
type Query {
admins: [User!]
allStudentsVoted: Boolean
@ -23,6 +35,9 @@ type Query {
votes: [Vote!]
}
"The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text."
scalar String
type Student {
id: Int!
user: User!
@ -79,6 +94,67 @@ type Vote {
teacherVotes: [TeacherVote!]!
}
type __Directive {
args: [__InputValue!]!
description: String
locations: [String!]!
name: String!
}
type __EnumValue {
deprecationReason: String
description: String
isDeprecated: Boolean!
name: String!
}
type __Field {
args: [__InputValue!]!
deprecationReason: String
description: String
isDeprecated: Boolean!
name: String!
type: __Type!
}
type __InputValue {
defaultValue: String
description: String
name: String!
type: __Type!
}
type __Schema {
directives: [__Directive!]!
mutationType: __Type
queryType: __Type!
subscriptionType: __Type
types: [__Type!]!
}
type __Type {
description: String
enumValues(includeDeprecated: Boolean! = false): [__EnumValue!]
fields(includeDeprecated: Boolean! = false): [__Field!]
inputFields: [__InputValue!]
interfaces: [__Type!]
kind: __TypeKind!
name: String
ofType: __Type
possibleTypes: [__Type!]
}
enum __TypeKind {
ENUM
INPUT_OBJECT
INTERFACE
LIST
NON_NULL
OBJECT
SCALAR
UNION
}
type LoginPayload {
bearer: String!
token: String!
@ -108,4 +184,4 @@ input UserCreateInput {
input VoteCreateInput {
teacherIds: [Int!]!
}
}

View File

@ -1,47 +0,0 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use yew_agent::{Agent, AgentLink, Context, HandlerId};
#[derive(Serialize, Deserialize, Debug)]
pub enum Request {
LoggedIn(bool),
}
pub struct EventBus {
link: AgentLink<Self>,
subscribers: HashSet<HandlerId>,
}
impl Agent for EventBus {
type Reach = Context<Self>;
type Message = ();
type Input = Request;
type Output = bool;
fn create(link: AgentLink<Self>) -> Self {
Self {
link,
subscribers: HashSet::new(),
}
}
fn update(&mut self, _msg: Self::Message) {}
fn handle_input(&mut self, msg: Self::Input, _id: HandlerId) {
match msg {
Request::LoggedIn(x) => {
for sub in self.subscribers.iter() {
self.link.respond(*sub, x);
}
}
}
}
fn connected(&mut self, id: HandlerId) {
self.subscribers.insert(id);
}
fn disconnected(&mut self, id: HandlerId) {
self.subscribers.remove(&id);
}
}

View File

@ -1 +0,0 @@
pub mod logged_in;

View File

@ -15,7 +15,7 @@ impl Component for Footer {
<footer class={classes!("footer")}>
<div class={classes!("columns")}>
<div class={classes!("column", "is-three-quarters")}>
<h3 class={classes!("title", "is-3")}>{ "dergrimm.net" }</h3>
<h3 class={classes!("title", "is-3")}>{ "mentorenwahl.dergrimm.net" }</h3>
<p>
<a href="https://git.dergrimm.net/mentorenwahl/mentorenwahl"><strong>{ "Mentorenwahl" }</strong></a> { " von " }
<a href="https://dergrimm.net">{ "Dominic Grimm" }</a> { ". " }
@ -24,9 +24,11 @@ impl Component for Footer {
</div>
<div class={classes!("column")}>
<p>
<figure class={classes!("image")}>
<img src="https://forthebadge.com/images/badges/made-with-rust.svg" />
</figure>
<a href="https://www.rust-lang.org/">
<figure class={classes!("image")}>
<img src="https://forthebadge.com/images/badges/made-with-rust.svg" />
</figure>
</a>
</p>
<p>
<figure class={classes!("image")}>

View File

@ -1,54 +0,0 @@
use yew::prelude::*;
use yew_agent::{Bridge, Bridged};
use yew_router::prelude::*;
use crate::agents;
use crate::cookies;
use crate::routes;
pub enum Msg {
LoggedIn(bool),
}
#[derive(Properties, PartialEq)]
pub struct LoggedInHandlerProps {
pub logged_in: bool,
}
pub struct LoggedInHandler {
logged_in: bool,
_logged_in_producer: Box<dyn Bridge<agents::logged_in::EventBus>>,
}
impl Component for LoggedInHandler {
type Message = Msg;
type Properties = LoggedInHandlerProps;
fn create(ctx: &Context<Self>) -> Self {
Self {
logged_in: ctx.props().logged_in,
_logged_in_producer: agents::logged_in::EventBus::bridge(
ctx.link().callback(Msg::LoggedIn),
),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::LoggedIn(x) => {
if self.logged_in && !x {
log::info!("Global logout!");
cookies::logout_clear();
ctx.link().history().unwrap().push(routes::Route::Login);
}
self.logged_in = x;
false
}
}
}
fn view(&self, _ctx: &Context<Self>) -> Html {
html! {}
}
}

View File

@ -1,5 +1,5 @@
pub mod fetching;
pub mod footer;
pub mod graphql_errors;
pub mod logged_in_handler;
pub mod navbar;
pub mod title;

View File

@ -1,13 +1,16 @@
use graphql_client::reqwest::post_graphql;
use std::rc::Rc;
use yew::prelude::*;
use yew_agent::{Dispatched, Dispatcher};
use yew_router::prelude::*;
use yewdux::prelude::*;
use crate::agents;
use crate::cookies;
use crate::graphql;
use crate::routes;
use crate::stores;
pub enum Msg {
UpdateLoggedIn(Rc<stores::LoggedIn>),
LogoutClicked,
Logout,
BurgerClicked,
@ -16,12 +19,11 @@ pub enum Msg {
#[derive(Properties, PartialEq)]
pub struct NavbarProps {
pub token: Option<String>,
pub logged_in: bool,
}
pub struct Navbar {
logged_in: bool,
logged_in_event_bus: Dispatcher<agents::logged_in::EventBus>,
logged_in: Rc<stores::LoggedIn>,
logged_in_dispatch: Dispatch<stores::LoggedIn>,
burger_active: bool,
}
@ -30,15 +32,23 @@ impl Component for Navbar {
type Properties = NavbarProps;
fn create(ctx: &Context<Self>) -> Self {
let dispatch = Dispatch::subscribe(ctx.link().callback(Msg::UpdateLoggedIn));
Self {
logged_in: ctx.props().logged_in,
logged_in_event_bus: agents::logged_in::EventBus::dispatcher(),
logged_in: dispatch.get(),
logged_in_dispatch: dispatch,
burger_active: false,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::UpdateLoggedIn(x) => {
let tmp = self.logged_in.0;
self.logged_in = x;
tmp != self.logged_in.0
}
Msg::LogoutClicked => {
let client = graphql::client(ctx.props().token.as_ref()).unwrap();
ctx.link().send_future(async move {
@ -56,8 +66,9 @@ impl Component for Navbar {
false
}
Msg::Logout => {
self.logged_in_event_bus
.send(agents::logged_in::Request::LoggedIn(false));
cookies::logout_clear();
self.logged_in_dispatch.reduce_mut(|x| x.0 = false);
ctx.link().navigator().unwrap().push(&routes::Route::Login);
false
}
Msg::BurgerClicked => {
@ -114,7 +125,7 @@ impl Component for Navbar {
<div class={classes!("navbar-item")}>
<div class={classes!("buttons")}>
if self.logged_in {
if self.logged_in.0 {
<a onclick={ctx.link().callback(|_| Msg::LogoutClicked)} class={classes!("button")}>
{ "Logout" }
</a>

View File

@ -0,0 +1,26 @@
use bounce::helmet::Helmet;
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct TitleProps {
pub title: String,
}
pub struct Title;
impl Component for Title {
type Message = ();
type Properties = TitleProps;
fn create(_ctx: &Context<Self>) -> Self {
Self
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<Helmet>
<title>{ format!("{} | Mentorenwahl", ctx.props().title) }</title>
</Helmet>
}
}
}

View File

@ -1,11 +1,21 @@
use const_format::concatcp;
pub const DEFAULT_COOKIE_OPTIONS: wasm_cookies::CookieOptions = wasm_cookies::CookieOptions {
domain: None,
expires: None,
path: None,
same_site: wasm_cookies::SameSite::Lax,
secure: false,
};
const BASE: &str = "mentorenwahl_";
pub mod names {
use const_format::concatcp;
pub const TOKEN: &str = concatcp!(BASE, "token");
pub const ADMIN: &str = concatcp!(BASE, "admin");
const BASE: &str = "mentorenwahl_";
pub const DELETE_ON_LOGOUT: [&str; 2] = [TOKEN, ADMIN];
pub const TOKEN: &str = concatcp!(BASE, "token");
pub const ADMIN: &str = concatcp!(BASE, "admin");
}
const DELETE_ON_LOGOUT: [&str; 2] = [names::TOKEN, names::ADMIN];
pub fn logout_clear() {
for x in DELETE_ON_LOGOUT {

View File

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

View File

@ -2,7 +2,6 @@ use graphql_client::GraphQLQuery;
use crate::graphql::scalars;
// type UUID = String;
type UUID = scalars::UUID;
type Time = String;

View File

@ -0,0 +1,19 @@
use graphql_client::GraphQLQuery;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/users_by_role_students.graphql",
response_derives = "Debug",
skip_serializing_none
)]
pub struct Students;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/users_by_role_teachers.graphql",
response_derives = "Debug",
skip_serializing_none
)]
pub struct Teachers;

View File

@ -5,7 +5,6 @@ use crate::components;
#[derive(Properties, PartialEq)]
pub struct BaseProps {
pub token: Option<String>,
pub logged_in: bool,
#[prop_or_default]
pub children: Children,
}
@ -23,10 +22,8 @@ impl Component for Base {
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<>
<components::logged_in_handler::LoggedInHandler logged_in={ctx.props().logged_in} />
<div id="wrapper">
<components::navbar::Navbar token={ctx.props().token.to_owned()} logged_in={ctx.props().logged_in} />
<components::navbar::Navbar token={ctx.props().token.to_owned()} />
{ for ctx.props().children.iter() }
</div>
<components::footer::Footer />

View File

@ -1,24 +1,24 @@
use std::rc::Rc;
use yew::prelude::*;
use yew_agent::{Bridge, Bridged};
use yew_router::prelude::*;
use yewdux::prelude::*;
use crate::agents;
use crate::routes;
use crate::stores;
pub enum Msg {
LoggedIn(bool),
UpdateLoggedIn(Rc<stores::LoggedIn>),
}
#[derive(Properties, PartialEq)]
pub struct LoggedInProps {
pub logged_in: bool,
#[prop_or_default]
pub children: Children,
}
pub struct LoggedIn {
logged_in: bool,
_logged_in_producer: Box<dyn Bridge<agents::logged_in::EventBus>>,
logged_in: Rc<stores::LoggedIn>,
_logged_in_dispatch: Dispatch<stores::LoggedIn>, // _logged_in_producer: Box<dyn Bridge<agents::logged_in::EventBus>>,
}
impl Component for LoggedIn {
@ -26,33 +26,37 @@ impl Component for LoggedIn {
type Properties = LoggedInProps;
fn create(ctx: &Context<Self>) -> Self {
let dispatch = Dispatch::subscribe(ctx.link().callback(Msg::UpdateLoggedIn));
let logged_in = dispatch.get();
if !logged_in.0 {
ctx.link().navigator().unwrap().push(&routes::Route::Login)
}
Self {
logged_in: ctx.props().logged_in,
_logged_in_producer: agents::logged_in::EventBus::bridge(
ctx.link().callback(Msg::LoggedIn),
),
logged_in,
_logged_in_dispatch: dispatch,
}
}
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::LoggedIn(x) => {
let prev = self.logged_in;
Msg::UpdateLoggedIn(x) => {
self.logged_in = x;
prev != self.logged_in
if !self.logged_in.0 {
ctx.link().navigator().unwrap().push(&routes::Route::Login);
}
false
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
if !self.logged_in {
ctx.link().history().unwrap().push(routes::Route::Login);
}
html! {
if self.logged_in {
{ for ctx.props().children.iter() }
}
if self.logged_in.0 {
{ for ctx.props().children.iter() }
}
}
}
}

View File

@ -6,7 +6,6 @@ use crate::layouts;
#[derive(Properties, PartialEq)]
pub struct MainProps {
pub token: Option<String>,
pub logged_in: bool,
#[prop_or_default]
pub children: Children,
}
@ -23,9 +22,9 @@ impl Component for Main {
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<layouts::base::Base token={ctx.props().token.to_owned()} logged_in={ctx.props().logged_in}>
<div class={classes!("columns")}>
<main class={classes!("column", "is-four-fifths", "mx-auto")}>
<layouts::base::Base token={ctx.props().token.to_owned()}>
<div class={classes!("columns", "is-centered")}>
<main class={classes!("column", "is-four-fifths")}>
{ for ctx.props().children.iter() }
</main>
</div>

View File

@ -1,6 +1,6 @@
pub mod agents;
pub mod components;
pub mod cookies;
pub mod graphql;
pub mod layouts;
pub mod routes;
pub mod stores;

View File

@ -1,9 +1,7 @@
use std::rc::Rc;
use bounce::helmet::HelmetBridge;
use bounce::BounceRoot;
use yew::prelude::*;
use yew_router::prelude::*;
use yew_side_effect::title::TitleProvider;
use frontend::routes;
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
@ -19,15 +17,13 @@ impl Component for App {
}
fn view(&self, _ctx: &Context<Self>) -> Html {
let format_title =
Rc::new(|m: &str| format!("{} | mentorenwahl.de", m)) as Rc<dyn Fn(&str) -> String>;
html! {
<BrowserRouter>
<TitleProvider default_title="mentorenwahl.de" {format_title}>
<Switch<routes::Route> render={Switch::render(routes::switch)} />
</TitleProvider>
</BrowserRouter>
<BounceRoot>
<HelmetBridge default_title="Mentorenwahl" />
<BrowserRouter>
<Switch<frontend::routes::Route> render={frontend::routes::switch} />
</BrowserRouter>
</BounceRoot>
}
}
}
@ -36,5 +32,5 @@ fn main() {
#[cfg(target_arch = "wasm32")]
wasm_logger::init(wasm_logger::Config::default());
yew::start_app::<App>();
yew::Renderer::<App>::new().render();
}

View File

@ -1,6 +1,5 @@
use graphql_client::reqwest::post_graphql;
use yew::prelude::*;
use yew_side_effect::title::Title;
use crate::components;
use crate::graphql;
@ -70,7 +69,7 @@ impl Component for Home {
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<>
<Title value="Home" />
<components::title::Title title="Home" />
if self.fetching {
<components::fetching::Fetching />
} else {

View File

@ -218,7 +218,7 @@ impl Component for StudentVote {
<p>{ "Noch nicht erlaubt zu wählen." }</p>
}
} else {
let onsubmit = ctx.link().callback(|e: FocusEvent| {
let onsubmit = ctx.link().callback(|e: SubmitEvent| {
e.prevent_default();
Msg::Submit
@ -241,32 +241,34 @@ impl Component for StudentVote {
<fieldset>
<legend>{ format!("{}. Wahl", i + 1) }</legend>
{ for self.teachers.iter().enumerate().filter_map(|(j, t)| {
let checked = curr_t == Some(&Some(t.id));
{
for self.teachers.iter().enumerate().filter_map(|(j, t)| {
let checked = curr_t == Some(&Some(t.id));
if teachers.contains(&t.id) && !checked {
None
} else {
let id = format!("{}_{}", name, j);
let teacher_id = t.id;
let onchange = ctx.link().callback(move |_| Msg::RadioSelect { priority: i, teacher: teacher_id });
if teachers.contains(&t.id) && !checked {
None
} else {
let id = format!("{}_{}", name, j);
let teacher_id = t.id;
let onchange = ctx.link().callback(move |_| Msg::RadioSelect { priority: i, teacher: teacher_id });
Some(html! {
<div>
<input
type="radio"
id={id.to_owned()}
name={name.to_owned()}
value={t.user.name.to_owned()}
required=true
{onchange}
{checked}
/>
<label for={id}>{ &t.user.name }</label>
</div>
})
}
}) }
Some(html! {
<div>
<input
type="radio"
id={id.to_owned()}
name={name.to_owned()}
value={t.user.name.to_owned()}
required=true
{onchange}
{checked}
/>
<label for={id}>{ &t.user.name }</label>
</div>
})
}
})
}
</fieldset>
if i < self.slots - 1 {
<br />

View File

@ -74,7 +74,7 @@ impl Component for TeacherRegistration {
}
fn view(&self, ctx: &Context<Self>) -> Html {
let onsubmit = ctx.link().callback(|e: FocusEvent| {
let onsubmit = ctx.link().callback(|e: SubmitEvent| {
e.prevent_default();
Msg::Submit

View File

@ -1,12 +1,10 @@
use yew::prelude::*;
use yew_side_effect::title::Title;
use crate::components;
#[derive(Properties, PartialEq)]
pub struct IndexProps {
pub token: Option<String>,
pub logged_in: bool,
}
pub struct Index;
@ -22,13 +20,10 @@ impl Component for Index {
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<>
<components::logged_in_handler::LoggedInHandler logged_in={ctx.props().logged_in} />
<Title value="Mentorenwahl" />
<div id="wrapper">
<section class={classes!("hero", "is-success", "is-fullheight")}>
<div class={classes!("hero-head")}>
<components::navbar::Navbar token={ctx.props().token.to_owned()} logged_in={ctx.props().logged_in} />
<components::navbar::Navbar token={ctx.props().token.to_owned()} />
</div>
<div class={classes!("hero-body")}>

View File

@ -0,0 +1,23 @@
use yew::prelude::*;
use crate::components;
pub struct Info;
impl Component for Info {
type Message = ();
type Properties = ();
fn create(_ctx: &Context<Self>) -> Self {
Self
}
fn view(&self, _ctx: &Context<Self>) -> Html {
html! {
<>
<components::title::Title title="Informationen" />
<h1>{ "Informationen" }</h1>
</>
}
}
}

View File

@ -1,27 +1,30 @@
use graphql_client::reqwest::post_graphql;
use std::rc::Rc;
use web_sys::HtmlInputElement;
use yew::prelude::*;
use yew_router::prelude::*;
use yew_side_effect::title::Title;
use yewdux::prelude::*;
use crate::components;
use crate::cookies;
use crate::graphql;
use crate::layouts;
use crate::routes;
use crate::stores;
#[derive(Properties, PartialEq)]
pub struct LoginProps {
pub token: Option<String>,
pub logged_in: bool,
}
pub enum Msg {
UpdateLoggedIn(Rc<stores::LoggedIn>),
Submit,
Login(Option<Vec<String>>),
}
pub struct Login {
logged_in_dispatch: Dispatch<stores::LoggedIn>,
username: NodeRef,
password: NodeRef,
fetching: bool,
@ -32,8 +35,9 @@ impl Component for Login {
type Message = Msg;
type Properties = LoginProps;
fn create(_ctx: &Context<Self>) -> Self {
fn create(ctx: &Context<Self>) -> Self {
Self {
logged_in_dispatch: Dispatch::subscribe(ctx.link().callback(Msg::UpdateLoggedIn)),
username: NodeRef::default(),
password: NodeRef::default(),
fetching: false,
@ -43,6 +47,7 @@ impl Component for Login {
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::UpdateLoggedIn(_) => false,
Msg::Submit => {
if let (Some(username), Some(password)) = (
self.username.cast::<HtmlInputElement>().map(|x| x.value()),
@ -66,18 +71,18 @@ impl Component for Login {
let data = response.data.unwrap().login.unwrap();
wasm_cookies::set(
cookies::TOKEN,
cookies::names::TOKEN,
&data.token,
&wasm_cookies::CookieOptions::default(),
&cookies::DEFAULT_COOKIE_OPTIONS,
);
if data.user.admin {
wasm_cookies::set(
cookies::ADMIN,
cookies::names::ADMIN,
"1",
&wasm_cookies::CookieOptions::default(),
&cookies::DEFAULT_COOKIE_OPTIONS,
);
} else {
wasm_cookies::delete(cookies::ADMIN);
wasm_cookies::delete(cookies::names::ADMIN);
}
}
@ -96,7 +101,8 @@ impl Component for Login {
self.fetching = false;
self.errors = errors;
if self.errors.is_none() {
ctx.link().history().unwrap().push(routes::Route::Home);
self.logged_in_dispatch.reduce_mut(|x| x.0 = true);
ctx.link().navigator().unwrap().push(&routes::Route::Home);
false
} else {
true
@ -106,17 +112,13 @@ impl Component for Login {
}
fn view(&self, ctx: &Context<Self>) -> Html {
if ctx.props().logged_in {
ctx.link().history().unwrap().push(routes::Route::Home);
}
html! {
<>
<Title value="Login" />
<layouts::main::Main token={ctx.props().token.to_owned()} logged_in={false}>
<div class={classes!("columns")}>
<div class={classes!("column", "is-one-third", "mx-auto")}>
<form onsubmit={ctx.link().callback(|e: FocusEvent| { e.prevent_default(); Msg::Submit })} class={classes!("box")}>
<components::title::Title title="Login" />
<layouts::main::Main token={ctx.props().token.to_owned()}>
<div class={classes!("columns", "is-centered")}>
<div class={classes!("column", "is-half")}>
<form onsubmit={ctx.link().callback(|e: SubmitEvent| { e.prevent_default(); Msg::Submit })} class={classes!("box")}>
<div class={classes!("field")}>
<p class={classes!("control", "has-icons-left")}>
<input ref={self.username.clone()}

View File

@ -6,6 +6,7 @@ use crate::layouts;
pub mod home;
pub mod index;
pub mod info;
pub mod login;
pub mod not_found;
pub mod settings;
@ -27,28 +28,27 @@ pub enum Route {
NotFound,
}
pub fn switch(routes: &Route) -> Html {
pub fn switch(routes: Route) -> Html {
let token = {
let tmp = wasm_cookies::get(cookies::TOKEN);
let tmp = wasm_cookies::get(cookies::names::TOKEN);
if let Some(x) = tmp {
if let Ok(y) = x {
Some(y)
} else {
wasm_cookies::delete(cookies::TOKEN);
wasm_cookies::delete(cookies::names::TOKEN);
None
}
} else {
None
}
};
let logged_in = token.is_some();
let admin = {
let tmp = wasm_cookies::get(cookies::ADMIN);
let tmp = wasm_cookies::get(cookies::names::ADMIN);
if let Some(x) = tmp {
if let Ok(_) = x {
true
} else {
wasm_cookies::delete(cookies::ADMIN);
wasm_cookies::delete(cookies::names::ADMIN);
false
}
} else {
@ -59,13 +59,13 @@ pub fn switch(routes: &Route) -> Html {
match routes {
Route::Index => {
html! {
<index::Index token={token.to_owned()} {logged_in} />
<index::Index token={token.to_owned()}/>
}
}
Route::Home => {
html! {
<layouts::logged_in::LoggedIn {logged_in}>
<layouts::main::Main token={token.to_owned()} {logged_in}>
<layouts::logged_in::LoggedIn>
<layouts::main::Main token={token.to_owned()}>
<home::Home {token} />
</layouts::main::Main>
</layouts::logged_in::LoggedIn>
@ -73,8 +73,8 @@ pub fn switch(routes: &Route) -> Html {
}
Route::Settings => {
html! {
<layouts::logged_in::LoggedIn {logged_in}>
<layouts::main::Main token={token.to_owned()} {logged_in}>
<layouts::logged_in::LoggedIn>
<layouts::main::Main token={token.to_owned()}>
<settings::Settings {token} {admin} />
</layouts::main::Main>
</layouts::logged_in::LoggedIn>
@ -82,16 +82,16 @@ pub fn switch(routes: &Route) -> Html {
}
Route::Info => {
html! {
<layouts::main::Main token={token.to_owned()} {logged_in}>
<h1>{ "Informationen" }</h1>
<layouts::main::Main token={token.to_owned()}>
<info::Info />
</layouts::main::Main>
}
}
Route::Login => html! {
<login::Login {token} {logged_in} />
<login::Login {token} />
},
Route::NotFound => html! {
<not_found::NotFound {token} {logged_in} />
<not_found::NotFound {token} />
},
}
}

View File

@ -1,12 +1,11 @@
use yew::prelude::*;
use yew_side_effect::title::Title;
use crate::components;
use crate::layouts;
#[derive(Properties, PartialEq)]
pub struct NotFoundProps {
pub token: Option<String>,
pub logged_in: bool,
}
pub struct NotFound;
@ -22,8 +21,8 @@ impl Component for NotFound {
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<>
<Title value="404 Not found" />
<layouts::main::Main token={ctx.props().token.to_owned()} logged_in={ctx.props().logged_in}>
<components::title::Title title="404 Not found" />
<layouts::main::Main token={ctx.props().token.to_owned()}>
<h1>{ "404" }</h1>
</layouts::main::Main>
</>

View File

@ -54,25 +54,29 @@ impl Component for Assignments {
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<fieldset>
<fieldset class={classes!("fieldset")}>
<legend>{ "Zuweisungen" }</legend>
<table>
<tr>
<th>{ "Aktion" }</th>
<th>{ "Optionen" }</th>
</tr>
<tr>
<td>{ "Zuweisung starten" }</td>
<td>
<ul>
<li>
<button onclick={ctx.link().callback(|_| Msg::StartAssignment)}>
{ "Ausführen" }
</button>
</li>
</ul>
</td>
</tr>
<table class={classes!("table")}>
<thead>
<tr>
<th>{ "Aktion" }</th>
<th>{ "Optionen" }</th>
</tr>
</thead>
<tbody>
<tr>
<td>{ "Zuweisung starten" }</td>
<td>
<ul>
<li>
<button class={classes!("button")} onclick={ctx.link().callback(|_| Msg::StartAssignment)}>
{ "Ausführen" }
</button>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
<components::graphql_errors::GraphQLErrors errors={self.errors.to_owned()} />

View File

@ -1,8 +1,11 @@
use yew::prelude::*;
use yew_side_effect::title::Title;
use crate::components;
pub mod assignments;
pub mod new_user_modal;
pub mod tokens;
pub mod users;
#[derive(Properties, PartialEq, Eq)]
pub struct SettingsProps {
@ -23,15 +26,18 @@ impl Component for Settings {
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<>
<Title value="Settings" />
<section>
<tokens::Tokens token={ctx.props().token.as_ref().unwrap().to_owned()} />
<components::title::Title title="Settings" />
<section class={classes!("block")}>
<tokens::Tokens token={ctx.props().token.as_ref().unwrap().to_owned()} />
</section>
if ctx.props().admin {
<section class={classes!("block")}>
<assignments::Assignments token={ctx.props().token.as_ref().unwrap().to_owned()} />
</section>
if ctx.props().admin {
<section>
<assignments::Assignments token={ctx.props().token.as_ref().unwrap().to_owned()} />
</section>
}
<section class={classes!("block")}>
<users::Users token={ctx.props().token.as_ref().unwrap().to_owned()} />
</section>
}
</>
}
}

View File

@ -0,0 +1,74 @@
use yew::prelude::*;
pub enum Msg {
CloseModal,
Save,
}
#[derive(Properties, PartialEq)]
pub struct NewUserModalProps {
pub close: Callback<()>,
pub active: bool,
}
pub struct NewUserModal {
username: NodeRef,
admin: NodeRef,
}
impl Component for NewUserModal {
type Message = Msg;
type Properties = NewUserModalProps;
fn create(ctx: &Context<Self>) -> Self {
Self {
username: NodeRef::default(),
admin: NodeRef::default(),
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::CloseModal => {
ctx.props().close.emit(());
false
}
Msg::Save => {
log::debug!("self.username = {:?}", self.username);
log::debug!("self.admin = {:?}", self.admin);
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let close = ctx.link().callback(|_| Msg::CloseModal);
html! {
<div class={classes!("modal", if ctx.props().active { Some("is-active") } else { None })}>
<div onclick={close.clone()}
class={classes!("modal-background")}
/>
<div class={classes!("modal-card")}>
<header class={classes!("modal-card-head")}>
<p class={classes!("modal-card-title")}>{ "Neuer Benutzer" }</p>
<button onclick={close.clone()} class={classes!("delete")} aria-label="close" />
</header>
<section class={classes!("modal-card-body")}>
<form onsubmit={ctx.link().callback(|e: SubmitEvent| { e.prevent_default(); Msg::Save })}>
<div class={classes!("field")}>
</div>
</form>
</section>
<footer class={classes!("modal-card-foot")}>
<button onclick={close} class={classes!("button")}>{ "Abbrechen" }</button>
<button class={classes!("button", "is-success")}>{ "Speichern" }</button>
</footer>
</div>
</div>
}
}
}

View File

@ -6,11 +6,11 @@ use crate::graphql;
pub enum Msg {
DoneFetching {
errors: Option<Vec<String>>,
errors: graphql::Errors,
tokens: Vec<graphql::queries::tokens::tokens::TokensTokens>,
},
Revoke(usize),
RevokeDone(Option<Vec<String>>),
RevokeDone(graphql::Errors),
}
#[derive(Properties, PartialEq, Eq)]
@ -20,7 +20,7 @@ pub struct TokensProps {
pub struct Tokens {
fetching: bool,
errors: Option<Vec<String>>,
errors: graphql::Errors,
tokens: Option<Vec<graphql::queries::tokens::tokens::TokensTokens>>,
}
@ -40,7 +40,7 @@ impl Component for Tokens {
.unwrap();
Msg::DoneFetching {
errors: None,
errors: graphql::convert(response.errors),
tokens: response.data.unwrap().tokens.unwrap(),
}
});
@ -92,7 +92,7 @@ impl Component for Tokens {
<fieldset class={classes!("fieldset")}>
<legend>{ "Tokens" }</legend>
if self.fetching {
<p>{ "Fetching..." }</p>
<components::fetching::Fetching />
} else {
<table class={classes!("table")}>
<thead>
@ -104,20 +104,20 @@ impl Component for Tokens {
</tr>
</thead>
<tbody>
{ for self.tokens.as_ref().unwrap().iter().enumerate().map(|(i, t)| {
html! {
{
for self.tokens.as_ref().unwrap().iter().enumerate().map(|(i, t)| html! {
<tr>
<th><code>{ &t.id }</code></th>
<td><code>{ &t.iat }</code></td>
<td><code>{ &t.exp }</code></td>
<td>
<button onclick={ctx.link().callback(move |_| Msg::Revoke(i))}>
<button class={classes!("button")} onclick={ctx.link().callback(move |_| Msg::Revoke(i))}>
{ "Revoke" }
</button>
</td>
</tr>
}
}) }
})
}
</tbody>
</table>

View File

@ -0,0 +1,329 @@
use graphql_client::reqwest::post_graphql;
use yew::prelude::*;
use crate::components;
use crate::graphql;
use crate::routes::settings::new_user_modal;
pub enum Msg {
DoneFetching {
errors: graphql::Errors,
students: Option<Vec<graphql::queries::users_by_role::students::StudentsStudents>>,
teachers: Option<Vec<graphql::queries::users_by_role::teachers::TeachersTeachers>>,
},
SwitchTab(UsersTab),
OpenModal,
CloseModal,
}
#[derive(Properties, PartialEq, Eq)]
pub struct UsersProps {
pub token: String,
}
#[derive(PartialEq, Clone, Debug)]
pub enum UsersTab {
All,
Students,
Teachers,
}
pub struct Users {
tab: UsersTab,
fetching: bool,
errors: graphql::Errors,
students: Option<Vec<graphql::queries::users_by_role::students::StudentsStudents>>,
teachers: Option<Vec<graphql::queries::users_by_role::teachers::TeachersTeachers>>,
modal_active: bool,
}
impl Component for Users {
type Message = Msg;
type Properties = UsersProps;
fn create(ctx: &Context<Self>) -> Self {
let client = graphql::client(Some(&ctx.props().token)).unwrap();
ctx.link().send_future(async move {
let students_response = post_graphql::<graphql::queries::users_by_role::Students, _>(
&client,
graphql::URL.as_str(),
graphql::queries::users_by_role::students::Variables,
)
.await
.unwrap();
let teachers_response = post_graphql::<graphql::queries::users_by_role::Teachers, _>(
&client,
graphql::URL.as_str(),
graphql::queries::users_by_role::teachers::Variables,
)
.await
.unwrap();
let x = [
students_response.errors.map_or_else(|| vec![], |e| e),
teachers_response.errors.map_or_else(|| vec![], |e| e),
]
.concat();
if x.is_empty() {
Msg::DoneFetching {
errors: None,
students: Some(students_response.data.unwrap().students.unwrap()),
teachers: Some(teachers_response.data.unwrap().teachers),
}
} else {
Msg::DoneFetching {
errors: graphql::convert(Some(x)),
students: None,
teachers: None,
}
}
});
Self {
tab: UsersTab::All,
fetching: true,
errors: None,
students: None,
teachers: None,
modal_active: false,
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::DoneFetching {
errors,
students,
teachers,
} => {
self.fetching = false;
self.errors = errors;
self.students = students;
self.teachers = teachers;
true
}
Msg::SwitchTab(tab) => {
if self.tab == tab {
false
} else {
self.tab = tab;
true
}
}
Msg::OpenModal => {
self.modal_active = true;
true
}
Msg::CloseModal => {
self.modal_active = false;
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<fieldset class={classes!("fieldset")}>
<legend>{ "Benutzer" }</legend>
<div class={classes!("tabs")}>
<ul>
<li class={classes!(if self.tab == UsersTab::All { Some("is-active") } else { None })}>
<a onclick={ctx.link().callback(|_| Msg::SwitchTab(UsersTab::All))}>{ "Alle" }</a>
</li>
<li class={classes!(if self.tab == UsersTab::Students { Some("is-active") } else { None })}>
<a onclick={ctx.link().callback(|_| Msg::SwitchTab(UsersTab::Students))}>{ "Schüler" }</a>
</li>
<li class={classes!(if self.tab == UsersTab::Teachers { Some("is-active") } else { None })}>
<a onclick={ctx.link().callback(|_| Msg::SwitchTab(UsersTab::Teachers))}>{ "Lehrer" }</a>
</li>
</ul>
</div>
if self.fetching {
<components::fetching::Fetching />
} else {
<div>
{
match self.tab {
UsersTab::All => html! {
<table class={classes!("table")}>
<thead>
<tr>
<th><abbr title="ID des Benutzers in der Datenbank / API">{ "ID" }</abbr></th>
<th>{ "Nachname" }</th>
<th>{ "Vorname" }</th>
<th><abbr title="LDAP Benutzername">{ "Benutzername" }</abbr></th>
<th>{ "Email" }</th>
<th>{ "Rolle" }</th>
<th><abbr title="ID des externen Benutzerobjekts">{ "Externe Rollen-ID" }</abbr></th>
<th>{ "Admin" }</th>
<th><abbr title="Wenn Schüler, dann ob Lehrer gewählt wurde">{ "Gewählt" }</abbr></th>
</tr>
</thead>
<tbody>
{
for self.students.as_ref().unwrap().iter().map(|s| html! {
<tr>
<td><code>{ &s.user.id }</code></td>
<td>{ &s.user.last_name }</td>
<td>{ &s.user.first_name }</td>
<td><code>{ &s.user.username }</code></td>
<td>
<a href={format!("mailto:{}", s.user.email)}>
<code>{ &s.user.email }</code>
</a>
</td>
<td>
<code>
{
match &s.user.role {
graphql::queries::users_by_role::students::UserRole::Student => "S",
graphql::queries::users_by_role::students::UserRole::Teacher => "T",
graphql::queries::users_by_role::students::UserRole::Other(_) => "N/A",
}
}
</code>
</td>
<td><code>{ &s.id }</code></td>
<td><code>{ if s.user.admin { 1 } else { 0 } }</code></td>
<td><code>{ if s.vote.is_some() { 1 } else { 0 } }</code></td>
</tr>
})
}
{
for self.teachers.as_ref().unwrap().iter().map(|t| html! {
<tr>
<td><code>{ &t.user.id }</code></td>
<td>{ &t.user.last_name }</td>
<td>{ &t.user.first_name }</td>
<td><code>{ &t.user.username }</code></td>
<td>
<a href={format!("mailto:{}", t.user.email)}>
<code>{ &t.user.email }</code>
</a>
</td>
<td>
<code>
{
match &t.user.role {
graphql::queries::users_by_role::teachers::UserRole::Student => "S",
graphql::queries::users_by_role::teachers::UserRole::Teacher => "T",
graphql::queries::users_by_role::teachers::UserRole::Other(_) => "N/A",
}
}
</code>
</td>
<td><code>{ &t.id }</code></td>
<td><code>{ if t.user.admin { 1 } else { 0 } }</code></td>
<td><code>{ "N/A" }</code></td>
</tr>
})
}
</tbody>
</table>
},
UsersTab::Students => html! {
<table class={classes!("table")}>
<thead>
<tr>
<th><abbr title="ID des Schülers in der Datenbank / API">{ "ID" }</abbr></th>
<th>{ "Nachname" }</th>
<th>{ "Vorname" }</th>
<th><abbr title="LDAP Benutzername">{ "Benutzername" }</abbr></th>
<th>{ "Email" }</th>
<th>{ "Benutzer-ID" }</th>
<th>{ "Admin" }</th>
<th>{ "Gewählt" }</th>
</tr>
</thead>
<tbody>
{
for self.students.as_ref().unwrap().iter().map(|s| html! {
<tr>
<td><code>{ &s.id }</code></td>
<td>{ &s.user.last_name }</td>
<td>{ &s.user.first_name }</td>
<td><code>{ &s.user.username }</code></td>
<td>
<a href={format!("mailto:{}", s.user.email)}>
<code>{ &s.user.email }</code>
</a>
</td>
<td><code>{ &s.user.id }</code></td>
<td><code>{ if s.user.admin { 1 } else { 0 } }</code></td>
<td><code>{ if s.vote.is_some() { 1 } else { 0 } }</code></td>
</tr>
})
}
</tbody>
</table>
},
UsersTab::Teachers => html! {
<table class={classes!("table")}>
<thead>
<tr>
<th><abbr title="ID des Lehrers in der Datenbank / API">{ "ID" }</abbr></th>
<th>{ "Nachname" }</th>
<th>{ "Vorname" }</th>
<th><abbr title="LDAP Benutzername">{ "Benutzername" }</abbr></th>
<th>{ "Email" }</th>
<th>{ "Benutzer-ID" }</th>
<th>{ "Admin" }</th>
</tr>
</thead>
<tbody>
{
for self.teachers.as_ref().unwrap().iter().map(|t| html! {
<tr>
<td><code>{ &t.id }</code></td>
<td>{ &t.user.last_name }</td>
<td>{ &t.user.first_name }</td>
<td><code>{ &t.user.username }</code></td>
<td>
<a href={format!("mailto:{}", t.user.email)}>
<code>{ &t.user.email }</code>
</a>
</td>
<td>
<code>
{
match &t.user.role {
graphql::queries::users_by_role::teachers::UserRole::Student => "S",
graphql::queries::users_by_role::teachers::UserRole::Teacher => "T",
graphql::queries::users_by_role::teachers::UserRole::Other(_) => "N/A",
}
}
</code>
</td>
<td><code>{ &t.user.id }</code></td>
<td><code>{ if t.user.admin { 1 } else { 0 } }</code></td>
</tr>
})
}
</tbody>
</table>
},
}
}
<button onclick={ctx.link().callback(|_| Msg::OpenModal)}
class={classes!("button", "is-success", "is-fullwidth")}
>
<span class={classes!("icon")}>
<i class={classes!("fas", "fa-user-plus")} />
</span>
<span>{ "Neuer Benutzer" }</span>
</button>
<new_user_modal::NewUserModal close={ctx.link().callback(|_| Msg::CloseModal)}
active={self.modal_active}
/>
</div>
<components::graphql_errors::GraphQLErrors errors={self.errors.to_owned()} />
}
</fieldset>
}
}
}

29
frontend/src/stores.rs Normal file
View File

@ -0,0 +1,29 @@
use yewdux::prelude::*;
use crate::cookies;
#[derive(Debug, Clone, PartialEq, Eq, Store)]
pub struct LoggedIn(pub bool);
impl Default for LoggedIn {
fn default() -> Self {
let token = {
let tmp = wasm_cookies::get(cookies::names::TOKEN);
if let Some(x) = tmp {
if let Ok(y) = x {
Some(y)
} else {
wasm_cookies::delete(cookies::names::TOKEN);
None
}
} else {
None
}
};
Self(token.is_some())
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Store)]
pub struct Token(pub String);

View File

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

View File

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