Add student vote enable toggle to admin panel
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Dominic Grimm 2023-02-04 12:14:11 +01:00
parent f7d32ac08c
commit d6dbd18090
No known key found for this signature in database
GPG key ID: 6F294212DEAAC530
18 changed files with 218 additions and 84 deletions

View file

@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
FROM docker.io/crystallang/crystal:1.6.2-alpine as crystal FROM docker.io/crystallang/crystal:1.7.2-alpine as crystal
FROM tdewolff/minify:latest as minify FROM tdewolff/minify:latest as minify
FROM crystal as micrate-deps FROM crystal as micrate-deps

View file

@ -72,8 +72,25 @@ CREATE TABLE assignments(
teacher_id int NOT NULL REFERENCES teachers(id) teacher_id int NOT NULL REFERENCES teachers(id)
); );
CREATE TABLE configs(
id serial PRIMARY KEY,
active boolean NOT NULL,
can_vote boolean NOT NULL
);
CREATE UNIQUE INDEX ON configs(active)
WHERE
active;
INSERT INTO
configs(active, can_vote)
VALUES
(TRUE, FALSE);
-- +micrate Down -- +micrate Down
-- SQL section ' Down ' is executed when this migration is rolled back -- SQL section ' Down ' is executed when this migration is rolled back
DROP TABLE configs;
DROP TABLE assignments; DROP TABLE assignments;
DROP TABLE teacher_votes; DROP TABLE teacher_votes;

View file

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

View file

@ -26,7 +26,7 @@ targets:
backend: backend:
main: src/backend.cr main: src/backend.cr
crystal: 1.6.2 crystal: 1.7.2
dependencies: dependencies:
clear: clear:

View file

@ -216,6 +216,18 @@ module Backend
Vote.new(vote) Vote.new(vote)
end end
@[GraphQL::Field]
# Sets if students are allowed to vote
def set_voting(context : Context, state : Bool) : Bool?
context.admin!
config = Db::Config.query.where { active == true }.first!
config.can_vote = state
config.save!
state
end
end end
end end
end end

View file

@ -143,9 +143,10 @@ module Backend
@[GraphQL::Field] @[GraphQL::Field]
# Students can vote # Students can vote
def students_can_vote : Bool def students_can_vote : Bool
teacher_role_count = Db::User.query.where(role: Db::UserRole::Teacher).count # teacher_role_count = Db::User.query.where(role: Db::UserRole::Teacher).count
teacher_role_count > 0 && teacher_role_count == Db::Teacher.query.count # teacher_role_count > 0 && teacher_role_count == Db::Teacher.query.count
Db::Config.query.select(:can_vote).where { active == true }.first!.can_vote
end end
@[GraphQL::Field] @[GraphQL::Field]

View file

@ -102,13 +102,13 @@ module Backend
@[GraphQL::Field] @[GraphQL::Field]
# User's external ID # User's external ID
def external_id : Int32 def external_id : Int32?
case @model.role.to_api case @model.role
when Db::UserRole::Teacher when Db::UserRole::Teacher
@model.teacher @model.teacher.try(&.id)
when Db::UserRole::Student when Db::UserRole::Student
@model.student @model.student.try(&.id)
end.not_nil!.id end
end end
@[GraphQL::Field] @[GraphQL::Field]

View file

@ -0,0 +1,11 @@
module Backend::Db
class Config
include Clear::Model
self.table = :configs
primary_key type: serial
column active : Bool
column can_vote : Bool
end
end

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Benutzeraccounts | Mentorenwahl</title> <title>Benutzerexport</title>
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=jetbrains-mono:400" rel="stylesheet" /> <link href="https://fonts.bunny.net/css?family=jetbrains-mono:400" rel="stylesheet" />
@ -68,6 +68,12 @@
height: 1px; height: 1px;
background: black; background: black;
} }
.link {
font-style: italic;
font-size: small;
font-weight: lighter;
}
</style> </style>
</head> </head>
<body> <body>
@ -123,6 +129,7 @@
<%- group.each do |student| %> <%- group.each do |student| %>
<%- if student -%> <%- if student -%>
<td class="border padded"> <td class="border padded">
<span class="link">mentorenwahl.dergrimm.net</span>
<table width="100%"> <table width="100%">
<tr> <tr>
<td> <td>
@ -170,10 +177,11 @@
<%- group.each do |teacher| %> <%- group.each do |teacher| %>
<%- if teacher -%> <%- if teacher -%>
<td class="border padded"> <td class="border padded">
<span class="link">mentorenwahl.dergrimm.net</span>
<table width="100%"> <table width="100%">
<tr> <tr>
<td> <td>
<%= teacher.last_name %>, <%= teacher.first_name %> <%= (teacher.first_name ? "#{teacher.last_name}, #{teacher.first_name}" : teacher.last_name).rstrip.rchop(',') %>
<hr /> <hr />
</td> </td>
</tr> </tr>

View file

@ -0,0 +1,3 @@
mutation SetVoting($state: Boolean!) {
setVoting(state: $state)
}

View file

@ -5,6 +5,7 @@ query Users {
lastName lastName
username username
role role
externalId
admin admin
} }
} }

View file

@ -71,7 +71,7 @@ scalar UUID
type User { type User {
admin: Boolean! admin: Boolean!
externalId: Int! externalId: Int
firstName: String! firstName: String!
id: Int! id: Int!
lastName: String! lastName: String!
@ -162,11 +162,11 @@ type LoginPayload {
type Mutation { type Mutation {
createVote(input: VoteCreateInput!): Vote createVote(input: VoteCreateInput!): Vote
deleteUser(id: Int!): Int
login(password: String!, username: String!): LoginPayload login(password: String!, username: String!): LoginPayload
logout: UUID logout: UUID
registerTeacher(input: TeacherInput!): Teacher! registerTeacher(input: TeacherInput!): Teacher!
revokeToken(token: UUID!): UUID! revokeToken(token: UUID!): UUID!
setVoting(state: Boolean!): Boolean
startAssignment: Boolean startAssignment: Boolean
} }

View file

@ -2,5 +2,6 @@ pub mod login;
pub mod logout; pub mod logout;
pub mod register_teacher; pub mod register_teacher;
pub mod revoke_token; pub mod revoke_token;
pub mod set_voting;
pub mod start_assignment; pub mod start_assignment;
pub mod vote; pub mod vote;

View file

@ -0,0 +1,10 @@
use graphql_client::GraphQLQuery;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/mutations/set_voting.graphql",
response_derives = "Debug",
skip_serializing_none
)]
pub struct SetVoting;

View file

@ -7,15 +7,15 @@ use crate::graphql;
pub enum Msg { pub enum Msg {
DoneFetchingCanVote { DoneFetchingCanVote {
errors: Option<Vec<String>>, errors: graphql::Errors,
can_vote: bool, can_vote: bool,
}, },
DoneFetchingConfig { DoneFetchingConfig {
errors: Option<Vec<String>>, errors: graphql::Errors,
min: usize, min: usize,
}, },
DoneFetchingTeachers { DoneFetchingTeachers {
errors: Option<Vec<String>>, errors: graphql::Errors,
teachers: Vec<graphql::queries::teachers::teachers::TeachersTeachers>, teachers: Vec<graphql::queries::teachers::teachers::TeachersTeachers>,
}, },
RadioSelect { RadioSelect {
@ -23,7 +23,7 @@ pub enum Msg {
teacher: i64, teacher: i64,
}, },
Submit, Submit,
Vote(Option<Vec<String>>), Vote(graphql::Errors),
AddSlot, AddSlot,
RemoveSlot, RemoveSlot,
Reset, Reset,
@ -38,7 +38,7 @@ pub struct StudentVoteProps {
pub struct StudentVote { pub struct StudentVote {
fetching: bool, fetching: bool,
can_vote: bool, can_vote: bool,
errors: Option<Vec<String>>, errors: graphql::Errors,
min: usize, min: usize,
slots: usize, slots: usize,
teachers: Vec<graphql::queries::teachers::teachers::TeachersTeachers>, teachers: Vec<graphql::queries::teachers::teachers::TeachersTeachers>,

View file

@ -83,16 +83,19 @@ impl Component for TeacherRegistration {
html! { html! {
<> <>
<form {onsubmit}> <form {onsubmit}>
<div> <div class={classes!("field")}>
<label for="max_students"> <label for="max_students">
{ "Maximale Anzahl von Schülern:" } { "Maximale Anzahl von Schülern:" }
<br />
<input ref={self.max_students.clone()} type="number" id="max_students" name="max_students" min=0 />
</label> </label>
<div class={classes!("control")}>
<input ref={self.max_students.clone()} type="number" id="max_students" name="max_students" min=0 class={classes!("input")} />
</div>
</div> </div>
<div> <div class={classes!("field")}>
<input type="submit" value="Submit" /> <div class={classes!("control")}>
<input type="submit" value="Speichern" class={classes!("button", "is-success")} />
</div>
</div> </div>
<components::graphql_errors::GraphQLErrors errors={self.errors.clone()} /> <components::graphql_errors::GraphQLErrors errors={self.errors.clone()} />

View file

@ -5,6 +5,12 @@ use crate::components;
use crate::graphql; use crate::graphql;
pub enum Msg { pub enum Msg {
DoneFetchingCanVote {
errors: graphql::Errors,
can_vote: bool,
},
UpdateCanVote,
UpdateCanVoteDone(graphql::Errors),
StartAssignment, StartAssignment,
StartAssignmentDone(Option<Vec<String>>), StartAssignmentDone(Option<Vec<String>>),
} }
@ -16,18 +22,71 @@ pub struct AssignmentsProps {
pub struct Assignments { pub struct Assignments {
errors: Option<Vec<String>>, errors: Option<Vec<String>>,
fetching_students_can_vote: bool,
students_can_vote: bool,
} }
impl Component for Assignments { impl Component for Assignments {
type Message = Msg; type Message = Msg;
type Properties = AssignmentsProps; type Properties = AssignmentsProps;
fn create(_ctx: &Context<Self>) -> Self { fn create(ctx: &Context<Self>) -> Self {
Self { errors: None } let client = graphql::client(Some(&ctx.props().token)).unwrap();
ctx.link().send_future(async move {
let response = post_graphql::<graphql::queries::students_can_vote::StudentsCanVote, _>(
&client,
graphql::URL.as_str(),
graphql::queries::students_can_vote::students_can_vote::Variables,
)
.await
.unwrap();
Msg::DoneFetchingCanVote {
errors: graphql::convert(response.errors),
can_vote: response.data.unwrap().students_can_vote,
}
});
Self {
errors: None,
fetching_students_can_vote: true,
students_can_vote: false,
}
} }
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg { match msg {
Msg::DoneFetchingCanVote { errors, can_vote } => {
self.errors = errors;
self.students_can_vote = can_vote;
self.fetching_students_can_vote = false;
true
}
Msg::UpdateCanVote => {
self.students_can_vote = !self.students_can_vote;
let state = self.students_can_vote;
let client = graphql::client(Some(&ctx.props().token)).unwrap();
ctx.link().send_future(async move {
let response = post_graphql::<graphql::mutations::set_voting::SetVoting, _>(
&client,
graphql::URL.as_str(),
graphql::mutations::set_voting::set_voting::Variables { state },
)
.await
.unwrap();
Msg::UpdateCanVoteDone(graphql::convert(response.errors))
});
true
}
Msg::UpdateCanVoteDone(errors) => {
self.errors = errors;
true
}
Msg::StartAssignment => { Msg::StartAssignment => {
let client = graphql::client(Some(&ctx.props().token)).unwrap(); let client = graphql::client(Some(&ctx.props().token)).unwrap();
ctx.link().send_future(async move { ctx.link().send_future(async move {
@ -60,20 +119,34 @@ impl Component for Assignments {
<thead> <thead>
<tr> <tr>
<th>{ "Aktion" }</th> <th>{ "Aktion" }</th>
<th>{ "Optionen" }</th> <th>{ "Option" }</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr>
if self.fetching_students_can_vote {
<td><components::fetching::Fetching /></td>
} else {
<td>{ "Wahl erlauben" }</td>
<td>
<button onclick={ctx.link().callback(|_| Msg::UpdateCanVote)}
class={classes!("button", if self.students_can_vote { "is-success" } else { "is-danger" })}
>
if self.students_can_vote {
{ "An" }
} else {
{ "Aus" }
}
</button>
</td>
}
</tr>
<tr> <tr>
<td>{ "Zuweisung starten" }</td> <td>{ "Zuweisung starten" }</td>
<td> <td>
<ul> <button onclick={ctx.link().callback(|_| Msg::StartAssignment)} class={classes!("button")}>
<li> { "Ausführen" }
<button class={classes!("button")} onclick={ctx.link().callback(|_| Msg::StartAssignment)}> </button>
{ "Ausführen" }
</button>
</li>
</ul>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View file

@ -7,6 +7,7 @@ use crate::graphql;
pub enum Msg { pub enum Msg {
DoneFetching { DoneFetching {
errors: graphql::Errors, errors: graphql::Errors,
users: Option<Vec<graphql::queries::users::users::UsersUsers>>,
students: Option<Vec<graphql::queries::users_by_role::students::StudentsStudents>>, students: Option<Vec<graphql::queries::users_by_role::students::StudentsStudents>>,
teachers: Option<Vec<graphql::queries::users_by_role::teachers::TeachersTeachers>>, teachers: Option<Vec<graphql::queries::users_by_role::teachers::TeachersTeachers>>,
}, },
@ -29,6 +30,7 @@ pub struct Users {
tab: UsersTab, tab: UsersTab,
fetching: bool, fetching: bool,
errors: graphql::Errors, errors: graphql::Errors,
users: Option<Vec<graphql::queries::users::users::UsersUsers>>,
students: Option<Vec<graphql::queries::users_by_role::students::StudentsStudents>>, students: Option<Vec<graphql::queries::users_by_role::students::StudentsStudents>>,
teachers: Option<Vec<graphql::queries::users_by_role::teachers::TeachersTeachers>>, teachers: Option<Vec<graphql::queries::users_by_role::teachers::TeachersTeachers>>,
} }
@ -40,6 +42,13 @@ impl Component for Users {
fn create(ctx: &Context<Self>) -> Self { fn create(ctx: &Context<Self>) -> Self {
let client = graphql::client(Some(&ctx.props().token)).unwrap(); let client = graphql::client(Some(&ctx.props().token)).unwrap();
ctx.link().send_future(async move { ctx.link().send_future(async move {
let users_response = post_graphql::<graphql::queries::users::Users, _>(
&client,
graphql::URL.as_str(),
graphql::queries::users::users::Variables,
)
.await
.unwrap();
let students_response = post_graphql::<graphql::queries::users_by_role::Students, _>( let students_response = post_graphql::<graphql::queries::users_by_role::Students, _>(
&client, &client,
graphql::URL.as_str(), graphql::URL.as_str(),
@ -56,20 +65,32 @@ impl Component for Users {
.unwrap(); .unwrap();
let x = [ let x = [
users_response.errors.map_or_else(|| vec![], |e| e),
students_response.errors.map_or_else(|| vec![], |e| e), students_response.errors.map_or_else(|| vec![], |e| e),
teachers_response.errors.map_or_else(|| vec![], |e| e), teachers_response.errors.map_or_else(|| vec![], |e| e),
] ]
.concat(); .concat();
if x.is_empty() { if x.is_empty() {
let mut users = users_response.data.unwrap().users.unwrap();
users.sort_by_key(|x| x.id);
let mut students = students_response.data.unwrap().students.unwrap();
students.sort_by_key(|x| x.id);
let mut teachers = teachers_response.data.unwrap().teachers;
teachers.sort_by_key(|x| x.id);
Msg::DoneFetching { Msg::DoneFetching {
errors: None, errors: None,
students: Some(students_response.data.unwrap().students.unwrap()), users: Some(users),
teachers: Some(teachers_response.data.unwrap().teachers), students: Some(students),
teachers: Some(teachers),
} }
} else { } else {
Msg::DoneFetching { Msg::DoneFetching {
errors: graphql::convert(Some(x)), errors: graphql::convert(Some(x)),
users: None,
students: None, students: None,
teachers: None, teachers: None,
} }
@ -80,6 +101,7 @@ impl Component for Users {
tab: UsersTab::All, tab: UsersTab::All,
fetching: true, fetching: true,
errors: None, errors: None,
users: None,
students: None, students: None,
teachers: None, teachers: None,
} }
@ -89,11 +111,14 @@ impl Component for Users {
match msg { match msg {
Msg::DoneFetching { Msg::DoneFetching {
errors, errors,
users,
students, students,
teachers, teachers,
} => { } => {
self.fetching = false; self.fetching = false;
self.errors = errors; self.errors = errors;
self.users = users;
self.students = students; self.students = students;
self.teachers = teachers; self.teachers = teachers;
@ -142,58 +167,39 @@ impl Component for Users {
<th>{ "Vorname" }</th> <th>{ "Vorname" }</th>
<th>{ "Benutzername" }</th> <th>{ "Benutzername" }</th>
<th>{ "Rolle" }</th> <th>{ "Rolle" }</th>
<th><abbr title="ID des externen Benutzerobjekts">{ "Externe Rollen-ID" }</abbr></th> <th><abbr title="ID des externen Benutzerobjekts">{ "Rollen-ID" }</abbr></th>
<th>{ "Admin" }</th> <th>{ "Admin" }</th>
<th><abbr title="Wenn Schüler, dann ob Lehrer gewählt wurde">{ "Gewählt" }</abbr></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ {
for self.students.as_ref().unwrap().iter().map(|s| html! { for self.users.as_ref().unwrap().iter().map(|u| html! {
<tr> <tr>
<td><code>{ &s.user.id }</code></td> <td><code>{ &u.id }</code></td>
<td>{ &s.user.last_name }</td> <td>{ &u.last_name }</td>
<td>{ &s.user.first_name }</td> <td>{ &u.first_name }</td>
<td><code>{ &s.user.username }</code></td> <td><code>{ &u.username }</code></td>
<td> <td>
<code> <code>
{ {
match &s.user.role { match &u.role {
graphql::queries::users_by_role::students::UserRole::Student => "S", graphql::queries::users::users::UserRole::Student => "S",
graphql::queries::users_by_role::students::UserRole::Teacher => "T", graphql::queries::users::users::UserRole::Teacher => "T",
graphql::queries::users_by_role::students::UserRole::Other(_) => "N/A", graphql::queries::users::users::UserRole::Other(_) => "N/A",
} }
} }
</code> </code>
</td> </td>
<td><code>{ &s.id }</code></td> // <td><code>{ if let Some(id) = u.external_id { id } else { "N/A" } }</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> <td>
<code> if let Some(id) = u.external_id {
{ <code>{ id }</code>
match &t.user.role { } else {
graphql::queries::users_by_role::teachers::UserRole::Student => "S", <i>{ "N/A" }</i>
graphql::queries::users_by_role::teachers::UserRole::Teacher => "T", }
graphql::queries::users_by_role::teachers::UserRole::Other(_) => "N/A",
}
}
</code>
</td> </td>
<td><code>{ &t.id }</code></td> <td><code>{ if u.admin { 1 } else { 0 } }</code></td>
<td><code>{ if t.user.admin { 1 } else { 0 } }</code></td>
<td><code>{ "N/A" }</code></td>
</tr> </tr>
}) })
} }
@ -238,7 +244,6 @@ impl Component for Users {
<th>{ "Nachname" }</th> <th>{ "Nachname" }</th>
<th>{ "Vorname" }</th> <th>{ "Vorname" }</th>
<th>{ "Benutzername" }</th> <th>{ "Benutzername" }</th>
<th>{ "Email" }</th>
<th>{ "Benutzer-ID" }</th> <th>{ "Benutzer-ID" }</th>
<th>{ "Admin" }</th> <th>{ "Admin" }</th>
</tr> </tr>
@ -251,17 +256,6 @@ impl Component for Users {
<td>{ &t.user.last_name }</td> <td>{ &t.user.last_name }</td>
<td>{ &t.user.first_name }</td> <td>{ &t.user.first_name }</td>
<td><code>{ &t.user.username }</code></td> <td><code>{ &t.user.username }</code></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>{ &t.user.id }</code></td>
<td><code>{ if t.user.admin { 1 } else { 0 } }</code></td> <td><code>{ if t.user.admin { 1 } else { 0 } }</code></td>
</tr> </tr>