diff --git a/.sqlx/query-2a7e94e6d4fcaa3afc2755ee236d116547656c3418174463aacadc69d57cbf63.json b/.sqlx/query-2a7e94e6d4fcaa3afc2755ee236d116547656c3418174463aacadc69d57cbf63.json new file mode 100644 index 00000000..91694b8f --- /dev/null +++ b/.sqlx/query-2a7e94e6d4fcaa3afc2755ee236d116547656c3418174463aacadc69d57cbf63.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE assignment SET function = $1, startTime = $2, endTime = $3 WHERE eventId = $4 AND availabillityId = $5;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + { + "Custom": { + "name": "function", + "kind": { + "Enum": [ + "posten", + "fuehrungsassistent", + "wachhabender" + ] + } + } + }, + "Time", + "Time", + "Int4", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "2a7e94e6d4fcaa3afc2755ee236d116547656c3418174463aacadc69d57cbf63" +} diff --git a/.sqlx/query-2d516ada804b4e3306e2a444602b303cf579a1c2d9c445d68f7b17319dc43007.json b/.sqlx/query-2d516ada804b4e3306e2a444602b303cf579a1c2d9c445d68f7b17319dc43007.json new file mode 100644 index 00000000..2849fac7 --- /dev/null +++ b/.sqlx/query-2d516ada804b4e3306e2a444602b303cf579a1c2d9c445d68f7b17319dc43007.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT * FROM location WHERE id= $1);", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2d516ada804b4e3306e2a444602b303cf579a1c2d9c445d68f7b17319dc43007" +} diff --git a/.sqlx/query-5e66fa920534af67e80c538be4fa5dbae6ac53427e697be5c71d36a16ea01154.json b/.sqlx/query-5e66fa920534af67e80c538be4fa5dbae6ac53427e697be5c71d36a16ea01154.json new file mode 100644 index 00000000..a632a0cb --- /dev/null +++ b/.sqlx/query-5e66fa920534af67e80c538be4fa5dbae6ac53427e697be5c71d36a16ea01154.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE event SET date = $1, startTime = $2, endTime = $3, name = $4, locationId = $5, voluntaryWachhabender = $6, voluntaryFuehrungsassistent = $7, amountOfPosten = $8, clothing = $9, note = $10 WHERE id = $11;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Date", + "Time", + "Time", + "Text", + "Int4", + "Bool", + "Bool", + "Int2", + "Text", + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "5e66fa920534af67e80c538be4fa5dbae6ac53427e697be5c71d36a16ea01154" +} diff --git a/Cargo.lock b/Cargo.lock index d0b2bd3e..a284d894 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -655,6 +655,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -764,10 +773,11 @@ dependencies = [ "dotenv", "fake", "futures-util", + "garde", "insta", "lettre", "pico-args", - "quick-xml", + "quick-xml 0.37.2", "rand", "regex", "rinja", @@ -775,7 +785,7 @@ dependencies = [ "serde_json", "sqlx", "static-files", - "thiserror", + "thiserror 2.0.9", "zxcvbn", ] @@ -833,6 +843,25 @@ dependencies = [ "bytes", ] +[[package]] +name = "card-validate" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655fa70596e2a38372c0c0c4449ec0166ad9cc43d91558bbecc1a6f38bf9eb91" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "castaway" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.7" @@ -941,6 +970,21 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1350,7 +1394,7 @@ checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ "bit-set", "regex-automata", - "regex-syntax", + "regex-syntax 0.8.5", ] [[package]] @@ -1537,6 +1581,37 @@ dependencies = [ "slab", ] +[[package]] +name = "garde" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dbf10452e3dbf51033a5035a05762b2653c43bf84d46e96f15bc93beedd426d" +dependencies = [ + "card-validate", + "compact_str", + "garde_derive", + "idna", + "once_cell", + "phonenumber", + "regex", + "serde", + "smallvec", + "unicode-segmentation", + "url", +] + +[[package]] +name = "garde_derive" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccfdbc9c39fad7991686e229c55cf71565eafe73dcb2cf38ddf1d4aa3ca7e176" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.95", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1972,6 +2047,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2137,6 +2221,15 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "md-5" version = "0.10.6" @@ -2291,6 +2384,12 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "oncemutex" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d11de466f4a3006fe8a5e7ec84e93b79c70cb992ae0aa0eb631ad2df8abfe2" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -2417,6 +2516,27 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "phonenumber" +version = "0.3.6+8.13.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11756237b57b8cc5e97dc8b1e70ea436324d30e7075de63b14fd15073a8f692a" +dependencies = [ + "bincode", + "either", + "fnv", + "itertools 0.12.1", + "lazy_static", + "nom", + "quick-xml 0.31.0", + "regex", + "regex-cache", + "serde", + "serde_derive", + "strum", + "thiserror 1.0.69", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -2549,6 +2669,15 @@ dependencies = [ "cc", ] +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.37.2" @@ -2622,7 +2751,7 @@ dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax", + "regex-syntax 0.8.5", ] [[package]] @@ -2633,7 +2762,19 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-cache" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7b62d69743b8b94f353b6b7c3deb4c5582828328bcb8d5fedf214373808793" +dependencies = [ + "lru-cache", + "oncemutex", + "regex", + "regex-syntax 0.6.29", ] [[package]] @@ -2642,6 +2783,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -2812,6 +2959,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" + [[package]] name = "ryu" version = "1.0.18" @@ -3060,7 +3213,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.9", "tracing", "url", "webpki-roots", @@ -3143,7 +3296,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.9", "tracing", "whoami", ] @@ -3181,7 +3334,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.9", "tracing", "whoami", ] @@ -3240,6 +3393,12 @@ dependencies = [ "path-slash", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stringprep" version = "0.1.5" @@ -3257,6 +3416,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.95", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3310,13 +3491,33 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.9", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.95", ] [[package]] @@ -3487,6 +3688,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -4008,7 +4215,7 @@ dependencies = [ "chrono", "derive_builder", "fancy-regex", - "itertools", + "itertools 0.13.0", "lazy_static", "regex", "time", diff --git a/web/Cargo.toml b/web/Cargo.toml index 3149cae8..fc468ed1 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -33,6 +33,7 @@ brass-macros = { path = "../macros" } brass-config = { path = "../config" } actix-http = "3.9.0" rinja = "0.3.5" +garde = { version = "0.21.0", features = ["full"] } [build-dependencies] built = "0.7.4" diff --git a/web/src/endpoints/assignment/post_new.rs b/web/src/endpoints/assignment/post_new.rs index a8d620f0..3083f1bf 100644 --- a/web/src/endpoints/assignment/post_new.rs +++ b/web/src/endpoints/assignment/post_new.rs @@ -1,11 +1,15 @@ use actix_web::{web, HttpResponse, Responder}; +use garde::Validate; use rinja::Template; use serde::Deserialize; use sqlx::PgPool; use crate::{ endpoints::assignment::PlanEventPersonalTablePartialTemplate, - models::{Assignment, Availabillity, Event, Function, Role, User}, + models::{ + Assignment, AssignmentChangeset, AssignmentContext, Availabillity, Event, Function, Role, + User, + }, utils::{ event_planning_template::{ generate_availabillity_assignment_list, generate_status_whether_staff_is_required, @@ -40,55 +44,43 @@ pub async fn post( return Err(ApplicationError::Unauthorized); } - let Some(availability) = + let Some(availabillity) = Availabillity::read_by_id_including_user(pool.get_ref(), query.availabillity).await? else { return Ok(HttpResponse::NotFound().finish()); }; let availability_user_not_in_event_location_area = - availability.user.as_ref().unwrap().area_id != event.location.as_ref().unwrap().area_id; + availabillity.user.as_ref().unwrap().area_id != event.location.as_ref().unwrap().area_id; - let existing_assignments_for_availabillity = - Assignment::read_all_by_availabillity(pool.get_ref(), availability.id).await?; - let has_start_time_during_event = - |a: &Assignment| a.start_time >= event.start_time && a.start_time <= event.end_time; - let has_end_time_during_event = - |a: &Assignment| a.end_time >= event.start_time && a.end_time <= event.end_time; - - let availability_already_assigned = existing_assignments_for_availabillity - .iter() - .any(|a| has_start_time_during_event(a) || has_end_time_during_event(a)); - - let function = Function::try_from(query.function)?; - let user_not_qualified_for_assigned_function = - availability.user.as_ref().unwrap().function < function; - - let a = Assignment::count_by_event_and_function(pool.get_ref(), event.id, function).await?; - let event_already_has_enough_assignments_for_function = match function { - Function::Posten => a >= event.amount_of_posten as i64, - Function::Fuehrungsassistent => event.voluntary_fuehrungsassistent && a >= 1, - Function::Wachhabender => event.voluntary_wachhabender && a >= 1, - }; - - if availability_user_not_in_event_location_area - || availability_already_assigned - || user_not_qualified_for_assigned_function - || event_already_has_enough_assignments_for_function - { - // TODO: Fehlermeldung verbessern - return Ok(HttpResponse::BadRequest().body(format!("{availability_user_not_in_event_location_area} {availability_already_assigned} {user_not_qualified_for_assigned_function} {event_already_has_enough_assignments_for_function}"))); + if availability_user_not_in_event_location_area { + return Ok(HttpResponse::BadRequest() + .body("availability user is not in the same area as event location")); } - Assignment::create( - pool.get_ref(), - event.id, - availability.id, + let function = Function::try_from(query.function)?; + + let changeset = AssignmentChangeset { function, - event.start_time, - event.end_time, - ) - .await?; + time: (event.start_time, event.end_time), + }; + + let assignments_for_event = Assignment::read_all_by_event(pool.get_ref(), event.id).await?; + let assignments_for_availabillity = + Assignment::read_all_by_availabillity(pool.get_ref(), availabillity.id).await?; + let context = AssignmentContext { + event: event.clone(), + availabillity: availabillity.clone(), + user_function: availabillity.user.as_ref().unwrap().function, + assignments_for_event, + assignments_for_availabillity, + }; + + if let Err(e) = changeset.validate_with(&context) { + return Ok(HttpResponse::BadRequest().body(e.to_string())); + }; + + Assignment::create(pool.get_ref(), event.id, availabillity.id, changeset).await?; let availabillities = generate_availabillity_assignment_list(pool.get_ref(), &event).await?; diff --git a/web/src/endpoints/events/get_edit.rs b/web/src/endpoints/events/get_edit.rs index 31451ad6..9ff0b817 100644 --- a/web/src/endpoints/events/get_edit.rs +++ b/web/src/endpoints/events/get_edit.rs @@ -86,21 +86,24 @@ async fn produces_template(context: &DbTestContext) { .await .unwrap(); - Event::create( - &context.db_pool, - &NaiveDate::parse_from_str("2025-01-01", "%F").unwrap(), - &NaiveTime::parse_from_str("08:00", "%R").unwrap(), - &NaiveTime::parse_from_str("10:00", "%R").unwrap(), - &"Vorstellung".to_string(), - 1, - false, - false, - 2, - &"Tuchuniform".to_string(), - None, - ) - .await - .unwrap(); + let changeset = crate::models::EventChangeset { + date: NaiveDate::parse_from_str("2025-01-01", "%F").unwrap(), + time: ( + NaiveTime::parse_from_str("08:00", "%R").unwrap(), + NaiveTime::parse_from_str("10:00", "%R").unwrap(), + ), + name: "Vorstellung".to_string(), + location_id: 1, + voluntary_fuehrungsassistent: false, + voluntary_wachhabender: false, + amount_of_posten: 2, + clothing: "Tuchuniform".to_string(), + note: None, + }; + + Event::create(&context.db_pool, changeset) + .await + .unwrap(); let app = context.app().await; let config = RequestConfig { diff --git a/web/src/endpoints/events/mod.rs b/web/src/endpoints/events/mod.rs index b7f7d995..e4c9d5ac 100644 --- a/web/src/endpoints/events/mod.rs +++ b/web/src/endpoints/events/mod.rs @@ -8,6 +8,7 @@ pub mod get_edit; pub mod get_new; pub mod get_plan; pub mod post_new; +pub mod post_edit; #[derive(Template)] #[cfg_attr(not(test), template(path = "events/new_or_edit.html"))] diff --git a/web/src/endpoints/events/post_edit.rs b/web/src/endpoints/events/post_edit.rs new file mode 100644 index 00000000..43154f9a --- /dev/null +++ b/web/src/endpoints/events/post_edit.rs @@ -0,0 +1,187 @@ +use actix_web::{http::header::LOCATION, web, HttpResponse, Responder}; +use chrono::{NaiveDate, NaiveTime}; +use garde::Validate; +use serde::Deserialize; +use sqlx::PgPool; + +use crate::{ + endpoints::IdPath, + models::{ + Assignment, AssignmentChangeset, Availabillity, Event, EventChangeset, EventContext, + Function, Location, Role, User, + }, + utils::{self, ApplicationError}, +}; + +#[derive(Deserialize)] +pub struct EditEventForm { + name: String, + date: NaiveDate, + from: NaiveTime, + till: NaiveTime, + location: i32, + voluntarywachhabender: Option, + voluntaryfuehrungsassistent: Option, + amount: i16, + clothing: String, + note: Option, +} + +#[actix_web::post("/events/{id}/edit")] +pub async fn post( + user: web::ReqData, + pool: web::Data, + form: web::Form, + path: web::Path, +) -> Result { + if user.role != Role::Admin && user.role != Role::AreaManager { + return Err(ApplicationError::Unauthorized); + } + + let Some(event) = Event::read_by_id_including_location(pool.get_ref(), path.id).await? else { + return Ok(HttpResponse::NotFound().finish()); + }; + + if event.location_id != form.location { + let Some(location) = Location::read_by_id(pool.get_ref(), form.location).await? else { + return Ok(HttpResponse::BadRequest().body("Location does not exist")); + }; + + if user.role != Role::Admin && user.area_id != location.area_id { + return Ok(HttpResponse::BadRequest().body("Can't use location outside of your area")); + } + + if event.location.as_ref().unwrap().area_id != location.area_id { + return Ok(HttpResponse::BadRequest() + .body("Can't change to a location outside of previous location area")); + } + } + + let changeset = EventChangeset { + date: form.date, + amount_of_posten: form.amount, + clothing: form.clothing.clone(), + location_id: form.location, + time: (form.from, form.till), + name: form.name.clone(), + note: form + .note + .clone() + .and_then(|n| if n.len() != 0 { Some(n) } else { None }), + voluntary_wachhabender: form.voluntarywachhabender.unwrap_or(false), + voluntary_fuehrungsassistent: form.voluntaryfuehrungsassistent.unwrap_or(false), + }; + + let assignments_for_event = Assignment::read_all_by_event(pool.get_ref(), event.id).await?; + + let mut common_time = ( + NaiveTime::parse_from_str("00:00", "%R").unwrap(), + NaiveTime::parse_from_str("23:59", "%R").unwrap(), + ); + for assignment in &assignments_for_event { + let availability = Availabillity::read_by_id(pool.get_ref(), assignment.availabillity_id) + .await? + .unwrap(); + let all_assignments = + Assignment::read_all_by_availabillity(pool.get_ref(), assignment.availabillity_id) + .await?; + + if all_assignments.len() == 1 { + if availability.start_time.is_some() && availability.end_time.is_some() { + let start = availability.start_time.unwrap(); + let end = availability.end_time.unwrap(); + + if start > common_time.0 { + common_time.0 = start; + } + + if end < common_time.1 { + common_time.1 = end; + } + } + } else { + let mut slots = if availability.start_time.is_some() && availability.end_time.is_some() + { + vec![( + availability.start_time.as_ref().unwrap().clone(), + availability.end_time.as_ref().unwrap().clone(), + )] + } else { + vec![( + NaiveTime::parse_from_str("00:00", "%R").unwrap(), + NaiveTime::parse_from_str("23:59", "%R").unwrap(), + )] + }; + for a in all_assignments + .iter() + .filter(|x| x.event_id != assignment.event_id) + { + let (fit, rest) = slots + .into_iter() + .partition(|s| s.0 >= a.start_time && s.1 <= a.end_time); + slots = rest; + let fit = fit.first().unwrap(); + + if fit.0 != a.start_time { + slots.push((fit.0, a.start_time)); + } + + if fit.1 != a.end_time { + slots.push((a.end_time, fit.1)); + } + } + + let slot = slots + .into_iter() + .find(|s| s.0 >= assignment.start_time && s.1 <= assignment.end_time) + .unwrap(); + + if slot.0 > common_time.0 { + common_time.0 = slot.0; + } + + if slot.1 < common_time.1 { + common_time.1 = slot.1; + } + } + } + + let context = Some(EventContext { + date_in_db: event.date, + common_min_max_available_time: common_time, + // safe as max amount of posten can be only 100 + amount_of_assigned_posten: assignments_for_event + .iter() + .filter(|a| a.function == Function::Posten) + .count() as i16, + wachhabender_assigned: assignments_for_event + .iter() + .any(|a| a.function == Function::Wachhabender), + fuehrungsassistent_assigned: assignments_for_event + .iter() + .any(|a| a.function == Function::Fuehrungsassistent), + }); + + if let Err(e) = changeset.validate_with(&context) { + return Ok(HttpResponse::BadRequest().body(e.to_string())); + }; + + if event.start_time != changeset.time.0 || event.end_time != changeset.time.1 { + for a in assignments_for_event { + let c = AssignmentChangeset { + function: a.function, + time: changeset.time.clone(), + }; + Assignment::update(pool.get_ref(), a.event_id, a.availabillity_id, c).await?; + } + } + + Event::update(pool.get_ref(), event.id, changeset).await?; + + let url = utils::get_return_url_for_date(&form.date); + println!("redirecto to {url}"); + Ok(HttpResponse::Found() + .insert_header((LOCATION, url.clone())) + .insert_header(("HX-LOCATION", url)) + .finish()) +} diff --git a/web/src/endpoints/events/post_new.rs b/web/src/endpoints/events/post_new.rs index ec37b5d1..34fd85c4 100644 --- a/web/src/endpoints/events/post_new.rs +++ b/web/src/endpoints/events/post_new.rs @@ -1,10 +1,11 @@ use actix_web::{http::header::LOCATION, web, HttpResponse, Responder}; use chrono::{NaiveDate, NaiveTime}; +use garde::Validate; use serde::Deserialize; use sqlx::PgPool; use crate::{ - models::{Event, Role, User}, + models::{Event, EventChangeset, Location, Role, User}, utils::{self, ApplicationError}, }; @@ -32,24 +33,34 @@ pub async fn post( return Err(ApplicationError::Unauthorized); } - println!("{:?}", form.note); + let Some(location) = Location::read_by_id(pool.get_ref(), form.location).await? else { + return Ok(HttpResponse::BadRequest().body("Location does not exist")); + }; - Event::create( - pool.get_ref(), - &form.date, - &form.from, - &form.till, - &form.name, - form.location, - form.voluntarywachhabender.unwrap_or(false), - form.voluntaryfuehrungsassistent.unwrap_or(false), - form.amount, - &form.clothing, - form.note - .as_ref() + if user.role != Role::Admin && user.area_id != location.area_id { + return Ok(HttpResponse::BadRequest().body("Can't use location outside of your area")); + } + + let changeset = EventChangeset { + date: form.date, + amount_of_posten: form.amount, + clothing: form.clothing.clone(), + location_id: form.location, + time: (form.from, form.till), + name: form.name.clone(), + note: form + .note + .clone() .and_then(|n| if n.len() != 0 { Some(n) } else { None }), - ) - .await?; + voluntary_wachhabender: form.voluntarywachhabender.unwrap_or(false), + voluntary_fuehrungsassistent: form.voluntaryfuehrungsassistent.unwrap_or(false), + }; + + if let Err(e) = changeset.validate_with(&None) { + return Ok(HttpResponse::BadRequest().body(e.to_string())); + }; + + Event::create(pool.get_ref(), changeset).await?; let url = utils::get_return_url_for_date(&form.date); println!("redirecto to {url}"); diff --git a/web/src/endpoints/mod.rs b/web/src/endpoints/mod.rs index 575a0889..9bd5ee5f 100644 --- a/web/src/endpoints/mod.rs +++ b/web/src/endpoints/mod.rs @@ -7,9 +7,9 @@ mod assignment; mod availability; mod events; mod export; +mod imprint; mod location; pub mod user; -mod imprint; mod vehicle; mod vehicle_assignment; @@ -60,6 +60,7 @@ pub fn init(cfg: &mut ServiceConfig) { cfg.service(events::post_new::post); cfg.service(events::get_plan::get); cfg.service(events::get_edit::get); + cfg.service(events::post_edit::post); cfg.service(assignment::post_new::post); cfg.service(assignment::delete::delete); diff --git a/web/src/models/assignement.rs b/web/src/models/assignement.rs index 638fb861..592ef65d 100644 --- a/web/src/models/assignement.rs +++ b/web/src/models/assignement.rs @@ -1,7 +1,7 @@ use chrono::NaiveTime; use sqlx::{query, PgPool}; -use super::{Function, Result}; +use super::{assignment_changeset::AssignmentChangeset, Function, Result}; pub struct Assignment { pub event_id: i32, @@ -16,9 +16,7 @@ impl Assignment { pool: &PgPool, event_id: i32, availabillity_id: i32, - function: Function, - start_time: NaiveTime, - end_time: NaiveTime, + changeset: AssignmentChangeset ) -> Result<()> { query!( r##" @@ -27,9 +25,9 @@ impl Assignment { "##, event_id, availabillity_id, - function as Function, - start_time, - end_time + changeset.function as Function, + changeset.time.0, + changeset.time.1 ) .execute(pool) .await?; @@ -154,6 +152,17 @@ impl Assignment { Ok(assignemnet) } + pub async fn update( + pool: &PgPool, + event_id: i32, + availabillity_id: i32, + changeset: AssignmentChangeset, + ) -> Result<()> { + query!("UPDATE assignment SET function = $1, startTime = $2, endTime = $3 WHERE eventId = $4 AND availabillityId = $5;", changeset.function as Function, changeset.time.0, changeset.time.1, event_id, availabillity_id).execute(pool).await?; + + Ok(()) + } + pub async fn delete(pool: &PgPool, event_id: i32, availabillity_id: i32) -> Result<()> { query!("DELETE FROM assignment WHERE assignment.eventId = $1 AND assignment.availabillityId = $2;", event_id, diff --git a/web/src/models/assignment_changeset.rs b/web/src/models/assignment_changeset.rs new file mode 100644 index 00000000..2de850e4 --- /dev/null +++ b/web/src/models/assignment_changeset.rs @@ -0,0 +1,119 @@ +use chrono::NaiveTime; +use garde::Validate; + +use super::{start_time_lies_before_end_time, Assignment, Availabillity, Event, Function}; + +#[derive(Validate)] +#[garde(allow_unvalidated)] +#[garde(context(AssignmentContext as ctx))] +/// check before: event and availabillity and must exist and user of availabillity is in event location +pub struct AssignmentChangeset { + #[garde( + custom(user_of_availability_has_function), + custom(event_has_free_slot_for_function) + )] + pub function: Function, + #[garde( + custom(available_time_fits), + custom(start_time_lies_before_end_time), + custom(availabillity_not_already_assigned) + )] + pub time: (NaiveTime, NaiveTime), +} + +pub struct AssignmentContext { + pub event: Event, + pub availabillity: Availabillity, + pub user_function: Function, + pub assignments_for_event: Vec, + pub assignments_for_availabillity: Vec, +} + +fn available_time_fits( + value: &(NaiveTime, NaiveTime), + context: &AssignmentContext, +) -> garde::Result { + if context.availabillity.start_time.is_some() && context.availabillity.end_time.is_some() { + let start = context.availabillity.start_time.as_ref().unwrap(); + let end = context.availabillity.end_time.as_ref().unwrap(); + + if value.0 < *start || value.1 > *end { + return Err(garde::Error::new( + "time not made available can't be assigned", + )); + } + } + + Ok(()) +} + +fn user_of_availability_has_function( + value: &Function, + context: &AssignmentContext, +) -> garde::Result { + if *value > context.user_function { + return Err(garde::Error::new( + "user has not the required function for this assignment", + )); + } + + Ok(()) +} + +fn event_has_free_slot_for_function( + _value: &Function, + context: &AssignmentContext, +) -> garde::Result { + let list: Vec<&Assignment> = context + .assignments_for_event + .iter() + .filter(|a| { + a.availabillity_id != context.availabillity.id && a.event_id != context.event.id + }) + .collect(); + + let a = list + .iter() + .filter(|a| a.function == Function::Posten) + .count(); + if match context.user_function { + Function::Posten => a >= context.event.amount_of_posten as usize, + Function::Fuehrungsassistent => context.event.voluntary_fuehrungsassistent && a >= 1, + Function::Wachhabender => context.event.voluntary_wachhabender && a >= 1, + } { + return Err(garde::Error::new( + "event already has enough assignments for this function", + )); + } + + Ok(()) +} + +fn availabillity_not_already_assigned( + value: &(NaiveTime, NaiveTime), + context: &AssignmentContext, +) -> garde::Result { + let list: Vec<&Assignment> = context + .assignments_for_availabillity + .iter() + .filter(|a| { + a.availabillity_id != context.availabillity.id && a.event_id != context.event.id + }) + .collect(); + + let has_start_time_during_assignment = + |a: &Assignment| a.start_time >= value.0 && a.start_time <= value.1; + let has_end_time_during_assignment = + |a: &Assignment| a.end_time >= value.0 && a.end_time <= value.1; + + if list + .iter() + .any(|a| has_start_time_during_assignment(a) || has_end_time_during_assignment(a)) + { + return Err(garde::Error::new( + "availabillity is already assigned for that time", + )); + } + + Ok(()) +} diff --git a/web/src/models/event.rs b/web/src/models/event.rs index f7737cca..222533bb 100644 --- a/web/src/models/event.rs +++ b/web/src/models/event.rs @@ -1,9 +1,9 @@ use chrono::{NaiveDate, NaiveTime}; use sqlx::{query, PgPool}; -use super::{Location, Result}; +use super::{event_changeset::EventChangeset, Location, Result}; -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct Event { pub id: i32, pub date: NaiveDate, @@ -21,24 +21,12 @@ pub struct Event { } impl Event { - pub async fn create( - pool: &PgPool, - date: &NaiveDate, - start_time: &NaiveTime, - end_time: &NaiveTime, - name: &String, - location_id: i32, - voluntary_wachhabender: bool, - voluntary_fuehrungsassistent: bool, - amount_of_posten: i16, - clothing: &String, - note: Option<&String>, - ) -> Result<()> { + pub async fn create(pool: &PgPool, changeset: EventChangeset) -> Result<()> { query!(r#" INSERT INTO event (date, startTime, endTime, name, locationId, voluntaryWachhabender, voluntaryFuehrungsassistent, amountOfPosten, clothing, note) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10); "#, - date, start_time, end_time, name, location_id, voluntary_wachhabender, voluntary_fuehrungsassistent, amount_of_posten, clothing, note).execute(pool).await?; + changeset.date, changeset.time.0, changeset.time.1,changeset.name, changeset.location_id, changeset.voluntary_wachhabender, changeset.voluntary_fuehrungsassistent, changeset.amount_of_posten, changeset.clothing, changeset.note).execute(pool).await?; Ok(()) } @@ -157,4 +145,17 @@ impl Event { Ok(event) } + + pub async fn update(pool: &PgPool, id: i32, changeset: EventChangeset) -> Result<()> { + query!(r#" + UPDATE event SET date = $1, startTime = $2, endTime = $3, name = $4, locationId = $5, voluntaryWachhabender = $6, voluntaryFuehrungsassistent = $7, amountOfPosten = $8, clothing = $9, note = $10 WHERE id = $11; + "#, + changeset.date, + changeset.time.0, + changeset.time.1,changeset.name, changeset.location_id, changeset.voluntary_wachhabender, changeset.voluntary_fuehrungsassistent, changeset.amount_of_posten, changeset.clothing, changeset.note, id) + .execute(pool) + .await?; + + Ok(()) + } } diff --git a/web/src/models/event_changeset.rs b/web/src/models/event_changeset.rs new file mode 100644 index 00000000..ef477b3b --- /dev/null +++ b/web/src/models/event_changeset.rs @@ -0,0 +1,97 @@ +use chrono::NaiveDate; +use chrono::NaiveTime; +use garde::Validate; + +use super::start_time_lies_before_end_time; + +#[derive(Validate)] +#[garde(allow_unvalidated)] +#[garde(context(Option as ctx))] +pub struct EventChangeset { + #[garde(custom(date_unchanged_if_edit))] + pub date: NaiveDate, + #[garde( + custom(start_time_lies_before_end_time), + custom(time_can_be_extended_if_edit) + )] + pub time: (NaiveTime, NaiveTime), + pub name: String, + /// check before: must exist and user can create event for this location + pub location_id: i32, + #[garde(custom(can_unset_wachhabender))] + pub voluntary_wachhabender: bool, + #[garde(custom(can_unset_fuehrungsassistent))] + pub voluntary_fuehrungsassistent: bool, + #[garde(range(min = ctx.as_ref().and_then(|c: &EventContext| Some(c.amount_of_assigned_posten)).unwrap_or(1), max = 100))] + pub amount_of_posten: i16, + pub clothing: String, + pub note: Option, +} + +pub struct EventContext { + pub date_in_db: NaiveDate, + pub common_min_max_available_time: (NaiveTime, NaiveTime), + pub wachhabender_assigned: bool, + pub fuehrungsassistent_assigned: bool, + pub amount_of_assigned_posten: i16, +} + +fn date_unchanged_if_edit(value: &NaiveDate, context: &Option) -> garde::Result { + if context.as_ref().is_some_and(|c| c.date_in_db != *value) { + return Err(garde::Error::new("event date can't be changed")); + } + + Ok(()) +} + +fn time_can_be_extended_if_edit( + value: &(NaiveTime, NaiveTime), + context: &Option, +) -> garde::Result { + if let Some(context) = context { + let old_start_time = context.common_min_max_available_time.0; + let new_start_time = value.0; + let old_end_time = context.common_min_max_available_time.1; + let new_end_time = value.1; + + if new_start_time < old_start_time { + return Err(garde::Error::new( + "starttime lies outside of available time for assigned people", + )); + } + + if new_end_time > old_end_time { + return Err(garde::Error::new( + "endtime lies ouside of available time for assigned people", + )); + } + } + + Ok(()) +} + +fn can_unset_fuehrungsassistent(value: &bool, context: &Option) -> garde::Result { + if context + .as_ref() + .is_some_and(|c| !*value && c.fuehrungsassistent_assigned) + { + return Err(garde::Error::new( + "fuehrungsassistent can't be set to not by ff, because a person is already assigned", + )); + } + + Ok(()) +} + +fn can_unset_wachhabender(value: &bool, context: &Option) -> garde::Result { + if context + .as_ref() + .is_some_and(|c| !*value && c.wachhabender_assigned) + { + return Err(garde::Error::new( + "wachhabender can't be set to not by ff, because a person is already assigned", + )); + } + + Ok(()) +} diff --git a/web/src/models/location.rs b/web/src/models/location.rs index 449ecf7f..ea3020c1 100644 --- a/web/src/models/location.rs +++ b/web/src/models/location.rs @@ -4,7 +4,7 @@ use super::Area; use super::Result; -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct Location { pub id: i32, pub name: String, @@ -138,4 +138,12 @@ impl Location { Ok(()) } + + pub async fn exists(pool: &PgPool, id: i32) -> Result { + let b = query!("SELECT EXISTS(SELECT * FROM location WHERE id= $1);", id) + .fetch_one(pool) + .await?; + + Ok(b.exists.is_some_and(|e| e)) + } } diff --git a/web/src/models/mod.rs b/web/src/models/mod.rs index 12262251..3e0d1e5c 100644 --- a/web/src/models/mod.rs +++ b/web/src/models/mod.rs @@ -1,27 +1,43 @@ mod area; mod assignement; +mod assignment_changeset; mod availabillity; mod event; +mod event_changeset; mod function; mod location; +mod password_reset; +mod registration; mod role; mod user; mod vehicle; -mod password_reset; -mod registration; mod vehicle_assignement; pub use area::Area; +pub use assignement::Assignment; +pub use assignment_changeset::{AssignmentChangeset, AssignmentContext}; pub use availabillity::{Availabillity, AvailabillityAssignmentState}; +use chrono::NaiveTime; pub use event::Event; +pub use event_changeset::{EventChangeset, EventContext}; pub use function::Function; pub use location::Location; +pub use password_reset::{NoneToken, PasswordReset, Token}; +pub use registration::Registration; pub use role::Role; pub use user::User; -pub use assignement::Assignment; -pub use password_reset::{PasswordReset, Token, NoneToken}; -pub use registration::Registration; pub use vehicle::Vehicle; pub use vehicle_assignement::VehicleAssignement; type Result = std::result::Result; + +fn start_time_lies_before_end_time( + value: &(NaiveTime, NaiveTime), + _context: &T, +) -> garde::Result { + if value.0 >= value.1 { + return Err(garde::Error::new("endtime can't lie before starttime")); + } + + Ok(()) +}