stationslauf/src/station.rs
2025-04-14 14:59:10 +02:00

596 lines
23 KiB
Rust

use crate::{
admin::team::Team, err, models::rating::TeamsAtStationLocation, partials, succ, AppState,
Station,
};
use axum::{
extract::State,
response::{IntoResponse, Redirect},
routing::{get, post},
Form, Router,
};
use maud::{html, Markup, PreEscaped};
use serde::Deserialize;
use sqlx::SqlitePool;
use std::sync::Arc;
use tower_sessions::Session;
async fn view(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path((id, code)): axum::extract::Path<(i64, String)>,
) -> Markup {
let Some(station) = Station::login(&db, id, &code).await else {
let content = html! {
article class="error" {
"Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben."
}
};
return partials::page(content, session, false).await;
};
let teams = TeamsAtStationLocation::for_station(&db, &station).await;
let content = html! {
h1 { (format!("Station {}", station.name)) }
@if let (Some(lat), Some(lng)) = (station.lat, station.lng) {
article {
details open[(!station.ready)]{
summary { "Infos" }
@let first_teams = Team::all_with_first_station(&db, &station).await;
@if first_teams.is_empty() {
div {
"Du musst keine Teams zu deiner Station mitnehmen"
}
} @else{
b { (format!("Nimm bitte folgende {} Teams am Anfang zu deiner Station mit:", first_teams.len())) }
ol {
@for team in first_teams {
li {
b {
(team.name)
}
ul {
@if let Some(amount_people) = team.amount_people {
li {
(amount_people)
" Personen"
}
}
@if let Some(notes) = team.notes {
li {
(notes)
}
}
}
}
}
}
}
b { "Hier befindet sich deine Station:" }
div id="map" style="height: 500px" {}
script { (format!("
const map = L.map('map').setView([{lat}, {lng}], 14);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
attribution: '© OpenStreetMap contributors'
}}).addTo(map);
const myIcon = L.icon({{
iconUrl: '/marker.png',
iconAnchor: [12, 41]
}});
currentMarker = L.marker([{lat}, {lng}], {{icon: myIcon}}).addTo(map);
map.setView([{lat}, {lng}], 14);
"))
}
div {
sub {
a href=(format!("https://www.google.com/maps?q={lat},{lng}")) target="_blank" {
"Google Maps..."
}
}
}
hr;
a href=(format!("/s/{id}/{code}/ready")){
@if station.ready {
"Bin mit der Station doch noch nicht bereit..."
} @else {
button { "Ich bin bei meiner Station und bin bereit zu starten!" }
}
}
}
}
}
article {
"Insgesamt sollten "
(teams.total_teams)
" Teams zu deiner Station kommen."
progress value=(teams.total_teams-teams.not_yet_here.len() as i64) max=(teams.total_teams) {}
}
@if !teams.not_yet_here.is_empty() {
form action=(format!("/s/{id}/{code}/new-waiting")) method="post" {
fieldset role="group" {
select name="team_id" aria-label="Team auswählen" required {
@for team in &teams.not_yet_here {
option value=(team.id) {
(team.name)
}
}
}
input type="submit" value="Neues Team da";
}
}
}
h2 { "Teams bei dir" }
@if !teams.doing.is_empty() {
@for (team, rating) in teams.doing {
article {
details {
summary {
em data-tooltip="Aktiv" { "🎬 " }
(team.name)
small {
" (seit "
(rating.local_time_doing())
")"
}
"✏️"
a href=(format!("/s/{id}/{code}/team-finished/{}", team.id)) {
button { "Team fertig" }
}
}
form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" {
label {
"Notizen"
@if let Some(notes) = &rating.notes {
input type="text" name="notes" value=(notes);
} @else {
input type="text" name="notes";
}
}
input type="submit" value="Notizen speichern";
}
a href=(format!("/s/{id}/{code}/remove-doing/{}", team.id))
onclick="return confirm('Bist du sicher, dass das Team noch nicht bei dir arbeitet? Das Team wird zurück auf die Warte-Position gesetzt');" {
"🗑️"
}
}
}
}
}
@if !teams.waiting.is_empty() {
@for (team, rating) in teams.waiting {
article {
details {
summary {
em data-tooltip="Wartend" { "" }
(team.name)
small {
" (seit "
(rating.local_time_arrived_at())
")"
}
"✏️"
a href=(format!("/s/{id}/{code}/team-starting/{}", team.id)) {
button { "Team startet" }
}
}
form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" {
label {
"Notizen"
@if let Some(notes) = &rating.notes {
input type="text" name="notes" value=(notes);
} @else {
input type="text" name="notes";
}
}
input type="submit" value="Notizen speichern";
}
a href=(format!("/s/{id}/{code}/remove-waiting/{}", team.id))
onclick="return confirm('Bist du sicher, dass das Team noch nicht bei dir ist? Das kann _NICHT_ mehr rückgängig gemacht werden.');" {
"🗑️"
}
}
}
}
}
@if !teams.left_not_yet_rated.is_empty() {
h2 { "Noch zu bewerten" }
article class="warning" {
"Noch keine Punkte für diese Gruppe vergeben ⤵️"
}
@for (team, rating) in teams.left_not_yet_rated {
article {
em data-tooltip="Zu bewerten" { "" }
(team.name)
small {
" (um "
(rating.local_time_left())
" gegangen)"
}
form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" {
label {
@if let Some(points) = rating.points {
span { (points) " Punkte" }
input type="range" name="points" min="0" max="10" value=(points)
onchange="if(!confirm('Du hast die Gruppe bereits bewertet. Bist du sicher, dass du deine Bewertung nochmal ändern möchtest?')) { this.value = this.defaultValue; this.previousElementSibling.textContent = this.defaultValue + ' Punkte'; }"
oninput="this.previousElementSibling.textContent = this.value + ' Punkte'" {}
} @else {
span { "0 Punkte" }
input type="range" name="points" min="0" max="10" value="0" oninput="this.previousElementSibling.textContent = this.value + ' Punkte'" {}
}
}
label {
"Notizen"
@if let Some(notes) = &rating.notes {
input type="text" name="notes" value=(notes);
} @else {
input type="text" name="notes";
}
}
input type="submit" value="Speichern";
}
a href=(format!("/s/{id}/{code}/remove-left/{}", team.id))
onclick="return confirm('Bist du sicher, dass das Team noch nicht bei dir fertig ist? Das Team wird zurück auf die Arbeits-Position gesetzt');" {
"🗑️"
}
}
}
}
h2 { "Was bisher geschah" }
@if !teams.left_and_rated.is_empty() {
@for (team, rating) in teams.left_and_rated {
article {
details {
summary {
em data-tooltip="Schon bewertet" { "" }
(team.name)
(PreEscaped(" &rarr; "))
(rating.points.unwrap())
" Punkte"
}
small {
" (um "
(rating.local_time_arrived_at())
" eingetroffen, um "
(rating.local_time_doing())
" gestartet und um "
(rating.local_time_left())
" gegangen)"
}
form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" {
label {
@if let Some(points) = rating.points {
span { (points) " Punkte" }
input type="range" name="points" min="0" max="10" value=(points)
onchange="if(!confirm('Du hast die Gruppe bereits bewertet. Bist du sicher, dass du deine Bewertung nochmal ändern möchtest?')) { this.value = this.defaultValue; this.previousElementSibling.textContent = this.defaultValue + ' Punkte'; }"
oninput="this.previousElementSibling.textContent = this.value + ' Punkte'" {}
}
}
label {
"Notizen"
@if let Some(notes) = &rating.notes {
input type="text" name="notes" value=(notes);
} @else {
input type="text" name="notes";
}
}
input type="submit" value="Speichern";
}
a href=(format!("/s/{id}/{code}/remove-left/{}", team.id))
onclick="return confirm('Bist du sicher, dass das Team noch nicht bei dir fertig ist? Das Team wird zurück auf die Arbeits-Position gesetzt');" {
"🗑️"
}
}
}
}
} @else {
"Du hast bisher noch keine Teams bewertet."
}
};
let use_map = station.lat.is_some() && station.lng.is_some();
partials::page(content, session, use_map).await
}
async fn ready(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path((id, code)): axum::extract::Path<(i64, String)>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
err!(
session,
"Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben."
);
return Redirect::to("/s/{id}/{code}");
};
station.switch_ready(&db).await;
succ!(session, "Erfolgreich geändert");
Redirect::to(&format!("/s/{id}/{code}"))
}
#[derive(Deserialize)]
struct NewWaitingForm {
team_id: i64,
}
async fn new_waiting(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path((id, code)): axum::extract::Path<(i64, String)>,
Form(form): Form<NewWaitingForm>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
err!(
session,
"Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben."
);
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, form.team_id).await else {
err!(
session,
"Konnte das Team der Warteschlange nicht hinzufügen, weil ein Team mit ID {} nicht existiert",
form.team_id
);
return Redirect::to("/s/{id}/{code}");
};
match station.new_team_waiting(&db, &team).await {
Ok(()) => succ!(session, "Team der Warteschlange hinzugefügt"),
Err(e) => err!(session, "{e}"),
}
Redirect::to(&format!("/s/{id}/{code}"))
}
async fn remove_waiting(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
err!(
session,
"Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben."
);
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, team_id).await else {
err!(
session,
"Konnte das Team nicht von der Warteschlange entfernen, weil ein Team mit ID {} nicht existiert",
team_id
);
return Redirect::to("/s/{id}/{code}");
};
match station.remove_team_waiting(&db, &team).await {
Ok(()) => succ!(session, "Team der Warteschlange gelöscht"),
Err(e) => err!(session, "{e}"),
}
Redirect::to(&format!("/s/{id}/{code}"))
}
async fn team_starting(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
err!(
session,
"Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben."
);
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, team_id).await else {
err!(
session,
"Team kann nicht starten, weil ein Team mit ID {} nicht existiert",
team_id
);
return Redirect::to("/s/{id}/{code}");
};
match station.team_starting(&db, &team).await {
Ok(()) => succ!(session, "Team der Warteschlange gelöscht"),
Err(e) => err!(session, "{e}"),
}
Redirect::to(&format!("/s/{id}/{code}"))
}
async fn remove_doing(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
err!(
session,
"Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben."
);
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, team_id).await else {
err!(
session,
"Konnte das Team nicht zur Warteschlange hinzufügen, weil ein Team mit ID {} nicht existiert",
team_id
);
return Redirect::to("/s/{id}/{code}");
};
match station.remove_team_doing(&db, &team).await {
Ok(()) => succ!(session, "Team zur Warteschlange hinzugefügt"),
Err(e) => err!(session, "{e}"),
}
Redirect::to(&format!("/s/{id}/{code}"))
}
async fn team_finished(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
err!(
session,
"Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben."
);
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, team_id).await else {
err!(
session,
"Team kann nicht beenden, weil ein Team mit ID {} nicht existiert",
team_id
);
return Redirect::to("/s/{id}/{code}");
};
match station.team_finished(&db, &team).await {
Ok(()) => succ!(session, "Team erfolgreich beendet"),
Err(e) => err!(session, "{e}"),
}
Redirect::to(&format!("/s/{id}/{code}"))
}
async fn remove_left(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
err!(
session,
"Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben."
);
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, team_id).await else {
err!(
session,
"Konnte das Team nicht zur Arbeits-Position hinzufügen, weil ein Team mit ID {} nicht existiert",
team_id
);
return Redirect::to("/s/{id}/{code}");
};
match station.remove_team_left(&db, &team).await {
Ok(()) => succ!(session, "Team zur Arbeitsposition hinzugefügt"),
Err(e) => err!(session, "{e}"),
}
Redirect::to(&format!("/s/{id}/{code}"))
}
#[derive(Deserialize)]
struct TeamUpdateForm {
points: Option<i64>,
notes: Option<String>,
}
async fn team_update(
State(db): State<Arc<SqlitePool>>,
session: Session,
axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>,
Form(form): Form<TeamUpdateForm>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
err!(
session,
"Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben."
);
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, team_id).await else {
err!(
session,
"Konnte das Team nicht updaten, weil ein Team mit ID {} nicht existiert",
team_id
);
return Redirect::to("/s/{id}/{code}");
};
match station
.team_update(&db, &team, form.points, form.notes)
.await
{
Ok(()) => succ!(session, "Team bearbeitet"),
Err(e) => err!(session, "{e}"),
}
Redirect::to(&format!("/s/{id}/{code}"))
}
pub(super) fn routes() -> Router<AppState> {
Router::new()
.route("/", get(view))
.route("/ready", get(ready))
.route("/new-waiting", post(new_waiting))
.route("/remove-waiting/{team_id}", get(remove_waiting))
.route("/team-starting/{team_id}", get(team_starting))
.route("/remove-doing/{team_id}", get(remove_doing))
.route("/team-finished/{team_id}", get(team_finished))
.route("/remove-left/{team_id}", get(remove_left))
.route("/team-update/{team_id}", post(team_update))
}
#[cfg(test)]
mod test {
use crate::{router, testdb, Station};
use sqlx::SqlitePool;
use axum_test::TestServer;
#[sqlx::test]
async fn test_wrong_station() {
let pool = testdb!();
Station::create(&pool, "Teststation").await.unwrap();
let server = TestServer::new(router(pool)).unwrap();
let response = server.get("/s/1/wrong-pw").await;
response.assert_text_contains(
"Falscher Quick-Einlogg-Link. Bitte nochmal scannen oder neu eingeben.",
);
}
#[sqlx::test]
async fn test_correct_station() {
let pool = testdb!();
Station::create(&pool, "42-Station").await.unwrap();
let stations = Station::all(&pool).await;
let station = stations.last().unwrap();
let server = TestServer::new(router(pool)).unwrap();
let response = server.get(&format!("/s/1/{}", station.pw)).await;
response.assert_text_contains("42-Station");
}
}