diff --git a/README.md b/README.md index 2406247..8a7c7e0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,10 @@ ## New large features ### Logbuch -- Next: add rower to logbook +- Next: Allow editing of rowers on "Ausfahrt beenden" +- Then + - Allow editing own logbook entries of same day + - Stats (Personenliste mit Gesamt-KM vom Jahr) ### Guest-Scheckbuch - guest_trip @@ -91,7 +94,7 @@ user_details - [x] (remove) GET /remove/ - [x] (create) POST /cox/trip - [x] (update) POST /cox/trip/ -- [ ] (join) GET /cox/join/ +- [x] (join) GET /cox/join/ - [ ] (remove) GET /cox/remove/ - [ ] (remove_trip) GET /cox/remove/trip/ - [ ] (index) GET /auth/ diff --git a/migration.sql b/migration.sql index bba12c1..e00eef4 100644 --- a/migration.sql +++ b/migration.sql @@ -103,7 +103,8 @@ CREATE TABLE IF NOT EXISTS "logbook" ( CREATE TABLE IF NOT EXISTS "rower" ( "logbook_id" INTEGER NOT NULL REFERENCES logbook(id), - "rower_id" INTEGER NOT NULL REFERENCES user(id) + "rower_id" INTEGER NOT NULL REFERENCES user(id), + CONSTRAINT unq UNIQUE (logbook_id, rower_id) ); CREATE TABLE IF NOT EXISTS "boat_damage" ( diff --git a/src/model/boat.rs b/src/model/boat.rs index 128fa8d..19e2b89 100644 --- a/src/model/boat.rs +++ b/src/model/boat.rs @@ -48,7 +48,22 @@ impl Boat { // .await // .ok() // } - // + + pub async fn is_locked(&self, db: &SqlitePool) -> bool { + sqlx::query!("SELECT * FROM boat_damage WHERE boat_id=? AND lock_boat=true AND user_id_verified is null", self.id).fetch_optional(db).await.unwrap().is_some() + } + + pub async fn on_water(&self, db: &SqlitePool) -> bool { + sqlx::query!( + "SELECT * FROM logbook WHERE boat_id=? AND arrival is null", + self.id + ) + .fetch_optional(db) + .await + .unwrap() + .is_some() + } + pub async fn all(db: &SqlitePool) -> Vec { sqlx::query_as!( Boat, diff --git a/src/model/logbook.rs b/src/model/logbook.rs index afc0d60..9d9179f 100644 --- a/src/model/logbook.rs +++ b/src/model/logbook.rs @@ -1,10 +1,10 @@ use chrono::NaiveDateTime; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use sqlx::{FromRow, SqlitePool}; -use super::user::User; +use super::{boat::Boat, rower::Rower, user::User}; -#[derive(FromRow, Debug, Serialize, Deserialize)] +#[derive(FromRow, Serialize, Clone)] pub struct Logbook { pub id: i64, pub boat_id: i64, @@ -19,21 +19,13 @@ pub struct Logbook { pub logtype: Option, } -#[derive(Serialize, FromRow)] -pub struct LogbookWithBoatAndUsers { - pub id: i64, - pub boat_id: i64, - pub shipmaster: i64, - #[serde(default = "bool::default")] - pub shipmaster_only_steering: bool, - pub departure: String, //TODO: Switch to chrono::nativedatetime - pub arrival: Option, //TODO: Switch to chrono::nativedatetime - pub destination: Option, - pub distance_in_km: Option, - pub comments: Option, - pub logtype: Option, - pub boat: String, - pub shipmaster_name: String, +#[derive(Serialize)] +pub struct LogbookWithBoatAndRowers { + #[serde(flatten)] + pub logbook: Logbook, + pub boat: Boat, + pub shipmaster_user: User, + pub rowers: Vec, } pub enum LogbookUpdateError { @@ -43,6 +35,8 @@ pub enum LogbookUpdateError { pub enum LogbookCreateError { BoatAlreadyOnWater, BoatLocked, + BoatNotFound, + TooManyRowers(usize, usize), } impl Logbook { @@ -76,43 +70,61 @@ impl Logbook { // .ok() // } // - pub async fn on_water(db: &SqlitePool) -> Vec { - sqlx::query_as!( - LogbookWithBoatAndUsers, + pub async fn on_water(db: &SqlitePool) -> Vec { + let logs = sqlx::query_as!( + Logbook, " - SELECT logbook.id, boat_id, shipmaster, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype, boat.name as boat, user.name as shipmaster_name + SELECT id, boat_id, shipmaster, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype FROM logbook - INNER JOIN boat ON logbook.boat_id = boat.id - INNER JOIN user ON shipmaster = user.id WHERE arrival is null ORDER BY departure DESC " ) .fetch_all(db) .await - .unwrap() //TODO: fixme + .unwrap(); //TODO: fixme + + let mut ret = Vec::new(); + for log in logs { + ret.push(LogbookWithBoatAndRowers { + rowers: Rower::for_log(db, &log).await, + boat: Boat::find_by_id(db, log.boat_id as i32).await.unwrap(), + shipmaster_user: User::find_by_id(db, log.shipmaster as i32).await.unwrap(), + logbook: log, + }) + } + ret } - pub async fn completed(db: &SqlitePool) -> Vec { - sqlx::query_as!( - LogbookWithBoatAndUsers, + pub async fn completed(db: &SqlitePool) -> Vec { + let logs = sqlx::query_as!( + Logbook, " - SELECT logbook.id, boat_id, shipmaster, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype, boat.name as boat, user.name as shipmaster_name + SELECT id, boat_id, shipmaster, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype FROM logbook - INNER JOIN boat ON logbook.boat_id = boat.id - INNER JOIN user ON shipmaster = user.id WHERE arrival is not null - ORDER BY arrival DESC + ORDER BY departure DESC " ) .fetch_all(db) .await - .unwrap() //TODO: fixme + .unwrap(); //TODO: fixme + + let mut ret = Vec::new(); + for log in logs { + ret.push(LogbookWithBoatAndRowers { + rowers: Rower::for_log(db, &log).await, + boat: Boat::find_by_id(db, log.boat_id as i32).await.unwrap(), + shipmaster_user: User::find_by_id(db, log.shipmaster as i32).await.unwrap(), + logbook: log, + }) + } + ret } pub async fn create( db: &SqlitePool, - boat_id: i64, + boat_id: i32, shipmaster: i64, shipmaster_only_steering: bool, departure: NaiveDateTime, @@ -121,15 +133,40 @@ impl Logbook { distance_in_km: Option, comments: Option, logtype: Option, + rower: Vec, ) -> Result<(), LogbookCreateError> { - //Check if boat is not locked - //Check if boat is already on water - let _ = sqlx::query!( - "INSERT INTO logbook(boat_id, shipmaster, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype) VALUES (?,?,?,?,?,?,?,?,?)", + let boat = match Boat::find_by_id(db, boat_id).await { + Some(b) => b, + None => { + return Err(LogbookCreateError::BoatNotFound); + } + }; + + if boat.is_locked(db).await { + return Err(LogbookCreateError::BoatLocked); + } + + if boat.on_water(db).await { + return Err(LogbookCreateError::BoatAlreadyOnWater); + } + + if rower.len() > boat.amount_seats as usize - 1 { + return Err(LogbookCreateError::TooManyRowers( + boat.amount_seats as usize, + rower.len() + 1, + )); + } + + let inserted_row = sqlx::query!( + "INSERT INTO logbook(boat_id, shipmaster, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype) VALUES (?,?,?,?,?,?,?,?,?) RETURNING id", boat_id, shipmaster, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype ) - .execute(db) - .await; + .fetch_one(db) + .await.unwrap(); + + for rower in &rower { + Rower::create(db, inserted_row.id, *rower).await; + } Ok(()) } diff --git a/src/model/mod.rs b/src/model/mod.rs index 9f914f6..de034b9 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -13,6 +13,7 @@ pub mod log; pub mod logbook; pub mod logtype; pub mod planned_event; +pub mod rower; pub mod trip; pub mod tripdetails; pub mod triptype; diff --git a/src/model/rower.rs b/src/model/rower.rs new file mode 100644 index 0000000..4bcdc69 --- /dev/null +++ b/src/model/rower.rs @@ -0,0 +1,134 @@ +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool}; + +use super::{logbook::Logbook, user::User}; + +#[derive(FromRow, Debug, Serialize, Deserialize)] +pub struct Rower { + pub logbook_id: i64, + pub rower_id: i64, +} + +impl Rower { + //pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option { + // sqlx::query_as!( + // Self, + // " + //SELECT id,boat_id,shipmaster,shipmaster_only_steering,departure,arrival,destination,distance_in_km,comments,logtype + //FROM logbook + //WHERE id like ? + // ", + // id + // ) + // .fetch_one(db) + // .await + // .ok() + //} + + pub async fn for_log(db: &SqlitePool, log: &Logbook) -> Vec { + sqlx::query_as!( + User, + " + SELECT id, name, pw, is_cox, is_admin, is_guest, deleted, last_access + FROM user + WHERE id in (SELECT rower_id FROM rower WHERE logbook_id=?) + ", + log.id + ) + .fetch_all(db) + .await + .unwrap() + } + + pub async fn create(db: &SqlitePool, logbook_id: i64, rower_id: i64) { + //Check if boat is not locked + //Check if boat is already on water + let _ = sqlx::query!( + "INSERT INTO rower(logbook_id, rower_id) VALUES (?,?)", + logbook_id, + rower_id + ) + .execute(db) + .await + .unwrap(); + } + + // pub async fn delete(&self, db: &SqlitePool) { + // sqlx::query!("DELETE FROM boat WHERE id=?", self.id) + // .execute(db) + // .await + // .unwrap(); //Okay, because we can only create a User of a valid id + // } +} +// +//#[cfg(test)] +//mod test { +// use crate::{model::boat::Boat, testdb}; +// +// use sqlx::SqlitePool; +// +// #[sqlx::test] +// fn test_find_correct_id() { +// let pool = testdb!(); +// let boat = Boat::find_by_id(&pool, 1).await.unwrap(); +// assert_eq!(boat.id, 1); +// } +// +// #[sqlx::test] +// fn test_find_wrong_id() { +// let pool = testdb!(); +// let boat = Boat::find_by_id(&pool, 1337).await; +// assert!(boat.is_none()); +// } +// +// #[sqlx::test] +// fn test_all() { +// let pool = testdb!(); +// let res = Boat::all(&pool).await; +// assert!(res.len() > 3); +// } +// +// #[sqlx::test] +// fn test_succ_create() { +// let pool = testdb!(); +// +// assert_eq!( +// Boat::create( +// &pool, +// "new-boat-name".into(), +// 42, +// None, +// "Best Boatbuilder".into(), +// true, +// true, +// false, +// Some(1), +// None +// ) +// .await, +// true +// ); +// } +// +// #[sqlx::test] +// fn test_duplicate_name_create() { +// let pool = testdb!(); +// +// assert_eq!( +// Boat::create( +// &pool, +// "Haichenbach".into(), +// 42, +// None, +// "Best Boatbuilder".into(), +// true, +// true, +// false, +// Some(1), +// None +// ) +// .await, +// false +// ); +// } +//} diff --git a/src/model/user.rs b/src/model/user.rs index 3e13b54..5fc4dfe 100644 --- a/src/model/user.rs +++ b/src/model/user.rs @@ -20,12 +20,12 @@ use super::{tripdetails::TripDetails, Day}; pub struct User { pub id: i64, pub name: String, - pw: Option, + pub pw: Option, pub is_cox: bool, pub is_admin: bool, pub is_guest: bool, #[serde(default = "bool::default")] - deleted: bool, + pub deleted: bool, pub last_access: Option, } diff --git a/src/tera/cox.rs b/src/tera/cox.rs index 96f1772..8d2a54f 100644 --- a/src/tera/cox.rs +++ b/src/tera/cox.rs @@ -345,4 +345,107 @@ mod test { assert_eq!(flash_cookie.value(), "5:errorNicht deine Ausfahrt!"); } + + #[sqlx::test] + fn test_trip_join() { + let db = testdb!(); + + let rocket = rocket::build().manage(db.clone()); + let rocket = crate::tera::config(rocket); + + let client = Client::tracked(rocket).await.unwrap(); + let login = client + .post("/auth") + .header(ContentType::Form) // Set the content type to form + .body("name=cox&password=cox"); // Add the form data to the request body; + login.dispatch().await; + + let req = client.get("/cox/join/1"); + let response = req.dispatch().await; + + assert_eq!(response.status(), Status::SeeOther); + assert_eq!(response.headers().get("Location").next(), Some("/")); + + let flash_cookie = response + .cookies() + .get("_flash") + .expect("Expected flash cookie"); + + assert_eq!(flash_cookie.value(), "7:successDanke für's helfen!"); + + let req = client.get("/cox/join/1"); + let response = req.dispatch().await; + + assert_eq!(response.status(), Status::SeeOther); + assert_eq!(response.headers().get("Location").next(), Some("/")); + + let flash_cookie = response + .cookies() + .get("_flash") + .expect("Expected flash cookie"); + + assert_eq!(flash_cookie.value(), "5:errorDu hilfst bereits aus!"); + } + + #[sqlx::test] + fn test_trip_join_already_rower() { + let db = testdb!(); + + let rocket = rocket::build().manage(db.clone()); + let rocket = crate::tera::config(rocket); + + let client = Client::tracked(rocket).await.unwrap(); + let login = client + .post("/auth") + .header(ContentType::Form) // Set the content type to form + .body("name=cox&password=cox"); // Add the form data to the request body; + login.dispatch().await; + + let req = client.get("/join/1"); + let response = req.dispatch().await; + + let req = client.get("/cox/join/1"); + let response = req.dispatch().await; + + assert_eq!(response.status(), Status::SeeOther); + assert_eq!(response.headers().get("Location").next(), Some("/")); + + let flash_cookie = response + .cookies() + .get("_flash") + .expect("Expected flash cookie"); + + assert_eq!( + flash_cookie.value(), + "5:errorDu hast dich bereits als Ruderer angemeldet!" + ); + } + + #[sqlx::test] + fn test_trip_join_invalid_event() { + let db = testdb!(); + + let rocket = rocket::build().manage(db.clone()); + let rocket = crate::tera::config(rocket); + + let client = Client::tracked(rocket).await.unwrap(); + let login = client + .post("/auth") + .header(ContentType::Form) // Set the content type to form + .body("name=cox&password=cox"); // Add the form data to the request body; + login.dispatch().await; + + let req = client.get("/cox/join/9999"); + let response = req.dispatch().await; + + assert_eq!(response.status(), Status::SeeOther); + assert_eq!(response.headers().get("Location").next(), Some("/")); + + let flash_cookie = response + .cookies() + .get("_flash") + .expect("Expected flash cookie"); + + assert_eq!(flash_cookie.value(), "5:errorEvent gibt's nicht"); + } } diff --git a/src/tera/log.rs b/src/tera/log.rs index 4a93341..bc1f323 100644 --- a/src/tera/log.rs +++ b/src/tera/log.rs @@ -12,7 +12,7 @@ use tera::Context; use crate::model::{ boat::Boat, - logbook::Logbook, + logbook::{Logbook, LogbookCreateError}, logtype::LogType, user::{AdminUser, User}, }; @@ -24,7 +24,8 @@ async fn index( adminuser: AdminUser, ) -> Template { let boats = Boat::all(db).await; - let users = User::cox(db).await; + let coxes = User::cox(db).await; + let users = User::all(db).await; let logtypes = LogType::all(db).await; let on_water = Logbook::on_water(db).await; @@ -36,6 +37,7 @@ async fn index( } context.insert("boats", &boats); + context.insert("coxes", &coxes); context.insert("users", &users); context.insert("logtypes", &logtypes); context.insert("loggedin_user", &adminuser.user); @@ -47,7 +49,7 @@ async fn index( #[derive(FromForm)] struct LogAddForm { - boat_id: i64, + boat_id: i32, shipmaster: i64, shipmaster_only_steering: bool, departure: String, @@ -56,6 +58,7 @@ struct LogAddForm { distance_in_km: Option, comments: Option, logtype: Option, + rower: Vec, } #[post("/", data = "")] @@ -77,14 +80,16 @@ async fn create( data.distance_in_km, data.comments.clone(), //TODO: fix data.logtype, + data.rower.clone(), //TODO: fix ) .await { Ok(_) => Flash::success(Redirect::to("/log"), "Ausfahrt erfolgreich hinzugefügt"), - Err(_) => Flash::error(Redirect::to("/log"), format!("Fehler beim hinzufügen!")) - } - - + Err(LogbookCreateError::BoatAlreadyOnWater) => Flash::error(Redirect::to("/log"), format!("Boot schon am Wasser")), + Err(LogbookCreateError::BoatLocked) => Flash::error(Redirect::to("/log"), format!("Boot gesperrt")), + Err(LogbookCreateError::BoatNotFound) => Flash::error(Redirect::to("/log"), format!("Boot gibt's ned")), + Err(LogbookCreateError::TooManyRowers(expected, actual)) => Flash::error(Redirect::to("/log"), format!("Zu viele Ruderer (Boot fasst maximal {expected}, es wurden jedoch {actual} Ruderer ausgewählt)")), + } } #[derive(FromForm)] @@ -117,22 +122,18 @@ async fn home( data.destination.clone(), //TODO: fixme data.distance_in_km, data.comments.clone(), //TODO: fixme - data.logtype + data.logtype, ) .await { - Ok(_) => Flash::success(Redirect::to("/log"), "Successfully updated log"), - Err(_) => - Flash::error( + Ok(_) => Flash::success(Redirect::to("/log"), "Successfully updated log"), + Err(_) => Flash::error( Redirect::to("/log"), format!("Logbook with ID {} could not be updated!", logbook_id), - ) + ), } - } - - pub fn routes() -> Vec { routes![index, create, home] } diff --git a/templates/log.html.tera b/templates/log.html.tera index 0d92f0a..8fb8031 100644 --- a/templates/log.html.tera +++ b/templates/log.html.tera @@ -13,7 +13,7 @@

Neue Ausfahrt starten

{{ macros::select(data=boats, select_name='boat_id') }} - {{ macros::select(data=users, select_name='shipmaster', selected_id=loggedin_user.id) }} + {{ macros::select(data=coxes, select_name='shipmaster', selected_id=loggedin_user.id) }} {{ macros::checkbox(label='shipmaster_only_steering', name='shipmaster_only_steering') }} Departure: Arrival: @@ -35,6 +35,11 @@ {{ macros::input(label="Distanz", name="distance_in_km", type="number", min=0) }} {{ macros::input(label="Kommentar", name="comments", type="text") }} {{ macros::select(data=logtypes, select_name='logtype', default="Normal") }} +