diff --git a/README.md b/README.md index 258286e..3a2eb6e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ ## Next steps - [x] Station -- [ ] Route +- [x] Route + - [x] Route_station +- [ ] Group ## Fancy features - see when a group starts going to your direction diff --git a/migration.sql b/migration.sql index 911de24..0ad9a97 100644 --- a/migration.sql +++ b/migration.sql @@ -15,9 +15,9 @@ CREATE TABLE route ( ); CREATE TABLE route_station ( - route_id INTEGER, - station_id INTEGER, - pos INTEGER, + route_id INTEGER NOT NULL, + station_id INTEGER NOT NULL, + pos INTEGER NOT NULL, PRIMARY KEY (route_id, station_id), FOREIGN KEY (route_id) REFERENCES route(id), FOREIGN KEY (station_id) REFERENCES station(id) diff --git a/src/lib.rs b/src/lib.rs index 3c2f475..3c5a69e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,8 +7,8 @@ use tokio::net::TcpListener; use tower_sessions::{MemoryStore, Session, SessionManagerLayer}; mod partials; -mod route; -mod station; +pub(crate) mod route; +pub(crate) mod station; // TODO: find nicer name for this 'associative table' #[macro_export] macro_rules! err { diff --git a/src/route.rs b/src/route.rs index 370e97e..8b939c5 100644 --- a/src/route.rs +++ b/src/route.rs @@ -1,19 +1,19 @@ -use crate::{err, page, succ}; +use crate::{err, page, station::Station, succ}; use axum::{ extract::State, response::{IntoResponse, Redirect}, routing::{get, post}, Form, Router, }; -use maud::{html, Markup}; +use maud::{html, Markup, PreEscaped}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; use std::sync::Arc; use tower_sessions::Session; #[derive(FromRow, Debug, Serialize, Deserialize)] -struct Route { - id: i64, +pub(crate) struct Route { + pub(crate) id: i64, name: String, } @@ -47,12 +47,123 @@ impl Route { .unwrap(); } + async fn add_station(&self, db: &SqlitePool, station: &Station) -> Result<(), String> { + sqlx::query!( + r#" + INSERT INTO route_station (route_id, station_id, pos) + VALUES (?, ?, ( + SELECT COALESCE(MAX(pos), 0) + 2 + FROM route_station + WHERE route_id = ? + )) + "#, + self.id, + station.id, + self.id + ) + .execute(db) + .await + .map_err(|e| e.to_string())?; + + Ok(()) + } + + async fn delete_station(&self, db: &SqlitePool, station: &Station) -> bool { + let result = sqlx::query!( + "DELETE FROM route_station WHERE route_id = ? AND station_id = ?", + self.id, + station.id + ) + .execute(db) + .await + .unwrap(); + + result.rows_affected() > 0 + } + + async fn move_station_higher(&self, db: &SqlitePool, station: &Station) -> bool { + let result = sqlx::query!( + "UPDATE route_station SET pos = pos-3 WHERE route_id = ? AND station_id = ?", + self.id, + station.id + ) + .execute(db) + .await + .unwrap(); + + if result.rows_affected() == 0 { + return false; + } + + sqlx::query( + &format!( + " +-- Step 1: Create a temporary table with new rank values +CREATE TEMP TABLE IF NOT EXISTS temp_pos AS +SELECT + route_id, + station_id, + ROW_NUMBER() OVER (ORDER BY pos ASC) AS new_rank +FROM + route_station WHERE route_id = {}; + +-- Step 2: Update the original table +UPDATE route_station +SET pos = (SELECT 2*new_rank FROM temp_pos WHERE temp_pos.route_id = route_station.route_id AND temp_pos.station_id = route_station.station_id) WHERE route_id = {}; + +-- Step 3: Drop the temporary table (no longer needed) +DROP TABLE temp_pos;", + self.id, + self.id, + )) + .execute(db) + .await + .unwrap(); + + true + } + async fn delete(&self, db: &SqlitePool) { sqlx::query!("DELETE FROM route WHERE id = ?", self.id) .execute(db) .await .unwrap(); } + + async fn stations(&self, db: &SqlitePool) -> Vec { + sqlx::query_as::<_, Station>( + r#" + SELECT s.id, s.name, s.notes, s.amount_people, s.last_login, s.pw, s.lat, s.lng + FROM station s + JOIN route_station rs ON s.id = rs.station_id + WHERE rs.route_id = ? + ORDER BY rs.pos + "#, + ) + .bind(self.id) + .fetch_all(db) + .await + .unwrap() + } + + async fn stations_not_in_route(&self, db: &SqlitePool) -> Vec { + sqlx::query_as::<_, Station>( + r#" + SELECT id, name, notes, amount_people, last_login, pw, lat, lng + FROM station + WHERE id NOT IN ( + SELECT station_id + FROM route_station + WHERE route_id = ? + ) + ORDER BY name + "#, + ) + .bind(self.id) + .fetch_all(db) + .await + .unwrap() + } } async fn index(State(db): State>, session: Session) -> Markup { @@ -63,9 +174,20 @@ async fn index(State(db): State>, session: Session) -> Markup { a href="/" { "↩️" } "Routen" } + article { + em { "Routen " } + "definieren welche " + a href="/station" { "Stationen" } + " 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{ li { + @if route.stations(&db).await.is_empty() { + em data-tooltip="Keine Stationen zugeteilt" { + "⚠️" + } + } a href=(format!("/route/{}", route.id)){ (route.name) } @@ -76,6 +198,11 @@ async fn index(State(db): State>, session: Session) -> Markup { } } } + @if routes.is_empty() { + article class="warning" { + "Es gibt noch keine Routen. Das kannst du hier ändern ⤵️" + } + } h2 { "Neue Route" } form action="/route" method="post" { fieldset role="group" { @@ -144,6 +271,9 @@ async fn view( return Err(Redirect::to("/route")); }; + let cur_stations = route.stations(&db).await; + let stations_not_in_route = route.stations_not_in_route(&db).await; + let content = html! { h1 { a href="/route" { "↩️" } @@ -158,6 +288,52 @@ async fn view( } } } + h2 { "Stationen" } + @if cur_stations.is_empty() { + @if stations_not_in_route.is_empty() { + article class="error" { + (PreEscaped("Bevor du einer Route Stationen zuweisen kannst, musst du die Stationen erstellen → ")) + a role="button" href="/station" { + "Station erstellen" + } + } + } @else { + article class="warning" { + "Diese Route hat noch keine Stationen zugewiesen. Das kannst du hier ändern ⤵️" + } + } + } @else { + ol { + @for (idx, station) in cur_stations.into_iter().enumerate() { + li { + (station.name) + @if idx > 0 { + a href=(format!("/route/{}/move-station-higher/{}", route.id, station.id)){ + em data-tooltip=(format!("{} nach vor reihen", station.name)) { + "⬆️" + } + } + } + a href=(format!("/route/{}/delete-station/{}", route.id, station.id)) + onclick="return confirm('Bist du sicher, dass die Station von der Route entfernt werden soll?');" { + "🗑️" + } + } + } + } + } + @if !stations_not_in_route.is_empty(){ + form action=(format!("/route/{}/add-station", route.id)) method="post" { + select name="station" aria-label="Hinzuzufügende Station auswählen" required { + @for station in &stations_not_in_route { + option value=(station.id) { + (station.name) + } + } + input type="submit" value="Hinzufügen"; + } + } + } }; Ok(page(content, session, false).await) } @@ -193,6 +369,133 @@ async fn update_name( Redirect::to(&format!("/route/{id}")) } +#[derive(Deserialize)] +struct AddStationForm { + station: i64, +} +async fn add_station( + State(db): State>, + session: Session, + axum::extract::Path(id): axum::extract::Path, + Form(form): Form, +) -> impl IntoResponse { + let Some(route) = Route::find_by_id(&db, id).await else { + err!( + session, + "Route mit ID {id} konnte nicht geöffnet werden, da sie nicht existiert" + ); + + return Redirect::to("/route"); + }; + let Some(station) = Station::find_by_id(&db, form.station).await else { + err!( + session, + "Station mit ID {id} konnte nicht hinzugefügt werden, da sie nicht existiert" + ); + + return Redirect::to(&format!("/route/{id}")); + }; + + match route.add_station(&db, &station).await { + Ok(_) => succ!( + session, + "Station {} wurde der Route {} hinzugefügt", + station.name, + route.name + ), + Err(e) => err!( + session, + "Station {} kann nur 1x der Route {} hinzugefügt werden. ({e})", + station.name, + route.name + ), + } + + Redirect::to(&format!("/route/{id}")) +} + +async fn delete_station( + State(db): State>, + session: Session, + axum::extract::Path((route_id, station_id)): axum::extract::Path<(i64, i64)>, +) -> impl IntoResponse { + let Some(route) = Route::find_by_id(&db, route_id).await else { + err!( + session, + "Konnte keine Station von Route mit ID {route_id} entfernen, da diese Route nicht existiert." + ); + + return Redirect::to("/route"); + }; + let Some(station) = Station::find_by_id(&db, station_id).await else { + err!( + session, + "Konnte Station mit der ID {station_id} nicht von der Route {} entfernen, da die Station nicht existiert.", route.name + ); + + return Redirect::to(&format!("/route/{route_id}")); + }; + + if route.delete_station(&db, &station).await { + succ!( + session, + "Station '{}' wurde von der Route '{}' gelöscht", + station.name, + route.name + ); + } else { + err!( + session, + "Station '{}' konnte nicht von der Route '{}' gelöscht werden, da diese nicht auf dieser Route liegt", + station.name, + route.name + ); + } + + Redirect::to(&format!("/route/{route_id}")) +} + +async fn move_station_higher( + State(db): State>, + session: Session, + axum::extract::Path((route_id, station_id)): axum::extract::Path<(i64, i64)>, +) -> impl IntoResponse { + let Some(route) = Route::find_by_id(&db, route_id).await else { + err!( + session, + "Konnte keine Station von Route mit ID {route_id} verschieben, da diese Route nicht existiert." + ); + + return Redirect::to("/route"); + }; + let Some(station) = Station::find_by_id(&db, station_id).await else { + err!( + session, + "Konnte Station mit der ID {station_id} nicht in der Route {} verschieben, da die Station nicht existiert.", route.name + ); + + return Redirect::to(&format!("/route/{route_id}")); + }; + + if route.move_station_higher(&db, &station).await { + succ!( + session, + "Station '{}' wurde in der Route '{}' erfolgreich vorgereiht", + station.name, + route.name + ); + } else { + err!( + session, + "Station '{}' konnte in der Route '{}' nicht vorgereiht werden, da diese nicht auf dieser Route liegt", + station.name, + route.name + ); + } + + Redirect::to(&format!("/route/{route_id}")) +} + pub(super) fn routes() -> Router> { Router::new() .route("/", get(index)) @@ -200,4 +503,13 @@ pub(super) fn routes() -> Router> { .route("/{id}/delete", get(delete)) .route("/{id}", get(view)) .route("/{id}/name", post(update_name)) + .route("/{id}/add-station", post(add_station)) + .route( + "/{route_id}/delete-station/{station_id}", + get(delete_station), + ) + .route( + "/{route_id}/move-station-higher/{station_id}", + get(move_station_higher), + ) } diff --git a/src/station/mod.rs b/src/station/mod.rs index 334febd..31a1847 100644 --- a/src/station/mod.rs +++ b/src/station/mod.rs @@ -1,3 +1,4 @@ +use crate::route::Route; use axum::Router; use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; @@ -7,9 +8,9 @@ use std::sync::Arc; mod routes; #[derive(FromRow, Debug, Serialize, Deserialize)] -struct Station { - id: i64, - name: String, +pub(crate) struct Station { + pub(crate) id: i64, + pub(crate) name: String, notes: Option, amount_people: Option, last_login: Option, // TODO use proper timestamp (NaiveDateTime?) @@ -19,7 +20,7 @@ struct Station { } impl Station { - async fn all(db: &SqlitePool) -> Vec { + pub(crate) async fn all(db: &SqlitePool) -> Vec { sqlx::query_as::<_, Self>( "SELECT id, name, notes, amount_people, last_login, pw, lat, lng FROM station;", ) @@ -103,11 +104,28 @@ impl Station { .unwrap(); } - async fn delete(&self, db: &SqlitePool) { + async fn delete(&self, db: &SqlitePool) -> Result<(), String> { sqlx::query!("DELETE FROM station WHERE id = ?", self.id) .execute(db) .await - .unwrap(); + .map_err(|e| e.to_string())?; + Ok(()) + } + + async fn routes(&self, db: &SqlitePool) -> Vec { + sqlx::query_as::<_, Route>( + r#" + SELECT r.id, r.name + FROM route r + JOIN route_station rs ON r.id = rs.route_id + WHERE rs.station_id = ? + ORDER BY rs.pos + "#, + ) + .bind(self.id) + .fetch_all(db) + .await + .unwrap() } } diff --git a/src/station/routes.rs b/src/station/routes.rs index b31f078..96ba24f 100644 --- a/src/station/routes.rs +++ b/src/station/routes.rs @@ -47,9 +47,14 @@ async fn delete( return Redirect::to("/station"); }; - station.delete(&db).await; - - succ!(session, "Station '{}' erfolgreich gelöscht!", station.name); + match station.delete(&db).await { + Ok(_) => succ!(session, "Station '{}' erfolgreich gelöscht!", station.name), + Err(e) => err!( + session, + "Station '{}' kann nicht gelöscht werden, da sie bereits verwendet wird. ({e})", + station.name + ), + } Redirect::to("/station") } @@ -410,9 +415,18 @@ async fn index(State(db): State>, session: Session) -> Markup { a href="/" { "↩️" } "Stationen" } + article { + em { "Stationen " } + "sind festgelegte Orte mit spezifischen Aufgaben." + } ol { @for station in &stations { li { + @if station.routes(&db).await.is_empty() { + em data-tooltip="Noch keiner Route zugeordnet" { + "⚠️" + } + } a href=(format!("/station/{}", station.id)){ (station.name) } @@ -423,6 +437,11 @@ async fn index(State(db): State>, session: Session) -> Markup { } } } + @if stations.is_empty() { + article class="warning" { + "Es gibt noch keine Stationen. Das kannst du hier ändern ⤵️" + } + } h2 { "Neue Station" } form action="/station" method="post" { fieldset role="group" {