diff --git a/migration.sql b/migration.sql index 574288c..5b7befc 100644 --- a/migration.sql +++ b/migration.sql @@ -195,3 +195,21 @@ CREATE TABLE IF NOT EXISTS "weather" ( "wind_gust" FLOAT NOT NULL, "rain_mm" FLOAT NOT NULL ); + +CREATE TABLE IF NOT EXISTS "trailer" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" text NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS "trailer_reservation" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "trailer_id" INTEGER NOT NULL REFERENCES boat(id), + "start_date" DATE NOT NULL, + "end_date" DATE NOT NULL, + "time_desc" TEXT NOT NULL, + "usage" TEXT NOT NULL, + "user_id_applicant" INTEGER NOT NULL REFERENCES user(id), + "user_id_confirmation" INTEGER REFERENCES user(id), + "created_at" datetime not null default CURRENT_TIMESTAMP +); + diff --git a/seeds.sql b/seeds.sql index 0f2f70b..ceb2296 100644 --- a/seeds.sql +++ b/seeds.sql @@ -64,3 +64,5 @@ INSERT INTO "rower" (logbook_id, rower_id) VALUES(3,3); INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at) VALUES(4,'Dolle bei Position 2 fehlt', 5, '2142-12-24 15:02'); INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at, lock_boat) VALUES(5, 'TOHT', 5, '2142-12-24 15:02', 1); INSERT INTO "notification" (user_id, message, category) VALUES (1, 'This is a test notification', 'test-cat'); +INSERT INTO "trailer" (name) VALUES('Großer Hänger'); +INSERT INTO "trailer" (name) VALUES('Kleiner Hänger'); diff --git a/src/model/mod.rs b/src/model/mod.rs index 43760da..e6d3349 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -25,6 +25,8 @@ pub mod notification; pub mod role; pub mod rower; pub mod stat; +pub mod trailer; +pub mod trailerreservation; pub mod trip; pub mod tripdetails; pub mod triptype; diff --git a/src/model/trailer.rs b/src/model/trailer.rs new file mode 100644 index 0000000..658fc14 --- /dev/null +++ b/src/model/trailer.rs @@ -0,0 +1,31 @@ +use std::ops::DerefMut; + +use rocket::serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; + +#[derive(FromRow, Debug, Serialize, Deserialize, Eq, Hash, PartialEq, Clone)] +pub struct Trailer { + pub id: i64, + pub name: String, +} + +impl Trailer { + pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option { + sqlx::query_as!(Self, "SELECT id, name FROM trailer WHERE id like ?", id) + .fetch_one(db) + .await + .ok() + } + pub async fn find_by_id_tx(db: &mut Transaction<'_, Sqlite>, id: i32) -> Option { + sqlx::query_as!(Self, "SELECT id, name FROM trailer WHERE id like ?", id) + .fetch_one(db.deref_mut()) + .await + .ok() + } + pub async fn all(db: &SqlitePool) -> Vec { + sqlx::query_as!(Self, "SELECT id, name FROM trailer") + .fetch_all(db) + .await + .unwrap() + } +} diff --git a/src/model/trailerreservation.rs b/src/model/trailerreservation.rs new file mode 100644 index 0000000..0bc45a1 --- /dev/null +++ b/src/model/trailerreservation.rs @@ -0,0 +1,233 @@ +use std::collections::HashMap; + +use chrono::NaiveDate; +use chrono::NaiveDateTime; +use rocket::serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool}; + +use super::log::Log; +use super::notification::Notification; +use super::role::Role; +use super::trailer::Trailer; +use super::user::User; +use crate::tera::trailerreservation::ReservationEditForm; + +#[derive(FromRow, Debug, Serialize, Deserialize)] +pub struct TrailerReservation { + pub id: i64, + pub trailer_id: i64, + pub start_date: NaiveDate, + pub end_date: NaiveDate, + pub time_desc: String, + pub usage: String, + pub user_id_applicant: i64, + pub user_id_confirmation: Option, + pub created_at: NaiveDateTime, +} + +#[derive(FromRow, Debug, Serialize, Deserialize)] +pub struct TrailerReservationWithDetails { + #[serde(flatten)] + reservation: TrailerReservation, + trailer: Trailer, + user_applicant: User, + user_confirmation: Option, +} + +#[derive(Debug)] +pub struct TrailerReservationToAdd<'r> { + pub trailer: &'r Trailer, + pub start_date: NaiveDate, + pub end_date: NaiveDate, + pub time_desc: &'r str, + pub usage: &'r str, + pub user_applicant: &'r User, +} + +impl TrailerReservation { + pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option { + sqlx::query_as!( + Self, + "SELECT id, trailer_id, start_date, end_date, time_desc, usage, user_id_applicant, user_id_confirmation, created_at + FROM trailer_reservation + WHERE id like ?", + id + ) + .fetch_one(db) + .await + .ok() + } + + pub async fn all_future(db: &SqlitePool) -> Vec { + let trailerreservations = sqlx::query_as!( + Self, + " +SELECT id, trailer_id, start_date, end_date, time_desc, usage, user_id_applicant, user_id_confirmation, created_at +FROM trailer_reservation +WHERE end_date >= CURRENT_DATE ORDER BY end_date + " + ) + .fetch_all(db) + .await + .unwrap(); //TODO: fixme + + let mut res = Vec::new(); + for reservation in trailerreservations { + let user_confirmation = match reservation.user_id_confirmation { + Some(id) => { + let user = User::find_by_id(db, id as i32).await; + Some(user.unwrap()) + } + None => None, + }; + let user_applicant = User::find_by_id(db, reservation.user_id_applicant as i32) + .await + .unwrap(); + let trailer = Trailer::find_by_id(db, reservation.trailer_id as i32) + .await + .unwrap(); + + res.push(TrailerReservationWithDetails { + reservation, + trailer, + user_applicant, + user_confirmation, + }); + } + res + } + pub async fn all_future_with_groups( + db: &SqlitePool, + ) -> HashMap> { + let mut grouped_reservations: HashMap> = + HashMap::new(); + + let reservations = Self::all_future(db).await; + for reservation in reservations { + let key = format!( + "{}-{}-{}-{}-{}", + reservation.reservation.start_date, + reservation.reservation.end_date, + reservation.reservation.time_desc, + reservation.reservation.usage, + reservation.user_applicant.name + ); + + grouped_reservations + .entry(key) + .or_default() + .push(reservation); + } + + grouped_reservations + } + + pub async fn create( + db: &SqlitePool, + trailerreservation: TrailerReservationToAdd<'_>, + ) -> Result<(), String> { + if Self::trailer_reserved_between_dates( + db, + trailerreservation.trailer, + &trailerreservation.start_date, + &trailerreservation.end_date, + ) + .await + { + return Err("Hänger in diesem Zeitraum bereits reserviert.".into()); + } + + Log::create( + db, + format!("New trailer reservation: {trailerreservation:?}"), + ) + .await; + + sqlx::query!( + "INSERT INTO trailer_reservation(trailer_id, start_date, end_date, time_desc, usage, user_id_applicant) VALUES (?,?,?,?,?,?)", + trailerreservation.trailer.id, + trailerreservation.start_date, + trailerreservation.end_date, + trailerreservation.time_desc, + trailerreservation.usage, + trailerreservation.user_applicant.id, + ) + .execute(db) + .await + .map_err(|e| e.to_string())?; + + let board = + User::all_with_role(db, &Role::find_by_name(db, "Vorstand").await.unwrap()).await; + for user in board { + let date = if trailerreservation.start_date == trailerreservation.end_date { + format!("am {}", trailerreservation.start_date) + } else { + format!( + "von {} bis {}", + trailerreservation.start_date, trailerreservation.end_date + ) + }; + + Notification::create( + db, + &user, + &format!( + "{} hat eine neue Hängerreservierung für Hänger '{}' {} angelegt. Zeit: {}; Zweck: {}", + trailerreservation.user_applicant.name, + trailerreservation.trailer.name, + date, + trailerreservation.time_desc, + trailerreservation.usage + ), + "Neue Hängerreservierung", + None,None + ) + .await; + } + + Ok(()) + } + + pub async fn trailer_reserved_between_dates( + db: &SqlitePool, + trailer: &Trailer, + start_date: &NaiveDate, + end_date: &NaiveDate, + ) -> bool { + sqlx::query!( + "SELECT COUNT(*) AS reservation_count +FROM trailer_reservation +WHERE trailer_id = ? +AND start_date <= ? AND end_date >= ?;", + trailer.id, + end_date, + start_date + ) + .fetch_one(db) + .await + .unwrap() + .reservation_count + > 0 + } + + pub async fn update(&self, db: &SqlitePool, data: ReservationEditForm) { + let time_desc = data.time_desc.trim(); + let usage = data.usage.trim(); + sqlx::query!( + "UPDATE trailer_reservation SET time_desc = ?, usage = ? where id = ?", + time_desc, + usage, + self.id + ) + .execute(db) + .await + .unwrap(); //Okay, because we can only create a User of a valid id + } + + pub async fn delete(&self, db: &SqlitePool) { + sqlx::query!("DELETE FROM trailer_reservation WHERE id=?", self.id) + .execute(db) + .await + .unwrap(); //Okay, because we can only create a Boat of a valid id + } +} diff --git a/src/tera/mod.rs b/src/tera/mod.rs index 36ea1ad..27e88dc 100644 --- a/src/tera/mod.rs +++ b/src/tera/mod.rs @@ -39,6 +39,7 @@ mod misc; mod notification; mod planned; mod stat; +pub(crate) mod trailerreservation; #[derive(FromForm, Debug)] struct LoginForm<'r> { @@ -200,6 +201,7 @@ pub fn config(rocket: Rocket) -> Rocket { .mount("/stat", stat::routes()) .mount("/boatdamage", boatdamage::routes()) .mount("/boatreservation", boatreservation::routes()) + .mount("/trailerreservation", trailerreservation::routes()) .mount("/cox", cox::routes()) .mount("/admin", admin::routes()) .mount("/board", board::routes()) diff --git a/src/tera/trailerreservation.rs b/src/tera/trailerreservation.rs new file mode 100644 index 0000000..98c343e --- /dev/null +++ b/src/tera/trailerreservation.rs @@ -0,0 +1,211 @@ +use chrono::NaiveDate; +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::{ + log::Log, + trailer::Trailer, + trailerreservation::{TrailerReservation, TrailerReservationToAdd}, + user::{DonauLinzUser, User, UserWithDetails}, + }, + tera::log::KioskCookie, +}; + +#[get("/")] +async fn index_kiosk( + db: &State, + flash: Option>, + _kiosk: KioskCookie, +) -> Template { + let trailerreservations = TrailerReservation::all_future(db).await; + + let mut context = Context::new(); + if let Some(msg) = flash { + context.insert("flash", &msg.into_inner()); + } + + context.insert("trailerreservations", &trailerreservations); + context.insert("trailers", &Trailer::all(db).await); + context.insert("user", &User::all(db).await); + context.insert("show_kiosk_header", &true); + + Template::render("trailerreservations", context.into_json()) +} + +#[get("/", rank = 2)] +async fn index( + db: &State, + flash: Option>, + user: DonauLinzUser, +) -> Template { + let trailerreservations = TrailerReservation::all_future(db).await; + + let mut context = Context::new(); + if let Some(msg) = flash { + context.insert("flash", &msg.into_inner()); + } + + context.insert("trailerreservations", &trailerreservations); + context.insert("trailers", &Trailer::all(db).await); + context.insert("user", &User::all(db).await); + context.insert( + "loggedin_user", + &UserWithDetails::from_user(user.into(), db).await, + ); + + Template::render("trailerreservations", context.into_json()) +} + +#[derive(Debug, FromForm)] +pub struct FormTrailerReservationToAdd<'r> { + pub trailer_id: i64, + pub start_date: &'r str, + pub end_date: &'r str, + pub time_desc: &'r str, + pub usage: &'r str, + pub user_id_applicant: Option, +} + +#[post("/new", data = "", rank = 2)] +async fn create<'r>( + db: &State, + data: Form>, + user: DonauLinzUser, +) -> Flash { + let user_applicant: User = user.into(); + let trailer = Trailer::find_by_id(db, data.trailer_id as i32) + .await + .unwrap(); + let trailerreservation_to_add = TrailerReservationToAdd { + trailer: &trailer, + start_date: NaiveDate::parse_from_str(data.start_date, "%Y-%m-%d").unwrap(), + end_date: NaiveDate::parse_from_str(data.end_date, "%Y-%m-%d").unwrap(), + time_desc: data.time_desc, + usage: data.usage, + user_applicant: &user_applicant, + }; + match TrailerReservation::create(db, trailerreservation_to_add).await { + Ok(_) => Flash::success( + Redirect::to("/trailerreservation"), + "Reservierung erfolgreich hinzugefügt", + ), + Err(e) => Flash::error(Redirect::to("/trailerreservation"), format!("Fehler: {e}")), + } +} + +#[post("/new", data = "")] +async fn create_from_kiosk<'r>( + db: &State, + data: Form>, + _kiosk: KioskCookie, +) -> Flash { + let user_applicant: User = User::find_by_id(db, data.user_id_applicant.unwrap() as i32) + .await + .unwrap(); + let trailer = Trailer::find_by_id(db, data.trailer_id as i32) + .await + .unwrap(); + let trailerreservation_to_add = TrailerReservationToAdd { + trailer: &trailer, + start_date: NaiveDate::parse_from_str(data.start_date, "%Y-%m-%d").unwrap(), + end_date: NaiveDate::parse_from_str(data.end_date, "%Y-%m-%d").unwrap(), + time_desc: data.time_desc, + usage: data.usage, + user_applicant: &user_applicant, + }; + match TrailerReservation::create(db, trailerreservation_to_add).await { + Ok(_) => Flash::success( + Redirect::to("/trailerreservation"), + "Reservierung erfolgreich hinzugefügt", + ), + Err(e) => Flash::error(Redirect::to("/trailerreservation"), format!("Fehler: {e}")), + } +} + +#[derive(FromForm, Debug)] +pub struct ReservationEditForm { + pub(crate) id: i32, + pub(crate) time_desc: String, + pub(crate) usage: String, +} + +#[post("/", data = "")] +async fn update( + db: &State, + data: Form, + user: User, +) -> Flash { + let Some(reservation) = TrailerReservation::find_by_id(db, data.id).await else { + return Flash::error( + Redirect::to("/trailerreservation"), + format!("Reservation with ID {} does not exist!", data.id), + ); + }; + + if user.id != reservation.user_id_applicant && !user.has_role(db, "admin").await { + return Flash::error( + Redirect::to("/trailerreservation"), + "Not allowed to update reservation (only admins + creator do so).".to_string(), + ); + } + + Log::create( + db, + format!( + "{} updated reservation from {reservation:?} to {data:?}", + user.name + ), + ) + .await; + + reservation.update(db, data.into_inner()).await; + + Flash::success( + Redirect::to("/trailerreservation"), + "Reservierung erfolgreich bearbeitet", + ) +} + +#[get("//delete")] +async fn delete<'r>( + db: &State, + reservation_id: i32, + user: DonauLinzUser, +) -> Flash { + let reservation = TrailerReservation::find_by_id(db, reservation_id) + .await + .unwrap(); + + if user.id == reservation.user_id_applicant || user.has_role(db, "admin").await { + reservation.delete(db).await; + Flash::success( + Redirect::to("/trailerreservation"), + "Reservierung erfolgreich gelöscht", + ) + } else { + Flash::error( + Redirect::to("/trailerreservation"), + "Nur der Reservierer darf die Reservierung löschen.".to_string(), + ) + } +} + +pub fn routes() -> Vec { + routes![ + index, + index_kiosk, + create, + create_from_kiosk, + delete, + update + ] +} diff --git a/staging-diff.sql b/staging-diff.sql index 6fb21fc..849a833 100644 --- a/staging-diff.sql +++ b/staging-diff.sql @@ -1,3 +1,22 @@ +CREATE TABLE IF NOT EXISTS "trailer" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" text NOT NULL UNIQUE +); +INSERT INTO trailer(name) VALUES('Großer Hänger'); +INSERT INTO trailer(name) VALUES('Kleiner Hänger'); + +CREATE TABLE IF NOT EXISTS "trailer_reservation" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "trailer_id" INTEGER NOT NULL REFERENCES boat(id), + "start_date" DATE NOT NULL, + "end_date" DATE NOT NULL, + "time_desc" TEXT NOT NULL, + "usage" TEXT NOT NULL, + "user_id_applicant" INTEGER NOT NULL REFERENCES user(id), + "user_id_confirmation" INTEGER REFERENCES user(id), + "created_at" datetime not null default CURRENT_TIMESTAMP +); + -- test user INSERT INTO user(name) VALUES('Marie'); INSERT INTO "user_role" (user_id, role_id) VALUES((SELECT id from user where name = 'Marie'),(SELECT id FROM role where name = 'Donau Linz')); diff --git a/templates/base.html.tera b/templates/base.html.tera index 7572dfb..f3c4303 100644 --- a/templates/base.html.tera +++ b/templates/base.html.tera @@ -36,6 +36,7 @@ Bootsauswertung Bootsschaden Bootsreservierung + Hängerreservierung {% endif %} diff --git a/templates/includes/macros.html.tera b/templates/includes/macros.html.tera index 5b8cb31..d8c2b8e 100644 --- a/templates/includes/macros.html.tera +++ b/templates/includes/macros.html.tera @@ -78,6 +78,8 @@ class="block w-100 py-2 hover:text-primary-600 border-t">Bootsschaden Bootsreservierung + Hängerreservierung {% endif %} {% if loggedin_user.weight and loggedin_user.sex and loggedin_user.dob %} Ergo diff --git a/templates/index.html.tera b/templates/index.html.tera index de691d6..dde87ba 100644 --- a/templates/index.html.tera +++ b/templates/index.html.tera @@ -90,6 +90,10 @@ Bootsreservierung +
  • + Hängerreservierung +
  • Steuerleute & Co
  • diff --git a/templates/trailerreservations.html.tera b/templates/trailerreservations.html.tera new file mode 100644 index 0000000..b938bd2 --- /dev/null +++ b/templates/trailerreservations.html.tera @@ -0,0 +1,98 @@ +{% import "includes/macros" as macros %} +{% import "includes/forms/log" as log %} +{% extends "base" %} +{% block content %} +
    +

    Hängerreservierungen

    +

    + Neue Reservierung + + {% include "includes/plus-icon" %} + Neue Reservierung eintragen + +

    + +
    + + +
    +
    + {% for reservation in trailerreservations %} + {% set allowed_to_edit = false %} + {% if loggedin_user %} + {% if loggedin_user.id == reservation.user_applicant.id or "admin" in loggedin_user.roles %} + {% set allowed_to_edit = true %} + {% endif %} + {% endif %} +
    +
    + Boot: + {{ reservation.trailer.name }} +
    + Reservierung: + {{ reservation.user_applicant.name }} +
    + Datum: + {{ reservation.start_date }} + {% if reservation.end_date != reservation.start_date %} + - + {{ reservation.end_date }} + {% endif %} +
    + {% if not allowed_to_edit %} + Uhrzeit: + {{ reservation.time_desc }} +
    + Zweck: + {{ reservation.usage }} + {% endif %} + {% if allowed_to_edit %} +
    +
    + + {{ macros::input(label='Uhrzeit', name='time_desc', id=loop.index, type="text", value=reservation.time_desc, readonly=false) }} + {{ macros::input(label='Zweck', name='usage', id=loop.index, type="text", value=reservation.usage, readonly=false) }} +
    + +
    + {% endif %} +
    +
    + {% endfor %} +
    +{% endblock content %}