diff --git a/src/model/logbook.rs b/src/model/logbook.rs index a85ab46..076f2a6 100644 --- a/src/model/logbook.rs +++ b/src/model/logbook.rs @@ -33,7 +33,7 @@ impl PartialEq for Logbook { pub(crate) enum Filter { SingleDayOnly, - MultiDazOnly, + MultiDayOnly, None, } @@ -362,12 +362,13 @@ ORDER BY departure DESC } } - pub(crate) async fn completed_wanderfahrten_with_user_over_km_in_year( - db: &SqlitePool, + pub(crate) async fn completed_wanderfahrten_with_user_over_km_in_year_tx( + db: &mut Transaction<'_, Sqlite>, user: &User, min_distance: i32, year: i32, filter: Filter, + exclude_last_log: bool, ) -> Vec { let logs: Vec = sqlx::query_as( &format!(" @@ -378,7 +379,7 @@ ORDER BY departure DESC ORDER BY arrival DESC ", user.id, min_distance, year) ) - .fetch_all(db) + .fetch_all(db.deref_mut()) .await .unwrap(); //TODO: fixme @@ -389,19 +390,23 @@ ORDER BY departure DESC match filter { Filter::SingleDayOnly => { if trip_days == 0 { - ret.push(LogbookWithBoatAndRowers::from(db, log).await); + ret.push(LogbookWithBoatAndRowers::from_tx(db, log).await); } } - Filter::MultiDazOnly => { + Filter::MultiDayOnly => { if trip_days > 0 { - ret.push(LogbookWithBoatAndRowers::from(db, log).await); + ret.push(LogbookWithBoatAndRowers::from_tx(db, log).await); } } Filter::None => { - ret.push(LogbookWithBoatAndRowers::from(db, log).await); + ret.push(LogbookWithBoatAndRowers::from_tx(db, log).await); } } } + if exclude_last_log { + ret.pop(); + } + ret } diff --git a/src/model/personal/equatorprice.rs b/src/model/personal/equatorprice.rs index 97a489a..a03d9eb 100644 --- a/src/model/personal/equatorprice.rs +++ b/src/model/personal/equatorprice.rs @@ -1,3 +1,4 @@ +use crate::model::{logbook::Logbook, stat::Stat, user::User}; use serde::Serialize; #[derive(Serialize, PartialEq, Debug)] @@ -85,3 +86,19 @@ impl Next { } } } + +pub(crate) async fn new_level_with_last_log( + db: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + user: &User, +) -> Option { + let rowed_km = Stat::total_km_tx(db, user).await.rowed_km; + + if let Some(last_logbookentry) = Logbook::completed_with_user_tx(db, user).await.last() { + let last_trip_km = last_logbookentry.logbook.distance_in_km.unwrap(); + if Level::curr_level(rowed_km) != Level::curr_level(rowed_km - last_trip_km as i32) { + return Some(Level::curr_level(rowed_km).desc().to_string()); + } + } + + None +} diff --git a/src/model/personal/rowingbadge.rs b/src/model/personal/rowingbadge.rs index 446b886..93a969a 100644 --- a/src/model/personal/rowingbadge.rs +++ b/src/model/personal/rowingbadge.rs @@ -2,7 +2,7 @@ use std::cmp; use chrono::{Datelike, Local, NaiveDate}; use serde::Serialize; -use sqlx::SqlitePool; +use sqlx::{Sqlite, SqlitePool, Transaction}; use crate::model::{ logbook::{Filter, Logbook, LogbookWithBoatAndRowers}, @@ -111,11 +111,44 @@ pub(crate) struct Status { } impl Status { - pub(crate) async fn for_user(db: &SqlitePool, user: &User) -> Option { + fn calc( + agebracket: &AgeBracket, + rowed_km: i32, + single_day_trips_over_required_distance: usize, + multi_day_trips_over_required_distance: usize, + year: i32, + ) -> Self { + let category = agebracket.cat().to_string(); + + let required_km = agebracket.dist_in_km(); + let missing_km = cmp::max(required_km - rowed_km, 0); + + let achieved = missing_km == 0 + && (multi_day_trips_over_required_distance >= 1 + || single_day_trips_over_required_distance >= 2); + + Self { + year, + rowed_km, + category, + required_km, + missing_km, + multi_day_trips_over_required_distance: vec![], + single_day_trips_over_required_distance: vec![], + multi_day_trips_required_distance: agebracket.required_dist_multi_day_in_km(), + single_day_trips_required_distance: agebracket.required_dist_single_day_in_km(), + achieved, + } + } + + pub(crate) async fn for_user_tx( + db: &mut Transaction<'_, Sqlite>, + user: &User, + exclude_last_log: bool, + ) -> Option { let Ok(agebracket) = AgeBracket::try_from(user) else { return None; }; - let category = agebracket.cat().to_string(); let year = if Local::now().month() == 1 { Local::now().year() - 1 @@ -123,44 +156,66 @@ impl Status { Local::now().year() }; - let rowed_km = Stat::person(db, Some(year), user).await.rowed_km; - let required_km = agebracket.dist_in_km(); - let missing_km = cmp::max(required_km - rowed_km, 0); - + let rowed_km = Stat::person_tx(db, Some(year), user).await.rowed_km; let single_day_trips_over_required_distance = - Logbook::completed_wanderfahrten_with_user_over_km_in_year( + Logbook::completed_wanderfahrten_with_user_over_km_in_year_tx( db, user, agebracket.required_dist_single_day_in_km(), year, Filter::SingleDayOnly, + exclude_last_log, ) .await; let multi_day_trips_over_required_distance = - Logbook::completed_wanderfahrten_with_user_over_km_in_year( + Logbook::completed_wanderfahrten_with_user_over_km_in_year_tx( db, user, agebracket.required_dist_multi_day_in_km(), year, - Filter::MultiDazOnly, + Filter::MultiDayOnly, + exclude_last_log, ) .await; - let achieved = missing_km == 0 - && (multi_day_trips_over_required_distance.len() >= 1 - || single_day_trips_over_required_distance.len() >= 2); + let ret = Self::calc( + &agebracket, + rowed_km, + single_day_trips_over_required_distance.len(), + multi_day_trips_over_required_distance.len(), + year, + ); Some(Self { - year, - rowed_km, - category, - required_km, - missing_km, multi_day_trips_over_required_distance, single_day_trips_over_required_distance, - multi_day_trips_required_distance: agebracket.required_dist_multi_day_in_km(), - single_day_trips_required_distance: agebracket.required_dist_single_day_in_km(), - achieved, + ..ret }) } + + pub(crate) async fn for_user(db: &SqlitePool, user: &User) -> Option { + let mut tx = db.begin().await.unwrap(); + let ret = Self::for_user_tx(&mut tx, user, false).await; + tx.commit().await.unwrap(); + ret + } + + pub(crate) async fn completed_with_last_log( + db: &mut Transaction<'_, Sqlite>, + user: &User, + ) -> bool { + if let Some(status) = Self::for_user_tx(db, user, false).await { + // if user has agebracket... + if status.achieved { + // ... and has achieved the 'Fahrtenabzeichen' + let without_last_entry = Self::for_user_tx(db, user, true).await.unwrap(); + if !without_last_entry.achieved { + // ... and this wasn't the case before the last logentry + return true; + } + } + } + + false + } } diff --git a/src/model/stat.rs b/src/model/stat.rs index 6ec6b16..2ed663a 100644 --- a/src/model/stat.rs +++ b/src/model/stat.rs @@ -1,9 +1,9 @@ -use std::collections::HashMap; +use std::{collections::HashMap, ops::DerefMut}; use crate::model::user::User; use chrono::Datelike; use serde::Serialize; -use sqlx::{FromRow, Row, SqlitePool}; +use sqlx::{FromRow, Row, Sqlite, SqlitePool, Transaction}; use super::boat::Boat; @@ -218,7 +218,7 @@ ORDER BY rowed_km DESC, u.name; .collect() } - pub async fn total_km(db: &SqlitePool, user: &User) -> Stat { + pub async fn total_km_tx(db: &mut Transaction<'_, Sqlite>, user: &User) -> Stat { //TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server) let row = sqlx::query(&format!( " @@ -233,7 +233,7 @@ WHERE l.distance_in_km IS NOT NULL; ", user.id )) - .fetch_one(db) + .fetch_one(db.deref_mut()) .await .unwrap(); @@ -244,7 +244,18 @@ WHERE l.distance_in_km IS NOT NULL; } } - pub async fn person(db: &SqlitePool, year: Option, user: &User) -> Stat { + pub async fn total_km(db: &SqlitePool, user: &User) -> Stat { + let mut tx = db.begin().await.unwrap(); + let ret = Self::total_km_tx(&mut tx, user).await; + tx.commit().await.unwrap(); + ret + } + + pub async fn person_tx( + db: &mut Transaction<'_, Sqlite>, + year: Option, + user: &User, + ) -> Stat { let year = match year { Some(year) => year, None => chrono::Local::now().year(), @@ -263,7 +274,7 @@ WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%'; ", user.id )) - .fetch_one(db) + .fetch_one(db.deref_mut()) .await .unwrap(); @@ -273,6 +284,13 @@ WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%'; rowed_km: row.get("rowed_km"), } } + + pub async fn person(db: &SqlitePool, year: Option, user: &User) -> Stat { + let mut tx = db.begin().await.unwrap(); + let ret = Self::person_tx(&mut tx, year, user).await; + tx.commit().await.unwrap(); + ret + } } #[derive(Debug, Serialize)] diff --git a/src/model/trip.rs b/src/model/trip.rs index 71d98df..b8178fa 100644 --- a/src/model/trip.rs +++ b/src/model/trip.rs @@ -287,10 +287,8 @@ WHERE day=? return Err(TripUpdateError::NotYourTrip); } - if update.trip_type != Some(4) { - if !update.cox.allowed_to_steer(db).await { - return Err(TripUpdateError::TripTypeNotAllowed); - } + if update.trip_type != Some(4) && !update.cox.allowed_to_steer(db).await { + return Err(TripUpdateError::TripTypeNotAllowed); } let Some(trip_details_id) = update.trip.trip_details_id else { diff --git a/src/model/user/fee.rs b/src/model/user/fee.rs new file mode 100644 index 0000000..1fa9841 --- /dev/null +++ b/src/model/user/fee.rs @@ -0,0 +1,58 @@ +use super::User; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct Fee { + pub sum_in_cents: i32, + pub parts: Vec<(String, i32)>, + pub name: String, + pub user_ids: String, + pub paid: bool, + pub users: Vec, +} + +impl Default for Fee { + fn default() -> Self { + Self::new() + } +} + +impl Fee { + pub fn new() -> Self { + Self { + sum_in_cents: 0, + name: "".into(), + parts: Vec::new(), + user_ids: "".into(), + users: Vec::new(), + paid: false, + } + } + + pub fn add(&mut self, desc: String, price_in_cents: i32) { + self.sum_in_cents += price_in_cents; + + self.parts.push((desc, price_in_cents)); + } + + pub fn add_person(&mut self, user: &User) { + if !self.name.is_empty() { + self.name.push_str(" + "); + self.user_ids.push('&'); + } + self.name.push_str(&user.name); + + self.user_ids.push_str(&format!("user_ids[]={}", user.id)); + self.users.push(user.clone()); + } + + pub fn paid(&mut self) { + self.paid = true; + } + + pub fn merge(&mut self, fee: Fee) { + for (desc, price_in_cents) in fee.parts { + self.add(desc, price_in_cents); + } + } +} diff --git a/src/model/user.rs b/src/model/user/mod.rs similarity index 96% rename from src/model/user.rs rename to src/model/user/mod.rs index 3c544d7..6f27ea8 100644 --- a/src/model/user.rs +++ b/src/model/user/mod.rs @@ -15,14 +15,25 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; use super::{ - family::Family, log::Log, logbook::Logbook, mail::Mail, notification::Notification, role::Role, - stat::Stat, tripdetails::TripDetails, Day, + family::Family, + log::Log, + logbook::Logbook, + mail::Mail, + notification::Notification, + personal::{equatorprice, rowingbadge}, + role::Role, + stat::Stat, + tripdetails::TripDetails, + Day, }; use crate::{ tera::admin::user::UserEditForm, AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD, BOAT_STORAGE, EINSCHREIBGEBUEHR, FAMILY_THREE_OR_MORE, FAMILY_TWO, FOERDERND, REGULAR, RENNRUDERBEITRAG, SCHECKBUCH, STUDENT_OR_PUPIL, UNTERSTUETZEND, }; +use fee::Fee; + +mod fee; #[derive(FromRow, Serialize, Deserialize, Clone, Debug, Eq, Hash, PartialEq)] pub struct User { @@ -83,62 +94,6 @@ pub enum LoginError { DeserializationError, } -#[derive(Debug, Serialize)] -pub struct Fee { - pub sum_in_cents: i32, - pub parts: Vec<(String, i32)>, - pub name: String, - pub user_ids: String, - pub paid: bool, - pub users: Vec, -} - -impl Default for Fee { - fn default() -> Self { - Self::new() - } -} - -impl Fee { - pub fn new() -> Self { - Self { - sum_in_cents: 0, - name: "".into(), - parts: Vec::new(), - user_ids: "".into(), - users: Vec::new(), - paid: false, - } - } - - pub fn add(&mut self, desc: String, price_in_cents: i32) { - self.sum_in_cents += price_in_cents; - - self.parts.push((desc, price_in_cents)); - } - - pub fn add_person(&mut self, user: &User) { - if !self.name.is_empty() { - self.name.push_str(" + "); - self.user_ids.push('&'); - } - self.name.push_str(&user.name); - - self.user_ids.push_str(&format!("user_ids[]={}", user.id)); - self.users.push(user.clone()); - } - - pub fn paid(&mut self) { - self.paid = true; - } - - pub fn merge(&mut self, fee: Fee) { - for (desc, price_in_cents) in fee.parts { - self.add(desc, price_in_cents); - } - } -} - impl User { pub async fn allowed_to_steer(&self, db: &SqlitePool) -> bool { self.has_role(db, "cox").await || self.has_role(db, "Bootsführer").await @@ -1070,8 +1025,41 @@ ORDER BY last_access DESC } } - // TODO: check fahrtenabzeichen fertig? - // TODO: check äquatorpreis geschafft? + // check fahrtenabzeichen fertig + if rowingbadge::Status::completed_with_last_log(db, &self).await { + let board = Role::find_by_name_tx(db, "Vorstand").await.unwrap(); + Notification::create_for_role_tx( + db, + &board, + &format!( + "Lieber Vorstand, zur Info: {} hat gerade alle Anforderungen für das diesjährige Fahrtenabzeichen erfüllt.", + self.name + ), + "Fahrtenabzeichen geschafft", + None,None + ) + .await; + + Notification::create_with_tx(db, self, "Mit deiner letzten Ausfahrt hast du nun alle Anforderungen für das heurige Fahrtenzeichen erfüllt. Gratuliere! 🎉", "Fahrtenabzeichen geschafft", None, None).await; + } + + // check äquatorpreis geschafft? + if let Some(level) = equatorprice::new_level_with_last_log(db, self).await { + let board = Role::find_by_name_tx(db, "Vorstand").await.unwrap(); + Notification::create_for_role_tx( + db, + &board, + &format!( + "Lieber Vorstand, zur Info: {} hat gerade alle Anforderungen für den Äquatorpreis in {level} geschafft.", + self.name + ), + "Äquatorpreis", + None,None + ) + .await; + + Notification::create_with_tx(db, self, &format!("Mit deiner letzten Ausfahrt erfüllst du nun alle Anforderungen für den Äquatorpreis in {level}. Gratuliere! 🎉"), "Äquatorpreis", None, None).await; + } } }