From 5cbdedc37c70da7b6301c18851ea0c412c49c44d Mon Sep 17 00:00:00 2001 From: Philipp Hofer Date: Tue, 22 Apr 2025 12:21:01 +0200 Subject: [PATCH] properly end station run; every team gets a station --- locales/de-AT.yml | 9 +++ migration.sql | 19 +++--- src/admin/mod.rs | 95 ++++++++++++++++++++++++-- src/admin/route/mod.rs | 14 +++- src/admin/station/mod.rs | 18 ++++- src/admin/team/mod.rs | 141 +++++++++++++++++++++++++++++++++++---- src/admin/team/web.rs | 105 ++++++++++++++++++++++++++--- src/models/rating.rs | 8 ++- src/station.rs | 21 +++++- 9 files changed, 390 insertions(+), 40 deletions(-) diff --git a/locales/de-AT.yml b/locales/de-AT.yml index baaee1d..d56f490 100644 --- a/locales/de-AT.yml +++ b/locales/de-AT.yml @@ -15,6 +15,13 @@ login_succ: "Erfolgreich eingeloggt als %{name}" user_id_nonexisting: "User mit ID %{id} gibts ned" person: "Person" people: "Personen" +end_run: "Stationslauf beenden" +restart_run: "Stationslauf wieder aufnehmen" +confirm_end_run: "Willst du den Stationslauf wirklich beenden?" +confirm_restart_run: "Willst du den Stationslauf wirklich wieder aufnehmen?" +run_ended: "Stationslauf erfolgreich beendet" +run_restarted: "Stationslauf erfolgreich wieder aufgenommen" +come_home_with_these_groups: "Gruppen mitnehmen" # # ###### @@ -105,8 +112,10 @@ confirm_station_cancel_team_finished: "Bist du sicher, dass das Team noch nicht # station: "Station" stations: "Stationen" +go_to_stations: "Zu den Stationen" crewless_station: "Station ohne Stationsbetreuer" station_create: "Station erstellen" +no_stations_yet: "Es gibt noch keine Stationen." stations_expl_without_first_word: "sind festgelegte Orte mit spezifischen Aufgaben." station_warning_not_assigned_route: "Noch keiner Route zugeordnet" # should be short -> tooltip station_confirm_deletion: "Bist du sicher, dass die Station gelöscht werden soll? Das kann _NICHT_ mehr rückgängig gemacht werden." diff --git a/migration.sql b/migration.sql index f2c6317..4e6e62d 100644 --- a/migration.sql +++ b/migration.sql @@ -30,8 +30,10 @@ CREATE TABLE IF NOT EXISTS team ( notes TEXT, amount_people INTEGER, first_station_id INTEGER NOT NULL, + last_station_id INTEGER, route_id INTEGER NOT NULL, FOREIGN KEY (first_station_id) REFERENCES station(id), + FOREIGN KEY (last_station_id) REFERENCES station(id), FOREIGN KEY (route_id) REFERENCES route(id) ); @@ -49,15 +51,14 @@ CREATE TABLE IF NOT EXISTS rating ( ); CREATE TABLE IF NOT EXISTS user ( - id INTEGER PRIMARY KEY NOT NULL, - name TEXT NOT NULL UNIQUE, - pw TEXT NOT NULL, - require_new_password_code TEXT + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL UNIQUE, + pw TEXT NOT NULL, + require_new_password_code TEXT ); -create table if not exists "tower_sessions" ( - id text primary key not null, - data blob not null, - expiry_date integer not null +CREATE TABLE IF NOT EXISTS tower_sessions ( + id TEXT PRIMARY KEY NOT NULL, + data BLOB NOT NULL, + expiry_date INTEGER NOT NULL ); - diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 243f8c4..90aebc4 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -1,7 +1,12 @@ -use crate::{AppState, auth::Backend, models::rating::Rating, page}; -use axum::{Router, extract::State, routing::get}; +use crate::{auth::Backend, models::rating::Rating, page, suc, AppState, Station}; +use axum::{ + extract::State, + response::{IntoResponse, Redirect}, + routing::get, + Router, +}; use axum_login::login_required; -use maud::{Markup, html}; +use maud::{html, Markup}; use rand::{ distr::{Distribution, Uniform}, rng, @@ -9,6 +14,7 @@ use rand::{ use route::Route; use sqlx::SqlitePool; use std::sync::Arc; +use team::Team; use tower_sessions::Session; pub(crate) mod route; @@ -107,7 +113,29 @@ async fn highscore(State(db): State>, session: Session) -> Marku page(content, session, false).await } -async fn index(session: Session) -> Markup { +#[derive(PartialEq)] +pub enum RunStatus { + NoStationsYet, + Active, + HasEnded, +} + +impl RunStatus { + pub async fn curr(db: &SqlitePool) -> Self { + let stations = Station::all(db).await; + if stations.is_empty() { + return RunStatus::NoStationsYet; + } + + if station::some_team_has_last_station_id(db).await { + return RunStatus::HasEnded; + } + RunStatus::Active + } +} + +async fn index(State(db): State>, session: Session) -> Markup { + let status = RunStatus::curr(&db).await; let content = html! { nav { ul { @@ -147,16 +175,75 @@ async fn index(session: Session) -> Markup { (t!("admins")) } } + } } + @match status { + RunStatus::NoStationsYet => { + (t!("no_stations_yet")) + (t!("change_that_below")) + a role="button" href="/admin/station" { + (t!("go_to_stations")) + } + }, + RunStatus::Active => { + a href="/admin/end-run" onclick=(format!("return confirm('{}');", t!("confirm_end_run"))) { + button style="background-color: red;" { + (t!("end_run")) + } + } + }, + RunStatus::HasEnded => { + @let stations = Station::all(&db).await; + a href="/admin/restart-run" onclick=(format!("return confirm('{}');", t!("confirm_restart_run"))) { + button style="background-color: red;" { + (t!("restart_run")) + } + } + table { + thead { + tr { + th { (t!("stations")) } + th { (t!("come_home_with_these_groups")) } + } + } + tbody { + @for station in stations { + tr { + td { (station) } + td { + ol { + @for team in Team::all_with_last_station(&db, &station).await { + li { (team) } + } + } + } + } + } + } + } + } } }; page(content, session, false).await } +async fn end_run(State(db): State>, session: Session) -> impl IntoResponse { + Team::end_run(&db).await; + suc!(session, t!("run_ended")); + Redirect::to("/admin") +} +async fn restart_run(State(db): State>, session: Session) -> impl IntoResponse { + Team::restart_run(&db).await; + suc!(session, t!("run_restarted")); + Redirect::to("/admin") +} + pub(super) fn routes() -> Router { Router::new() .route("/", get(index)) .route("/highscore", get(highscore)) + .route("/end-run", get(end_run)) + .route("/restart-run", get(restart_run)) .nest("/station", station::routes()) .nest("/route", route::routes()) .nest("/team", team::routes()) diff --git a/src/admin/route/mod.rs b/src/admin/route/mod.rs index 1f32a97..b7c7ab0 100644 --- a/src/admin/route/mod.rs +++ b/src/admin/route/mod.rs @@ -1,6 +1,6 @@ use crate::{ - AppState, admin::{station::Station, team::Team}, + AppState, }; use axum::Router; use futures::future::join_all; @@ -236,6 +236,18 @@ DROP TABLE temp_pos;", ) } + pub async fn next_station(&self, db: &SqlitePool, target_station: &Station) -> Option { + let stations = Station::all(db).await; + for station in stations { + if let Some(prev_station) = self.prev_station(db, &station).await { + if &prev_station == target_station { + return Some(station); + } + } + } + None + } + pub async fn prev_station(&self, db: &SqlitePool, station: &Station) -> Option { if station.crewless() { return None; diff --git a/src/admin/station/mod.rs b/src/admin/station/mod.rs index 51e8866..57f9587 100644 --- a/src/admin/station/mod.rs +++ b/src/admin/station/mod.rs @@ -28,6 +28,12 @@ pub(crate) struct Station { pub(crate) lng: Option, } +impl PartialEq for Station { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + impl Render for Station { fn render(&self) -> Markup { html! { @@ -406,7 +412,7 @@ impl Station { pub(crate) async fn teams(&self, db: &SqlitePool) -> Vec { sqlx::query_as::<_, Team>( - "SELECT DISTINCT t.id, t.name, t.notes, t.amount_people, t.first_station_id, t.route_id + "SELECT DISTINCT t.id, t.name, t.notes, t.amount_people, t.first_station_id, t.last_station_id, t.route_id FROM team t JOIN route_station rs ON t.route_id = rs.route_id WHERE rs.station_id = ? @@ -420,7 +426,7 @@ ORDER BY LOWER(t.name);", pub(crate) async fn left_teams(&self, db: &SqlitePool) -> Vec { sqlx::query_as::<_, Team>( - "SELECT t.id, t.name, t.notes, t.amount_people, t.first_station_id, t.route_id + "SELECT t.id, t.name, t.notes, t.amount_people, t.first_station_id, t.last_station_id, t.route_id FROM team t JOIN rating r ON t.id = r.team_id WHERE r.station_id = ? @@ -470,6 +476,14 @@ AND r.left_at IS NOT NULL;", } } +pub async fn some_team_has_last_station_id(db: &SqlitePool) -> bool { + sqlx::query_scalar!("SELECT 1 FROM team WHERE last_station_id IS NOT NULL") + .fetch_optional(db) + .await + .unwrap() + .is_some() +} + pub struct TeamOnTheWay { pub(crate) team: Team, pub(crate) left: String, diff --git a/src/admin/team/mod.rs b/src/admin/team/mod.rs index 2b3b7c8..6f1e22d 100644 --- a/src/admin/team/mod.rs +++ b/src/admin/team/mod.rs @@ -1,10 +1,11 @@ use crate::{ - AppState, admin::{route::Route, station::Station}, models::rating::Rating, + AppState, }; use axum::Router; use chrono::{DateTime, Local, NaiveDateTime, Utc}; +use maud::{html, Markup, Render}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; @@ -17,26 +18,33 @@ pub(crate) struct Team { pub(crate) notes: Option, pub(crate) amount_people: Option, first_station_id: i64, + last_station_id: Option, route_id: i64, } +impl Render for Team { + fn render(&self) -> Markup { + html! { + a href=(format!("/admin/team/{}", self.id)){ + (self.name) + } + } + } +} + #[derive(FromRow, Debug, Serialize, Deserialize, PartialEq)] pub(crate) struct LastContactTeam { - team_id: i64, - team_name: String, - station_id: i64, - station_name: String, + team: Team, + station: Option, last_contact_time: Option, } impl LastContactTeam { pub(crate) async fn all_sort_missing(db: &SqlitePool) -> Vec { - sqlx::query_as::<_, Self>( + let rows = sqlx::query_as::<_, (i64, i64, Option)>( "SELECT t.id AS team_id, - t.name AS team_name, last_contact.station_id AS station_id, - s.name AS station_name, last_contact.last_contact_time AS last_contact_time FROM team t @@ -58,7 +66,16 @@ ORDER BY ) .fetch_all(db) .await - .unwrap() + .unwrap(); + let mut ret = Vec::new(); + for (team_id, station_id, last_contact_time) in rows { + ret.push(LastContactTeam { + team: Team::find_by_id(db, team_id).await.expect("db constraints"), + station: Station::find_by_id(db, station_id).await, + last_contact_time, + }); + } + ret } pub(crate) fn local_last_contact(&self) -> Option> { @@ -78,7 +95,7 @@ enum CreateError { impl Team { pub(crate) async fn all(db: &SqlitePool) -> Vec { sqlx::query_as::<_, Self>( - "SELECT id, name, notes, amount_people, first_station_id, route_id FROM team ORDER BY name;", + "SELECT id, name, notes, amount_people, first_station_id, last_station_id, route_id FROM team ORDER BY name;", ) .fetch_all(db) .await @@ -88,7 +105,7 @@ impl Team { pub(crate) async fn all_with_route(db: &SqlitePool, route: &Route) -> Vec { sqlx::query_as!( Team, - "SELECT id, name, notes, amount_people, first_station_id, route_id FROM team WHERE route_id = ?;", + "SELECT id, name, notes, amount_people, first_station_id, last_station_id, route_id FROM team WHERE route_id = ?;", route.id ) .fetch_all(db) @@ -99,7 +116,18 @@ impl Team { pub(crate) async fn all_with_first_station(db: &SqlitePool, station: &Station) -> Vec { sqlx::query_as!( Team, - "select id, name, notes, amount_people, first_station_id, route_id from team where first_station_id = ?;", + "select id, name, notes, amount_people, first_station_id, last_station_id, route_id from team where first_station_id = ?;", + station.id + ) + .fetch_all(db) + .await + .unwrap() + } + + pub(crate) async fn all_with_last_station(db: &SqlitePool, station: &Station) -> Vec { + sqlx::query_as!( + Team, + "select id, name, notes, amount_people, first_station_id, last_station_id, route_id from team where last_station_id = ?;", station.id ) .fetch_all(db) @@ -110,7 +138,7 @@ impl Team { pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option { sqlx::query_as!( Self, - "SELECT id, name, notes, amount_people, first_station_id, route_id FROM team WHERE id = ?", + "SELECT id, name, notes, amount_people, first_station_id, last_station_id, route_id FROM team WHERE id = ?", id ) .fetch_one(db) @@ -144,6 +172,17 @@ impl Team { .unwrap(); } + async fn update_end_station(&self, db: &SqlitePool, station: &Station) { + sqlx::query!( + "UPDATE team SET last_station_id = ? WHERE id = ?", + station.id, + self.id + ) + .execute(db) + .await + .unwrap(); + } + async fn update_notes(&self, db: &SqlitePool, notes: &str) { sqlx::query!("UPDATE team SET notes = ? WHERE id = ?", notes, self.id) .execute(db) @@ -191,6 +230,17 @@ impl Team { .unwrap(); } + async fn update_last_station(&self, db: &SqlitePool, station: &Station) { + sqlx::query!( + "UPDATE team SET last_station_id = ? WHERE id = ?", + station.id, + self.id + ) + .execute(db) + .await + .unwrap(); + } + async fn update_amount_people_reset(&self, db: &SqlitePool) { sqlx::query!("UPDATE team SET amount_people = NULL WHERE id = ?", self.id) .execute(db) @@ -212,6 +262,14 @@ impl Team { .expect("db constraints") } + pub async fn last_station(&self, db: &SqlitePool) -> Option { + if let Some(last_station_id) = self.last_station_id { + Station::find_by_id(db, last_station_id).await + } else { + None + } + } + pub async fn route(&self, db: &SqlitePool) -> Route { Route::find_by_id(db, self.route_id) .await @@ -234,6 +292,63 @@ impl Team { .await .is_some() } + + pub(crate) async fn end_station(&self, db: &SqlitePool) -> Station { + match LastContactTeam::all_sort_missing(db) + .await + .into_iter() + .find(|last_contact_team| &last_contact_team.team == self) + { + Some(last_contact_team) => { + if let Some(station) = last_contact_team.station { + // Team already made some contact with a station + match Rating::find_by_team_and_station(db, self, &station).await { + Some(rating) => { + if rating.left_at.is_none() { + rating.station(db).await + } else { + let next_station = self + .route(db) + .await + .next_station(db, &station) + .await + .unwrap(); + if Rating::find_by_team_and_station(db, self, &next_station) + .await + .is_some() + { + station // last station for team + } else { + next_station + } + } + } + None => self.first_station(db).await, + } + } else { + // Team has made no contact yet -> next station should be the first one + self.first_station(db).await + } + } + None => unreachable!(), + } + } + + pub async fn end_run(db: &SqlitePool) { + // set `last_station_id` to the next station where `left_at` is not null + let teams = Team::all(db).await; + for team in teams { + let end_station = team.end_station(db).await; + team.update_end_station(db, &end_station).await; + } + } + + pub async fn restart_run(db: &SqlitePool) { + sqlx::query!("UPDATE team SET last_station_id = null") + .execute(db) + .await + .unwrap(); + } } pub(super) fn routes() -> Router { diff --git a/src/admin/team/web.rs b/src/admin/team/web.rs index 0716b0e..52292bb 100644 --- a/src/admin/team/web.rs +++ b/src/admin/team/web.rs @@ -1,19 +1,18 @@ use super::{CreateError, LastContactTeam, Team}; use crate::{ - AppState, admin::{route::Route, station::Station}, err, models::rating::Rating, partials::page, - pl, succ, + pl, succ, AppState, }; use axum::{ - Form, Router, extract::State, response::{IntoResponse, Redirect}, routing::{get, post}, + Form, Router, }; -use maud::{Markup, PreEscaped, html}; +use maud::{html, Markup, PreEscaped}; use serde::Deserialize; use sqlx::SqlitePool; use std::{collections::HashMap, sync::Arc}; @@ -271,6 +270,7 @@ async fn view( return Err(Redirect::to("/admin/team")); }; let first_station = team.first_station(&db).await; + let last_station = team.last_station(&db).await; let routes = Route::all(&db).await; let stations = team.route(&db).await.crewful_stations(&db).await; @@ -399,6 +399,35 @@ async fn view( } } } + @if let Some(last_station) = last_station { + tr { + th scope="row" { + "Letzte Station" + }; + td { + a href=(format!("/admin/station/{}", last_station.id)) { + (last_station.name) + } + @if stations.len() > 1 { + details { + summary { "✏️" } + form action=(format!("/admin/team/{}/update-last-station", team.id)) method="post" { + select name="last_station_id" aria-label="Station auswählen" required { + @for station in &stations { + @if station.id != last_station.id { + option value=(station.id) { + (station.name) + } + } + } + } + input type="submit" value="Station speichern"; + } + } + } + } + } + } } } @@ -613,6 +642,61 @@ async fn update_first_station( Redirect::to(&format!("/admin/team/{id}")) } +#[derive(Deserialize)] +struct UpdateLastStationForm { + last_station_id: i64, +} +async fn update_last_station( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, + Form(form): Form, +) -> impl IntoResponse { + let Some(team) = Team::find_by_id(&db, id).await else { + err!( + session, + "Team mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert" + ); + + return Redirect::to("/admin/team"); + }; + + let Some(station) = Station::find_by_id(&db, form.last_station_id).await else { + err!( + session, + "Konnte die letzte Station (ID={}) des Teams mit ID {} nicht bearbeiten, da diese Station nicht existiert.", + form.last_station_id, + team.id + ); + + return Redirect::to(&format!("/admin/team/{id}")); + }; + + if !station.is_in_route(&db, &team.route(&db).await).await { + err!( + session, + "Konnte Station {} nicht dem Team {} hinzufügen, weil dieses Team bei Route {} und nicht bei Route {} mitläuft.", + station.name, + team.name, + team.route(&db).await.name, + team.name + ); + + return Redirect::to(&format!("/admin/team/{id}")); + } + + team.update_last_station(&db, &station).await; + + succ!( + session, + "Letzte Station des Teams {} ist ab sofort {}", + team.name, + station.name + ); + + Redirect::to(&format!("/admin/team/{id}")) +} + async fn update_amount_people_reset( State(db): State>, session: Session, @@ -659,8 +743,8 @@ async fn lost(State(db): State>, session: Session) -> Markup { @for lost in &losts { tr { td { - a href=(format!("/admin/team/{}", lost.team_id)) { - (lost.team_name) + a href=(format!("/admin/team/{}", lost.team.id)) { + (lost.team.name) } } td { @@ -671,8 +755,12 @@ async fn lost(State(db): State>, session: Session) -> Markup { } } td { - a href=(format!("/admin/station/{}", lost.station_id)) { - (lost.station_name) + @if let Some(station) = &lost.station { + a href=(format!("/admin/station/{}", station.id)) { + (station.name) + } + }@else{ + "Noch nicht gesehen" } } } @@ -782,4 +870,5 @@ pub(super) fn routes() -> Router { .route("/{id}/amount-people-reset", get(update_amount_people_reset)) .route("/{id}/update-route", post(update_route)) .route("/{id}/update-first-station", post(update_first_station)) + .route("/{id}/update-last-station", post(update_last_station)) } diff --git a/src/models/rating.rs b/src/models/rating.rs index fec5a8c..d4b64e0 100644 --- a/src/models/rating.rs +++ b/src/models/rating.rs @@ -1,4 +1,4 @@ -use crate::{Station, admin::team::Team}; +use crate::{admin::team::Team, Station}; use chrono::{DateTime, Local, NaiveDateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; @@ -49,6 +49,12 @@ impl Rating { .expect("db constraints") } + pub(crate) async fn station(&self, db: &SqlitePool) -> Station { + Station::find_by_id(db, self.station_id) + .await + .expect("db constraints") + } + pub(crate) async fn for_station(db: &SqlitePool, station: &Station) -> Vec { sqlx::query_as::<_, Self>("SELECT team_id, station_id, points, notes, arrived_at, started_at, left_at FROM rating WHERE station_id = ?;") .bind(station.id) diff --git a/src/station.rs b/src/station.rs index d6c9ad3..bac4235 100644 --- a/src/station.rs +++ b/src/station.rs @@ -1,6 +1,8 @@ use crate::{ - admin::team::Team, er, err, models::rating::TeamsAtStationLocation, partials, suc, AppState, - Station, + admin::{team::Team, RunStatus}, + er, err, + models::rating::TeamsAtStationLocation, + partials, suc, AppState, Station, }; use axum::{ extract::State, @@ -30,6 +32,7 @@ async fn view( let teams = TeamsAtStationLocation::for_station(&db, &station).await; let teams_on_the_way = station.teams_on_the_way(&db).await; + let status = RunStatus::curr(&db).await; let content = html! { h1 { @@ -126,6 +129,20 @@ async fn view( (t!("n_teams_should_come_to_station", amount=teams.total_teams)) } progress value=(teams.total_teams-teams.not_yet_here.len() as i64) max=(teams.total_teams) {} + @if status == RunStatus::HasEnded { + @let teams_to_take_home = Team::all_with_last_station(&db, &station).await; + @if !teams_to_take_home.is_empty() { + "Bitte nimm folgende Teams mit heim:" + ol { + @for team in teams_to_take_home { + li { (team.name) } + } + } + }@else { + "Du musst keine Teams mit heim nehmen" + } + + } } @for team in teams_on_the_way { article {