diff --git a/migration.sql b/migration.sql index 0ad9a97..965afa4 100644 --- a/migration.sql +++ b/migration.sql @@ -39,7 +39,7 @@ CREATE TABLE group_station ( station_id INTEGER, points INTEGER, notes TEXT, - arrived_at DATETIME, + arrived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, started_at DATETIME, left_at DATETIME, PRIMARY KEY (group_id, station_id), diff --git a/src/group/mod.rs b/src/group/mod.rs new file mode 100644 index 0000000..c230fc4 --- /dev/null +++ b/src/group/mod.rs @@ -0,0 +1,173 @@ +use crate::{route::Route, station::Station}; +use axum::Router; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool}; +use std::sync::Arc; + +mod web; + +#[derive(FromRow, Debug, Serialize, Deserialize)] +pub(crate) struct Group { + pub(crate) id: i64, + pub(crate) name: String, + notes: Option, + amount_people: Option, + first_station_id: i64, + route_id: i64, +} + +enum CreateError { + NoStationForRoute, + DuplicateName(String), +} + +impl Group { + pub(crate) async fn all(db: &SqlitePool) -> Vec { + sqlx::query_as::<_, Self>( + "SELECT id, name, notes, amount_people, first_station_id, route_id FROM 'group';", + ) + .fetch_all(db) + .await + .unwrap() + } + + pub(crate) async fn all_with_route(db: &SqlitePool, route: &Route) -> Vec { + sqlx::query_as!( + Group, + "select id, name, notes, amount_people, first_station_id, route_id from 'group' where route_id = ?;", + route.id + ) + .fetch_all(db) + .await + .unwrap() + } + + pub(crate) async fn all_with_first_station(db: &SqlitePool, station: &Station) -> Vec { + sqlx::query_as!( + Group, + "select id, name, notes, amount_people, first_station_id, route_id from 'group' where first_station_id = ?;", + station.id + ) + .fetch_all(db) + .await + .unwrap() + } + + 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 'group' WHERE id = ?", + id + ) + .fetch_one(db) + .await + .ok() + } + + async fn create(db: &SqlitePool, name: &str, route: &Route) -> Result<(), CreateError> { + // get next station id which has the lowest amount of groups to have in the first place + // assigned + let Some(station) = route.get_next_first_station(db).await else { + return Err(CreateError::NoStationForRoute); + }; + + sqlx::query!( + "INSERT INTO 'group'(name, route_id, first_station_id) VALUES (?, ?, ?)", + name, + route.id, + station.id + ) + .execute(db) + .await + .map_err(|e| CreateError::DuplicateName(e.to_string()))?; + Ok(()) + } + + async fn update_name(&self, db: &SqlitePool, name: &str) { + sqlx::query!("UPDATE 'group' SET name = ? WHERE id = ?", name, self.id) + .execute(db) + .await + .unwrap(); + } + + async fn update_notes(&self, db: &SqlitePool, notes: &str) { + sqlx::query!("UPDATE 'group' SET notes = ? WHERE id = ?", notes, self.id) + .execute(db) + .await + .unwrap(); + } + + async fn update_amount_people(&self, db: &SqlitePool, amount_people: i64) { + sqlx::query!( + "UPDATE 'group' SET amount_people = ? WHERE id = ?", + amount_people, + self.id + ) + .execute(db) + .await + .unwrap(); + } + + async fn update_route(&self, db: &SqlitePool, route: &Route) -> Result { + let Some(station) = route.get_next_first_station(db).await else { + return Err(()); + }; + + sqlx::query!( + "UPDATE 'group' SET route_id = ?, first_station_id = ? WHERE id = ?", + route.id, + station.id, + self.id + ) + .execute(db) + .await + .unwrap(); + + Ok(station.name) + } + + async fn update_first_station(&self, db: &SqlitePool, station: &Station) { + sqlx::query!( + "UPDATE 'group' SET first_station_id = ? WHERE id = ?", + station.id, + self.id + ) + .execute(db) + .await + .unwrap(); + } + + async fn update_amount_people_reset(&self, db: &SqlitePool) { + sqlx::query!( + "UPDATE 'group' SET amount_people = NULL WHERE id = ?", + self.id + ) + .execute(db) + .await + .unwrap(); + } + + async fn delete(&self, db: &SqlitePool) -> Result<(), String> { + sqlx::query!("DELETE FROM 'group' WHERE id = ?", self.id) + .execute(db) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + pub async fn first_station(&self, db: &SqlitePool) -> Station { + Station::find_by_id(db, self.first_station_id) + .await + .expect("db constraints") + } + + pub async fn route(&self, db: &SqlitePool) -> Route { + Route::find_by_id(db, self.route_id) + .await + .expect("db constraints") + } +} + +pub(super) fn routes() -> Router> { + web::routes() +} diff --git a/src/group/web.rs b/src/group/web.rs new file mode 100644 index 0000000..5be5775 --- /dev/null +++ b/src/group/web.rs @@ -0,0 +1,534 @@ +use super::{CreateError, Group}; +use crate::{err, partials::page, pl, route::Route, station::Station, succ}; +use axum::{ + Form, Router, + extract::State, + response::{IntoResponse, Redirect}, + routing::{get, post}, +}; +use maud::{Markup, PreEscaped, html}; +use serde::Deserialize; +use sqlx::SqlitePool; +use std::sync::Arc; +use tower_sessions::Session; + +#[derive(Deserialize)] +struct CreateForm { + name: String, + route_id: i64, +} + +async fn create( + State(db): State>, + session: Session, + Form(form): Form, +) -> impl IntoResponse { + let Some(route) = Route::find_by_id(&db, form.route_id).await else { + err!( + session, + "Gruppe mit {} konnte nicht erstellt werden, da keine Route mit ID {} existiert", + form.name, + form.route_id + ); + + return Redirect::to("/group"); + }; + + match Group::create(&db, &form.name, &route).await { + Ok(()) => succ!(session, "Gruppe '{}' erfolgreich erstellt!", form.name), + Err(CreateError::DuplicateName(e)) => err!( + session, + "Gruppe '{}' konnte _NICHT_ erstellt werden, da es bereits eine Gruppe mit diesem Namen gibt ({e})!", + form.name + ), + Err(CreateError::NoStationForRoute) => err!( + session, + "Gruppe '{}' konnte _NICHT_ erstellt werden, da in der angegebenen Route '{}' noch keine Stationen vorkommen", + form.name, + route.name + ), + } + + Redirect::to("/group") +} + +async fn delete( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, +) -> impl IntoResponse { + let Some(group) = Group::find_by_id(&db, id).await else { + err!( + session, + "Gruppe mit ID {id} konnte nicht gelöscht werden, da sie nicht existiert" + ); + + return Redirect::to("/group"); + }; + + match group.delete(&db).await { + Ok(()) => succ!(session, "Gruppe '{}' erfolgreich gelöscht!", group.name), + Err(e) => err!( + session, + "Gruppe '{}' kann nicht gelöscht werden, da sie bereits verwendet wird. ({e})", + group.name + ), + } + + Redirect::to("/group") +} + +async fn view( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, +) -> Result { + let Some(group) = Group::find_by_id(&db, id).await else { + err!( + session, + "Gruppe mit ID {id} konnte nicht geöffnet werden, da sie nicht existiert" + ); + + return Err(Redirect::to("/group")); + }; + let first_station = group.first_station(&db).await; + let routes = Route::all(&db).await; + + let stations = group.route(&db).await.stations(&db).await; + + // maybe switch to maud-display impl of group + let content = html! { + h1 { + a href="/group" { "↩️" } + "Gruppe " (group.name) + } + article { + details { + summary { "Gruppennamen bearbeiten ✏️" } + form action=(format!("/group/{}/name", group.id)) method="post" { + input type="text" name="name" value=(group.name) required; + input type="submit" value="Speichern"; + } + + } + } + table { + tbody { + tr { + th scope="row" { "Notizen" }; + td { + @match &group.notes { + Some(notes) => { + (notes) + details { + summary { "✏️" } + form action=(format!("/group/{}/notes", group.id)) method="post" { + textarea name="notes" required rows="10" { (notes) }; + input type="submit" value="Speichern"; + } + } + }, + None => details { + summary { "Neue Notiz hinzufügen" } + form action=(format!("/group/{}/notes", group.id)) method="post" { + textarea name="notes" required rows="10" {}; + input type="submit" value="Speichern"; + } + } + } + } + } + tr { + th scope="row" { "Anzahl Gruppenmitglieder" }; + td { + @match group.amount_people { + Some(amount) => (amount), + None => "?", + } + details { + summary { "✏️" } + form action=(format!("/group/{}/amount-people", group.id)) method="post" { + input type="number" name="amount_people" min="0" max="10"; + input type="submit" value="Speichern"; + } + a href=(format!("/group/{}/amount-people-reset", group.id)) { + button class="error" { + em data-tooltip="Ich weiß noch nicht wv. Personen diese Gruppe beherbergt." { + "?" + } + } + } + } + } + } + tr { + th scope="row" { "Route" }; + td { + a href=(format!("/route/{}", &group.route(&db).await.id)) { + (&group.route(&db).await.name) + } + @if routes.len() > 1 { + details { + summary { "✏️" } + form action=(format!("/group/{}/update-route", group.id)) method="post" { + select name="route_id" aria-label="Route auswählen" required { + @for route in &routes { + @if route.id != group.route(&db).await.id { + option value=(route.id) { + (route.name) + } + } + } + } + input type="submit" value="Gruppe speichern"; + } + } + } + } + } + tr { + th scope="row" { + "Erste Station" + article { + "Die erste Station wird beim Anlegen einer Gruppe automatisch an diejenige Station vergeben, die aktuell am wenigsten Startgruppen hat. Diese Zuteilung kannst du hier auch manuell verändern." + } + }; + td { + a href=(format!("/station/{}", first_station.id)) { + (first_station.name) + } + @if stations.len() > 1 { + details { + summary { "✏️" } + form action=(format!("/group/{}/update-first-station", group.id)) method="post" { + select name="first_station_id" aria-label="Station auswählen" required { + @for station in &stations { + @if station.id != first_station.id { + option value=(station.id) { + (station.name) + @let amount_start_groups = Group::all_with_first_station(&db, station).await.len(); + @if amount_start_groups > 0 { + (format!(" (schon {amount_start_groups} {})", pl(amount_start_groups, "Startgruppe"))) + } + } + } + } + } + input type="submit" value="Station speichern"; + } + } + } + } + } + } + + } + }; + Ok(page(content, session, true).await) +} + +#[derive(Deserialize)] +struct UpdateNameForm { + name: String, +} +async fn update_name( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, + Form(form): Form, +) -> impl IntoResponse { + let Some(group) = Group::find_by_id(&db, id).await else { + err!( + session, + "Gruppe mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert" + ); + + return Redirect::to("/group"); + }; + + group.update_name(&db, &form.name).await; + + succ!( + session, + "Gruppe '{}' heißt ab sofort '{}'.", + group.name, + form.name + ); + + Redirect::to(&format!("/group/{id}")) +} + +#[derive(Deserialize)] +struct UpdateNotesForm { + notes: String, +} +async fn update_notes( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, + Form(form): Form, +) -> impl IntoResponse { + let Some(group) = Group::find_by_id(&db, id).await else { + err!( + session, + "Gruppe mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert" + ); + + return Redirect::to("/group"); + }; + + group.update_notes(&db, &form.notes).await; + + succ!( + session, + "Notizen für die Gruppe '{}' wurden erfolgreich bearbeitet!", + group.name + ); + + Redirect::to(&format!("/group/{id}")) +} + +#[derive(Deserialize)] +struct UpdateAmountPeopleForm { + amount_people: i64, +} +async fn update_amount_people( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, + Form(form): Form, +) -> impl IntoResponse { + let Some(group) = Group::find_by_id(&db, id).await else { + err!( + session, + "Gruppe mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert" + ); + + return Redirect::to("/group"); + }; + + group.update_amount_people(&db, form.amount_people).await; + + succ!( + session, + "Anzahl an Mitglieder für die Gruppe '{}' wurden erfolgreich bearbeitet!", + group.name + ); + + Redirect::to(&format!("/group/{id}")) +} + +#[derive(Deserialize)] +struct UpdateRouteForm { + route_id: i64, +} +async fn update_route( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, + Form(form): Form, +) -> impl IntoResponse { + // TODO: move sanity checks into mod.rs + let Some(group) = Group::find_by_id(&db, id).await else { + err!( + session, + "Gruppe mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert" + ); + + return Redirect::to("/group"); + }; + + let Some(route) = Route::find_by_id(&db, form.route_id).await else { + err!( + session, + "Konnte Gruppe mit ID {id} konnte nicht zur Route mit ID {} bearbeiten, da diese Route nicht existiert.", + form.route_id + ); + + return Redirect::to(&format!("/group/{id}")); + }; + + match group.update_route(&db, &route).await { + Ok(new_first_station_name) => succ!( + session, + "Gruppe '{}' laufen ab sofort die Route '{}'. Auch die erste Station hat sich dadurch auf {} verändert!!", + group.name, + route.name, + new_first_station_name + ), + Err(()) => err!( + session, + "Konnte für die Gruppe {} nicht die Route {} auswählen, da Route {} keine Stationen zugeteilt hat.", + group.name, + route.name, + route.name + ), + } + + Redirect::to(&format!("/group/{id}")) +} + +#[derive(Deserialize)] +struct UpdateFirstStationForm { + first_station_id: i64, +} +async fn update_first_station( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, + Form(form): Form, +) -> impl IntoResponse { + let Some(group) = Group::find_by_id(&db, id).await else { + err!( + session, + "Gruppe mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert" + ); + + return Redirect::to("/group"); + }; + + let Some(station) = Station::find_by_id(&db, form.first_station_id).await else { + err!( + session, + "Konnte die erste Station (ID={}) der Gruppe mit ID {} nicht bearbeiten, da diese Station nicht existiert.", + form.first_station_id, + group.id + ); + + return Redirect::to(&format!("/group/{id}")); + }; + + if !station.is_in_route(&db, &group.route(&db).await).await { + err!( + session, + "Konnte Station {} nicht der Gruppe {} hinzufügen, weil die Gruppe bei Route {} und nicht bei Route {} mitläuft.", + station.name, + group.name, + group.route(&db).await.name, + group.name + ); + + return Redirect::to(&format!("/group/{id}")); + } + + group.update_first_station(&db, &station).await; + + succ!( + session, + "Erste Station der Gruppe {} ist ab sofort {}", + group.name, + station.name + ); + + Redirect::to(&format!("/group/{id}")) +} + +async fn update_amount_people_reset( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, +) -> impl IntoResponse { + let Some(group) = Group::find_by_id(&db, id).await else { + err!( + session, + "Gruppe mit ID {id} konnte nicht bearbeitet werden, da sie nicht existiert" + ); + + return Redirect::to("/group"); + }; + + group.update_amount_people_reset(&db).await; + + succ!( + session, + "Anzahl an Mitglieder für die Gruppe '{}' wurden erfolgreich bearbeitet!", + group.name + ); + + Redirect::to(&format!("/group/{id}")) +} + +async fn index(State(db): State>, session: Session) -> Markup { + let groups = Group::all(&db).await; + let routes = Route::all(&db).await; + + let content = html! { + h1 { + a href="/" { "↩️" } + "Gruppen" + } + article { + em { "Gruppen " } + "sind eine Menge an Personen, die verschiedene " + a href="/station" { "Stationen" } + " ablaufen. Welche Stationen, entscheidet sich je nachdem, welcher " + a href="/route" { "Route" } + " sie zugewiesen sind." + } + ol { + @for group in &groups{ + li { + a href=(format!("/group/{}", group.id)){ + (group.name) + } + a href=(format!("/group/{}/delete", group.id)) + onclick="return confirm('Bist du sicher, dass die Gruppe gelöscht werden soll? Das kann _NICHT_ mehr rückgängig gemacht werden.');" { + "🗑️" + } + } + } + } + @if groups.is_empty() { + article class="warning" { + "Es gibt noch keine Gruppen. " + @if !routes.is_empty() { + "Das kannst du hier ändern ⤵️" + } + } + } + h2 { "Neue Gruppe" } + @if routes.is_empty() { + article class="error" { + (PreEscaped("Bevor du einer Gruppe erstellen kannst, musst du zumindest eine Route erstellen, die die Gruppe gehen kann → ")) + a role="button" href="/route" { + "Gruppe erstellen" + } + } + } @else { + form action="/group" method="post" { + @if routes.len() == 1 { + fieldset role="group" { + input type="text" name="name" placeholder="Gruppenname" required; + input type="hidden" name="route_id" value=(routes[0].id) ; + input type="submit" value="Neue Gruppe"; + } + } @else { + input type="text" name="name" placeholder="Gruppenname" required; + select name="route_id" aria-label="Route auswählen" required { + @for route in &routes { + option value=(route.id) { + (route.name) + } + } + } + input type="submit" value="Neue Gruppe"; + } + } + } + }; + page(content, session, false).await +} + +pub(super) fn routes() -> Router> { + Router::new() + .route("/", get(index)) + .route("/", post(create)) + .route("/{id}", get(view)) + .route("/{id}/delete", get(delete)) + .route("/{id}/name", post(update_name)) + .route("/{id}/notes", post(update_notes)) + .route("/{id}/amount-people", post(update_amount_people)) + .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)) +} diff --git a/src/lib.rs b/src/lib.rs index c52eefd..5b36b78 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,23 @@ -use axum::{body::Body, response::Response, routing::get, Router}; -use maud::{html, Markup}; +use axum::{Router, body::Body, response::Response, routing::get}; +use maud::{Markup, html}; use partials::page; use sqlx::SqlitePool; use std::sync::Arc; use tokio::net::TcpListener; use tower_sessions::{MemoryStore, Session, SessionManagerLayer}; +pub(crate) mod group; mod partials; pub(crate) mod route; -pub(crate) mod station; // TODO: find nicer name for this 'associative table' +pub(crate) mod station; + +pub(crate) fn pl(amount: usize, single: &str) -> String { + if amount == 1 { + single.into() + } else { + format!("{single}n") + } +} #[macro_export] macro_rules! err { @@ -111,6 +120,7 @@ pub async fn start(listener: TcpListener, db: SqlitePool) { .route("/", get(index)) .nest("/station", station::routes()) .nest("/route", route::routes()) + .nest("/group", group::routes()) .route("/pico.css", get(serve_pico_css)) .route("/style.css", get(serve_my_css)) .route("/leaflet.css", get(serve_leaflet_css)) diff --git a/src/route/mod.rs b/src/route/mod.rs index 4970e4c..63eb900 100644 --- a/src/route/mod.rs +++ b/src/route/mod.rs @@ -1,7 +1,7 @@ -use crate::station::Station; +use crate::{group::Group, station::Station}; use axum::Router; use serde::{Deserialize, Serialize}; -use sqlx::{FromRow, SqlitePool}; +use sqlx::{FromRow, Row, SqlitePool}; use std::sync::Arc; mod web; @@ -9,11 +9,11 @@ mod web; #[derive(FromRow, Debug, Serialize, Deserialize)] pub(crate) struct Route { pub(crate) id: i64, - name: String, + pub(crate) name: String, } impl Route { - async fn all(db: &SqlitePool) -> Vec { + pub(crate) async fn all(db: &SqlitePool) -> Vec { sqlx::query_as::<_, Self>("SELECT id, name FROM route;") .fetch_all(db) .await @@ -126,7 +126,7 @@ DROP TABLE temp_pos;", Ok(()) } - async fn stations(&self, db: &SqlitePool) -> Vec { + pub(crate) async fn stations(&self, db: &SqlitePool) -> Vec { sqlx::query_as::<_, Station>( " SELECT s.id, s.name, s.notes, s.amount_people, s.last_login, s.pw, s.lat, s.lng @@ -160,6 +160,39 @@ DROP TABLE temp_pos;", .await .unwrap() } + + pub(crate) async fn groups(&self, db: &SqlitePool) -> Vec { + Group::all_with_route(db, self).await + } + + pub(crate) async fn get_next_first_station(&self, db: &SqlitePool) -> Option { + let Ok(row) = sqlx::query(&format!( + " + SELECT + s.id AS next_first_station_id, + COUNT(g.id) AS group_count + FROM station s + JOIN route_station rs ON s.id = rs.station_id + LEFT JOIN 'group' g ON s.id = g.first_station_id + WHERE rs.route_id = {} + GROUP BY s.id + ORDER BY group_count ASC, s.id ASC + LIMIT 1", + self.id + )) + .fetch_one(db) + .await + else { + return None; // No station for route exists + }; + let next_first_station_id = row.get::("next_first_station_id"); + + Some( + Station::find_by_id(db, next_first_station_id) + .await + .expect("db constraints"), + ) + } } pub(super) fn routes() -> Router> { diff --git a/src/route/web.rs b/src/route/web.rs index 9a4f855..f18308f 100644 --- a/src/route/web.rs +++ b/src/route/web.rs @@ -1,12 +1,12 @@ use super::Route; use crate::{err, page, station::Station, succ}; use axum::{ + Form, Router, extract::State, response::{IntoResponse, Redirect}, routing::{get, post}, - Form, Router, }; -use maud::{html, Markup, PreEscaped}; +use maud::{Markup, PreEscaped, html}; use serde::Deserialize; use sqlx::SqlitePool; use std::sync::Arc; @@ -24,7 +24,9 @@ async fn index(State(db): State>, session: Session) -> Markup { em { "Routen " } "definieren welche " a href="/station" { "Stationen" } - " von den Teilnehmern in welcher Reihenfolge abgeklappert werden sollen. Wenn es verschiedene Kategorien (zB Kinder- und Erwachsenenwertung) gibt, kannst du auch mehrere Routen mit (teils) überlappenden Stationen erstellen." + " von den " + a href="/group" { "Gruppen" } + " in welcher Reihenfolge abgeklappert werden sollen. Wenn es verschiedene Kategorien (zB Kinder- und Erwachsenenwertung) gibt, kannst du auch mehrere Routen mit (teils) überlappenden Stationen erstellen." } ol { @for route in &routes{ @@ -125,6 +127,8 @@ async fn view( let cur_stations = route.stations(&db).await; let stations_not_in_route = route.stations_not_in_route(&db).await; + let groups = route.groups(&db).await; + let content = html! { h1 { a href="/route" { "↩️" } @@ -185,6 +189,27 @@ async fn view( } } } + h2 { "Gruppen mit dieser Route" } + @if groups.is_empty() { + article { + "Noch keine Gruppe ist dieser Route zugeteilt." + a role="button" href="/group" { + "Zu den Gruppen" + } + + } + } @else { + ol { + @for group in &groups { + li { + a href=(format!("/group/{}", group.id)) { + (group.name) + } + } + } + } + } + }; Ok(page(content, session, false).await) } diff --git a/src/station/mod.rs b/src/station/mod.rs index 9e1ebf5..3168537 100644 --- a/src/station/mod.rs +++ b/src/station/mod.rs @@ -127,6 +127,16 @@ impl Station { .await .unwrap() } + + pub async fn is_in_route(&self, db: &SqlitePool, route: &Route) -> bool { + for r in self.routes(db).await { + if r.id == route.id { + return true; + } + } + + false + } } pub(super) fn routes() -> Router> {