From 1d4c5f356d286097a9487e21bc34a68941a8beec Mon Sep 17 00:00:00 2001 From: philipp Date: Sun, 23 Jul 2023 12:17:57 +0200 Subject: [PATCH 1/2] add first draft of logbook --- migration.sql | 2 +- src/model/logbook.rs | 231 ++++++++++++++++++++++++++++ src/model/logtype.rs | 52 +++++++ src/model/mod.rs | 2 + src/model/user.rs | 22 +++ src/tera/log.rs | 94 +++++++++++ src/tera/mod.rs | 2 + templates/faq.html.tera | 2 +- templates/includes/macros.html.tera | 6 +- templates/log.html.tera | 97 ++++++++++++ 10 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 src/model/logbook.rs create mode 100644 src/model/logtype.rs create mode 100644 src/tera/log.rs create mode 100644 templates/log.html.tera diff --git a/migration.sql b/migration.sql index 1103353..95ff9bb 100644 --- a/migration.sql +++ b/migration.sql @@ -98,7 +98,7 @@ CREATE TABLE IF NOT EXISTS "logbook" ( "destination" text, "distance_in_km" integer, "comments" text, - "type" INTEGER REFERENCES logbook_type(id) + "logtype" INTEGER REFERENCES logbook_type(id) ); CREATE TABLE IF NOT EXISTS "rower" ( diff --git a/src/model/logbook.rs b/src/model/logbook.rs new file mode 100644 index 0000000..2a5a207 --- /dev/null +++ b/src/model/logbook.rs @@ -0,0 +1,231 @@ +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool}; + +#[derive(FromRow, Debug, Serialize, Deserialize)] +pub struct Logbook { + 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, +} + +#[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, +} + +impl Logbook { + //pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option { + // sqlx::query_as!( + // Self, + // " + //SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, skull, external + //FROM boat + //WHERE id like ? + // ", + // id + // ) + // .fetch_one(db) + // .await + // .ok() + //} + // + // pub async fn find_by_name(db: &SqlitePool, name: &str) -> Option { + // sqlx::query_as!( + // User, + // " + //SELECT id, name, pw, is_cox, is_admin, is_guest, deleted, last_access + //FROM user + //WHERE name like ? + // ", + // name + // ) + // .fetch_one(db) + // .await + // .ok() + // } + // + pub async fn on_water(db: &SqlitePool) -> Vec { + sqlx::query_as!( + LogbookWithBoatAndUsers, + " + 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 + 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 + } + + pub async fn completed(db: &SqlitePool) -> Vec { + sqlx::query_as!( + LogbookWithBoatAndUsers, + " + 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 + 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 + " + ) + .fetch_all(db) + .await + .unwrap() //TODO: fixme + } + + pub async fn create( + db: &SqlitePool, + boat_id: i64, + shipmaster: i64, + shipmaster_only_steering: bool, + departure: NaiveDateTime, + arrival: Option, + destination: Option, + distance_in_km: Option, + comments: Option, + logtype: Option, + ) -> bool { + sqlx::query!( + "INSERT INTO logbook(boat_id, shipmaster, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype) VALUES (?,?,?,?,?,?,?,?,?)", + boat_id, shipmaster, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype + ) + .execute(db) + .await.is_ok() + } + // + // pub async fn update( + // &self, + // db: &SqlitePool, + // name: &str, + // amount_seats: i64, + // year_built: Option, + // boatbuilder: Option<&str>, + // default_shipmaster_only_steering: bool, + // skull: bool, + // external: bool, + // location_id: Option, + // owner: Option, + // ) -> bool { + // sqlx::query!( + // "UPDATE boat SET name=?, amount_seats=?, year_built=?, boatbuilder=?, default_shipmaster_only_steering=?, skull=?, external=?, location_id=?, owner=? WHERE id=?", + // name, + // amount_seats, + // year_built, + // boatbuilder, + // default_shipmaster_only_steering, + // skull, + // external, + // location_id, + // owner, + // self.id + // ) + // .execute(db) + // .await + // .is_ok() + // } + // + // 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/logtype.rs b/src/model/logtype.rs new file mode 100644 index 0000000..111d42d --- /dev/null +++ b/src/model/logtype.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool}; + +#[derive(FromRow, Debug, Serialize, Deserialize, Clone)] +pub struct LogType{ + pub id: i64, + name: String, +} + +impl LogType{ + pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option { + sqlx::query_as!( + Self, + " +SELECT id, name +FROM logbook_type +WHERE id like ? + ", + id + ) + .fetch_one(db) + .await + .ok() + } + + pub async fn all(db: &SqlitePool) -> Vec { + sqlx::query_as!( + Self, + " +SELECT id, name +FROM logbook_type + " + ) + .fetch_all(db) + .await + .unwrap() //TODO: fixme + } +} + +#[cfg(test)] +mod test { + use crate::testdb; + + use sqlx::SqlitePool; + + #[sqlx::test] + fn test_find_true() { + let pool = testdb!(); + } + + //TODO: write tests +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 9500984..df9ef27 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -10,6 +10,8 @@ use self::{ pub mod boat; pub mod location; pub mod log; +pub mod logbook; +pub mod logtype; pub mod planned_event; pub mod trip; pub mod tripdetails; diff --git a/src/model/user.rs b/src/model/user.rs index 7af6579..4172ff6 100644 --- a/src/model/user.rs +++ b/src/model/user.rs @@ -85,6 +85,21 @@ ORDER BY last_access DESC .unwrap() //TODO: fixme } + pub async fn cox(db: &SqlitePool) -> Vec { + sqlx::query_as!( + User, + " +SELECT id, name, pw, is_cox, is_admin, is_guest, deleted, last_access +FROM user +WHERE deleted = 0 AND is_cox=true +ORDER BY last_access DESC + " + ) + .fetch_all(db) + .await + .unwrap() //TODO: fixme + } + pub async fn create(db: &SqlitePool, name: &str, is_guest: bool) -> bool { sqlx::query!( "INSERT INTO USER(name, is_guest) VALUES (?,?)", @@ -365,6 +380,13 @@ mod test { assert!(res.len() > 3); } + #[sqlx::test] + fn test_cox() { + let pool = testdb!(); + let res = User::cox(&pool).await; + assert_eq!(res.len(), 2); + } + #[sqlx::test] fn test_succ_create() { let pool = testdb!(); diff --git a/src/tera/log.rs b/src/tera/log.rs new file mode 100644 index 0000000..dc2bcd8 --- /dev/null +++ b/src/tera/log.rs @@ -0,0 +1,94 @@ +use chrono::NaiveDateTime; +use rocket::{ + form::Form, + get, post, + request::FlashMessage, + response::{Flash, Redirect}, + routes, FromForm, Route, State, +}; +use rocket_dyn_templates::Template; +use sqlx::SqlitePool; +use tera::Context; + +use crate::model::{ + boat::Boat, + logbook::Logbook, + logtype::LogType, + user::{AdminUser, User}, +}; + +#[get("/")] +async fn index( + db: &State, + flash: Option>, + adminuser: AdminUser, +) -> Template { + let boats = Boat::all(db).await; + let users = User::cox(db).await; + let logtypes = LogType::all(db).await; + + let on_water = Logbook::on_water(db).await; + let completed = Logbook::completed(db).await; + + let mut context = Context::new(); + if let Some(msg) = flash { + context.insert("flash", &msg.into_inner()); + } + + context.insert("boats", &boats); + context.insert("users", &users); + context.insert("logtypes", &logtypes); + context.insert("loggedin_user", &adminuser.user); + context.insert("on_water", &on_water); + context.insert("completed", &completed); + + Template::render("log", context.into_json()) +} + +#[derive(FromForm)] +struct LogAddForm { + boat_id: i64, + shipmaster: i64, + shipmaster_only_steering: bool, + departure: String, + arrival: Option, + destination: Option, + distance_in_km: Option, + comments: Option, + logtype: Option, +} + +#[post("/", data = "")] +async fn create( + db: &State, + data: Form, + _adminuser: AdminUser, +) -> Flash { + if Logbook::create( + db, + data.boat_id, + data.shipmaster, + data.shipmaster_only_steering, + NaiveDateTime::parse_from_str(&data.departure, "%Y-%m-%dT%H:%M").unwrap(), //TODO: fix + data.arrival + .clone() + .map(|a| NaiveDateTime::parse_from_str(&a, "%Y-%m-%dT%H:%M").unwrap()), //TODO: fix + data.destination.clone(), //TODO: fix + data.distance_in_km, + data.comments.clone(), //TODO: fix + data.logtype, + ) + .await + { + Flash::success(Redirect::to("/log"), "Ausfahrt erfolgreich hinzugefügt") + } else { + Flash::error(Redirect::to("/log"), format!("Fehler beim hinzufügen!")) + } +} + +pub fn routes() -> Vec { + routes![index, create] +} + +#[cfg(test)] +mod test {} diff --git a/src/tera/mod.rs b/src/tera/mod.rs index fff78e4..61367f2 100644 --- a/src/tera/mod.rs +++ b/src/tera/mod.rs @@ -22,6 +22,7 @@ use crate::model::{ mod admin; mod auth; mod cox; +mod log; mod misc; #[get("/")] @@ -114,6 +115,7 @@ pub fn config(rocket: Rocket) -> Rocket { rocket .mount("/", routes![index, join, remove]) .mount("/auth", auth::routes()) + .mount("/log", log::routes()) .mount("/cox", cox::routes()) .mount("/admin", admin::routes()) .mount("/", misc::routes()) diff --git a/templates/faq.html.tera b/templates/faq.html.tera index dfc87d2..efd28b1 100644 --- a/templates/faq.html.tera +++ b/templates/faq.html.tera @@ -4,7 +4,7 @@ {% block content %}
-

FAQs

+

Infrequently asked questions

{% if loggedin_user.is_cox %} diff --git a/templates/includes/macros.html.tera b/templates/includes/macros.html.tera index 9189382..e1ab708 100644 --- a/templates/includes/macros.html.tera +++ b/templates/includes/macros.html.tera @@ -13,6 +13,10 @@ FAQs {% if loggedin_user.is_admin %} + + LOGBUCH + Logbuch + BOATS Bootsverwaltung @@ -48,7 +52,7 @@ {% endmacro checkbox %} {% macro select(data, select_name='trip_type', default='', selected_id='') %} - {% if default %} {% endif %} diff --git a/templates/log.html.tera b/templates/log.html.tera new file mode 100644 index 0000000..e2347a9 --- /dev/null +++ b/templates/log.html.tera @@ -0,0 +1,97 @@ +{% import "includes/macros" as macros %} + +{% extends "base" %} + +{% block content %} + + {% if flash %} + {{ macros::alert(message=flash.1, type=flash.0, class="sm:col-span-2 lg:col-span-3") }} + {% endif %} + +
+

Logbuch

+

Neue Ausfahrt starten

+
+ {{ macros::select(data=boats, select_name='boat_id') }} + {{ macros::select(data=users, select_name='shipmaster', selected_id=loggedin_user.id) }} + {{ macros::checkbox(label='shipmaster_only_steering', name='shipmaster_only_steering') }} + Departure: + Arrival: + Destination: + + + {{ 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") }} + + + +
+ +

Am Wasser

+ {% for log in on_water %} + Bootsname: {{ log.boat }}
+ Schiffsführer: {{ log.shipmaster_name }}
+ {% if log.shipmaster_only_steering %} + Schiffsführer steuert nur + {% endif %} + Weggefahren: {{ log.departure }}
+ Ziel: {{ log.destination }}
+ Kommentare: {{ log.comments }}
+ Logtype: {{ log.logtype }}
+
+ {% endfor %} + +

Einträge

+ {% for log in completed %} + Bootsname: {{ log.boat }}
+ Schiffsführer: {{ log.shipmaster_name }}
+ {% if log.shipmaster_only_steering %} + Schiffsführer steuert nur + {% endif %} + Weggefahren: {{ log.departure }}
+ Angekommen: {{ log.arrival}}
+ Ziel: {{ log.destination }}
+ Kommentare: {{ log.comments }}
+ Logtype: {{ log.logtype }}
+
+ {% endfor %} +
+ + +{% endblock content%} From 3259582aab639b7e90ec63bcc35241c79d693447 Mon Sep 17 00:00:00 2001 From: philipp Date: Sun, 23 Jul 2023 14:21:27 +0200 Subject: [PATCH 2/2] create new tests for /cox/trip/ --- README.md | 2 +- src/model/trip.rs | 4 +- src/tera/cox.rs | 114 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2a16a77..0e33590 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ user_details - [x] (join) GET /join/ - [x] (remove) GET /remove/ - [x] (create) POST /cox/trip -- [ ] (update) POST /cox/trip/ +- [x] (update) POST /cox/trip/ - [ ] (join) GET /cox/join/ - [ ] (remove) GET /cox/remove/ - [ ] (remove_trip) GET /cox/remove/trip/ diff --git a/src/model/trip.rs b/src/model/trip.rs index 339798a..9df89f8 100644 --- a/src/model/trip.rs +++ b/src/model/trip.rs @@ -16,9 +16,9 @@ pub struct Trip { cox_name: String, trip_details_id: Option, planned_starting_time: String, - max_people: i64, + pub max_people: i64, day: String, - notes: Option, + pub notes: Option, pub allow_guests: bool, trip_type_id: Option, } diff --git a/src/tera/cox.rs b/src/tera/cox.rs index bbc6ed0..fb2237f 100644 --- a/src/tera/cox.rs +++ b/src/tera/cox.rs @@ -218,4 +218,118 @@ mod test { .len() ); } + + #[sqlx::test] + fn test_trip_update_succ() { + let db = testdb!(); + + let trip = &Trip::get_for_day(&db, NaiveDate::from_ymd_opt(1970, 01, 02).unwrap()).await[0]; + assert_eq!(1, trip.trip.max_people); + assert_eq!( + "trip_details for trip from cox", + &trip.trip.notes.clone().unwrap() + ); + + 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 + .post("/cox/trip/1") + .header(ContentType::Form) + .body("notes=my-new-notes&max_people=12"); + 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:successAusfahrt erfolgreich aktualisiert." + ); + + let trip = &Trip::get_for_day(&db, NaiveDate::from_ymd_opt(1970, 01, 02).unwrap()).await[0]; + assert_eq!(12, trip.trip.max_people); + assert_eq!("my-new-notes", &trip.trip.notes.clone().unwrap()); + } + + #[sqlx::test] + fn test_trip_update_wrong_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 + .post("/cox/trip/9999") + .header(ContentType::Form) + .body("notes=my-new-notes&max_people=12"); + 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:errorAusfahrt gibt's nicht"); + } + + #[sqlx::test] + fn test_trip_update_wrong_cox() { + let db = testdb!(); + + let trip = &Trip::get_for_day(&db, NaiveDate::from_ymd_opt(1970, 01, 02).unwrap()).await[0]; + assert_eq!(1, trip.trip.max_people); + assert_eq!( + "trip_details for trip from cox", + &trip.trip.notes.clone().unwrap() + ); + + 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=cox2&password=cox"); // Add the form data to the request body; + login.dispatch().await; + + let req = client + .post("/cox/trip/1") + .header(ContentType::Form) + .body("notes=my-new-notes&max_people=12"); + 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:errorNicht deine Ausfahrt!"); + } }