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

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
# 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 crystal as micrate-deps

View File

@ -72,8 +72,25 @@ CREATE TABLE assignments(
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
-- SQL section ' Down ' is executed when this migration is rolled back
DROP TABLE configs;
DROP TABLE assignments;
DROP TABLE teacher_votes;

View File

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

View File

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

View File

@ -216,6 +216,18 @@ module Backend
Vote.new(vote)
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

View File

@ -143,9 +143,10 @@ module Backend
@[GraphQL::Field]
# Students can vote
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
@[GraphQL::Field]

View File

@ -102,13 +102,13 @@ module Backend
@[GraphQL::Field]
# User's external ID
def external_id : Int32
case @model.role.to_api
def external_id : Int32?
case @model.role
when Db::UserRole::Teacher
@model.teacher
@model.teacher.try(&.id)
when Db::UserRole::Student
@model.student
end.not_nil!.id
@model.student.try(&.id)
end
end
@[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 http-equiv="X-UA-Compatible" content="IE=edge" />
<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 href="https://fonts.bunny.net/css?family=jetbrains-mono:400" rel="stylesheet" />
@ -68,6 +68,12 @@
height: 1px;
background: black;
}
.link {
font-style: italic;
font-size: small;
font-weight: lighter;
}
</style>
</head>
<body>
@ -123,6 +129,7 @@
<%- group.each do |student| %>
<%- if student -%>
<td class="border padded">
<span class="link">mentorenwahl.dergrimm.net</span>
<table width="100%">
<tr>
<td>
@ -170,10 +177,11 @@
<%- group.each do |teacher| %>
<%- if teacher -%>
<td class="border padded">
<span class="link">mentorenwahl.dergrimm.net</span>
<table width="100%">
<tr>
<td>
<%= teacher.last_name %>, <%= teacher.first_name %>
<%= (teacher.first_name ? "#{teacher.last_name}, #{teacher.first_name}" : teacher.last_name).rstrip.rchop(',') %>
<hr />
</td>
</tr>

View File

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

View File

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

View File

@ -71,7 +71,7 @@ scalar UUID
type User {
admin: Boolean!
externalId: Int!
externalId: Int
firstName: String!
id: Int!
lastName: String!
@ -162,11 +162,11 @@ type LoginPayload {
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!
setVoting(state: Boolean!): Boolean
startAssignment: Boolean
}

View File

@ -2,5 +2,6 @@ pub mod login;
pub mod logout;
pub mod register_teacher;
pub mod revoke_token;
pub mod set_voting;
pub mod start_assignment;
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 {
DoneFetchingCanVote {
errors: Option<Vec<String>>,
errors: graphql::Errors,
can_vote: bool,
},
DoneFetchingConfig {
errors: Option<Vec<String>>,
errors: graphql::Errors,
min: usize,
},
DoneFetchingTeachers {
errors: Option<Vec<String>>,
errors: graphql::Errors,
teachers: Vec<graphql::queries::teachers::teachers::TeachersTeachers>,
},
RadioSelect {
@ -23,7 +23,7 @@ pub enum Msg {
teacher: i64,
},
Submit,
Vote(Option<Vec<String>>),
Vote(graphql::Errors),
AddSlot,
RemoveSlot,
Reset,
@ -38,7 +38,7 @@ pub struct StudentVoteProps {
pub struct StudentVote {
fetching: bool,
can_vote: bool,
errors: Option<Vec<String>>,
errors: graphql::Errors,
min: usize,
slots: usize,
teachers: Vec<graphql::queries::teachers::teachers::TeachersTeachers>,

View File

@ -83,16 +83,19 @@ impl Component for TeacherRegistration {
html! {
<>
<form {onsubmit}>
<div>
<div class={classes!("field")}>
<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=0 />
</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>
<input type="submit" value="Submit" />
<div class={classes!("field")}>
<div class={classes!("control")}>
<input type="submit" value="Speichern" class={classes!("button", "is-success")} />
</div>
</div>
<components::graphql_errors::GraphQLErrors errors={self.errors.clone()} />

View File

@ -5,6 +5,12 @@ use crate::components;
use crate::graphql;
pub enum Msg {
DoneFetchingCanVote {
errors: graphql::Errors,
can_vote: bool,
},
UpdateCanVote,
UpdateCanVoteDone(graphql::Errors),
StartAssignment,
StartAssignmentDone(Option<Vec<String>>),
}
@ -16,18 +22,71 @@ pub struct AssignmentsProps {
pub struct Assignments {
errors: Option<Vec<String>>,
fetching_students_can_vote: bool,
students_can_vote: bool,
}
impl Component for Assignments {
type Message = Msg;
type Properties = AssignmentsProps;
fn create(_ctx: &Context<Self>) -> Self {
Self { errors: None }
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::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 {
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 => {
let client = graphql::client(Some(&ctx.props().token)).unwrap();
ctx.link().send_future(async move {
@ -60,20 +119,34 @@ impl Component for Assignments {
<thead>
<tr>
<th>{ "Aktion" }</th>
<th>{ "Optionen" }</th>
<th>{ "Option" }</th>
</tr>
</thead>
<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>
<td>{ "Zuweisung starten" }</td>
<td>
<ul>
<li>
<button class={classes!("button")} onclick={ctx.link().callback(|_| Msg::StartAssignment)}>
{ "Ausführen" }
</button>
</li>
</ul>
<button onclick={ctx.link().callback(|_| Msg::StartAssignment)} class={classes!("button")}>
{ "Ausführen" }
</button>
</td>
</tr>
</tbody>

View File

@ -7,6 +7,7 @@ use crate::graphql;
pub enum Msg {
DoneFetching {
errors: graphql::Errors,
users: Option<Vec<graphql::queries::users::users::UsersUsers>>,
students: Option<Vec<graphql::queries::users_by_role::students::StudentsStudents>>,
teachers: Option<Vec<graphql::queries::users_by_role::teachers::TeachersTeachers>>,
},
@ -29,6 +30,7 @@ pub struct Users {
tab: UsersTab,
fetching: bool,
errors: graphql::Errors,
users: Option<Vec<graphql::queries::users::users::UsersUsers>>,
students: Option<Vec<graphql::queries::users_by_role::students::StudentsStudents>>,
teachers: Option<Vec<graphql::queries::users_by_role::teachers::TeachersTeachers>>,
}
@ -40,6 +42,13 @@ impl Component for Users {
fn create(ctx: &Context<Self>) -> Self {
let client = graphql::client(Some(&ctx.props().token)).unwrap();
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, _>(
&client,
graphql::URL.as_str(),
@ -56,20 +65,32 @@ impl Component for Users {
.unwrap();
let x = [
users_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),
]
.concat();
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 {
errors: None,
students: Some(students_response.data.unwrap().students.unwrap()),
teachers: Some(teachers_response.data.unwrap().teachers),
users: Some(users),
students: Some(students),
teachers: Some(teachers),
}
} else {
Msg::DoneFetching {
errors: graphql::convert(Some(x)),
users: None,
students: None,
teachers: None,
}
@ -80,6 +101,7 @@ impl Component for Users {
tab: UsersTab::All,
fetching: true,
errors: None,
users: None,
students: None,
teachers: None,
}
@ -89,11 +111,14 @@ impl Component for Users {
match msg {
Msg::DoneFetching {
errors,
users,
students,
teachers,
} => {
self.fetching = false;
self.errors = errors;
self.users = users;
self.students = students;
self.teachers = teachers;
@ -142,58 +167,39 @@ impl Component for Users {
<th>{ "Vorname" }</th>
<th>{ "Benutzername" }</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><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! {
for self.users.as_ref().unwrap().iter().map(|u| 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><code>{ &u.id }</code></td>
<td>{ &u.last_name }</td>
<td>{ &u.first_name }</td>
<td><code>{ &u.username }</code></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",
match &u.role {
graphql::queries::users::users::UserRole::Student => "S",
graphql::queries::users::users::UserRole::Teacher => "T",
graphql::queries::users::users::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><code>{ if let Some(id) = u.external_id { id } else { "N/A" } }</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>
if let Some(id) = u.external_id {
<code>{ id }</code>
} else {
<i>{ "N/A" }</i>
}
</td>
<td><code>{ &t.id }</code></td>
<td><code>{ if t.user.admin { 1 } else { 0 } }</code></td>
<td><code>{ "N/A" }</code></td>
<td><code>{ if u.admin { 1 } else { 0 } }</code></td>
</tr>
})
}
@ -238,7 +244,6 @@ impl Component for Users {
<th>{ "Nachname" }</th>
<th>{ "Vorname" }</th>
<th>{ "Benutzername" }</th>
<th>{ "Email" }</th>
<th>{ "Benutzer-ID" }</th>
<th>{ "Admin" }</th>
</tr>
@ -251,17 +256,6 @@ impl Component for Users {
<td>{ &t.user.last_name }</td>
<td>{ &t.user.first_name }</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>{ if t.user.admin { 1 } else { 0 } }</code></td>
</tr>