diff --git a/frontend/main.ts b/frontend/main.ts index e8d528c..62fa9ba 100644 --- a/frontend/main.ts +++ b/frontend/main.ts @@ -24,6 +24,7 @@ document.addEventListener("DOMContentLoaded", function () { reloadPage(); setCurrentdate(document.querySelector("#departure")); initDropdown(); + editReadOnlyField(); }); function changeTheme() { @@ -40,6 +41,30 @@ function changeTheme() { } } + +function editReadOnlyField() { + const editBtns = document.querySelectorAll( + '.edit-js' + ); + if (editBtns) { + Array.prototype.forEach.call(editBtns, (btn: HTMLButtonElement) => { + btn.addEventListener("click", function () { + let wrapper = btn.parentElement; + let input = wrapper?.querySelector('input.input'), + select = wrapper?.querySelector('select.input'), + attribute = 'readonly'; + + if(select) attribute = 'disabled'; + let element = input ? input : select; + + element?.toggleAttribute(attribute); + if(!element?.hasAttribute(attribute)) element?.focus(); + wrapper?.classList.toggle('editable'); + }); + }); + } +} + /*** * init javascript * 1) detect native color scheme or use set theme in local storage diff --git a/frontend/scss/components/_btns.scss b/frontend/scss/components/_btns.scss index 5691039..873617f 100644 --- a/frontend/scss/components/_btns.scss +++ b/frontend/scss/components/_btns.scss @@ -28,4 +28,8 @@ &[aria-pressed='true'] { @apply outline outline-2 outline-offset-2 outline-primary-600 bg-primary-100 text-primary-950; } + + &-hidden { + @apply hidden; + } } diff --git a/frontend/scss/components/_headlines.scss b/frontend/scss/components/_headlines.scss index cd4fa44..8bc5a0b 100644 --- a/frontend/scss/components/_headlines.scss +++ b/frontend/scss/components/_headlines.scss @@ -4,4 +4,8 @@ .h2 { @apply font-bold uppercase tracking-wide text-center rounded-t-md text-primary-950 dark:text-white bg-gray-200 dark:bg-primary-950 bg-opacity-80 text-lg px-3 py-3; +} + +.h3 { + @apply text-center text-xl uppercase tracking-wide font-bold text-primary-900 dark:text-white; } \ No newline at end of file diff --git a/frontend/scss/components/_important.scss b/frontend/scss/components/_important.scss index 7d2e869..54a7790 100644 --- a/frontend/scss/components/_important.scss +++ b/frontend/scss/components/_important.scss @@ -2,3 +2,12 @@ border-top-left-radius: 0px !important; border-top-right-radius: 0px !important; } + +.rounded-l-none-important { + border-bottom-left-radius: 0px !important; + border-top-left-radius: 0px !important; +} + +.rounded-none-important { + border-radius: 0px !important; +} diff --git a/frontend/scss/components/_input.scss b/frontend/scss/components/_input.scss index 8665c7c..6cf702a 100644 --- a/frontend/scss/components/_input.scss +++ b/frontend/scss/components/_input.scss @@ -2,6 +2,26 @@ @apply relative block w-full bg-white dark:bg-black border-0 py-1.5 px-2 text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-black placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-primary-600 sm:text-sm sm:leading-6; } +.input-group { + @apply flex; + + input[readonly], + select[disabled] { + opacity: .7; + } + + &.editable { + input[type="reset"], + input[type="submit"] { + @apply block; + } + + button[type="button"] { + @apply hidden; + } + } +} + select { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"); background-repeat: no-repeat; diff --git a/frontend/scss/components/_links.scss b/frontend/scss/components/_links.scss index 21d9ab7..2643d63 100644 --- a/frontend/scss/components/_links.scss +++ b/frontend/scss/components/_links.scss @@ -10,4 +10,12 @@ &-white { @apply text-white hover:text-primary-100 underline; } + + &-black { + @apply text-black hover:text-primary-950 dark:text-white hover:dark:text-primary-300 underline; + } + + &-no-underline { + @apply no-underline; + } } diff --git a/frontend/tests/log.spec.ts b/frontend/tests/log.spec.ts index efa53f0..626a597 100644 --- a/frontend/tests/log.spec.ts +++ b/frontend/tests/log.spec.ts @@ -115,7 +115,7 @@ test("Cox can start and finish trip", async ({ page }, testInfo) => { await page.getByPlaceholder("Passwort").press("Enter"); await page.goto("/log/show"); - await page.getByText('(cox2)').click(); + await page.getByRole('link', { name: 'Joe' }).nth(1).click(); page.once("dialog", (dialog) => { dialog.accept().catch(() => {}); }); @@ -208,7 +208,6 @@ test("Kiosk can start and finish trip", async ({ page }, testInfo) => { await page.getByRole('link', { name: 'Logbuch' }).click(); await expect(page.locator('body')).toContainText('Joe'); - await expect(page.locator('body')).toContainText('(cox2)'); await expect(page.locator('body')).toContainText('Ottensheim (25 km)'); await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2'); @@ -225,7 +224,7 @@ test("Kiosk can start and finish trip", async ({ page }, testInfo) => { await page.getByPlaceholder("Passwort").press("Enter"); await page.goto("/log/show"); - await page.getByText('(cox2)').click(); + await page.getByRole('link', { name: 'Joe' }).nth(1).click(); page.once("dialog", (dialog) => { dialog.accept().catch(() => {}); }); @@ -286,7 +285,6 @@ test("Cox can start and finish trip with cox steering only", async ({ page }, te await page.goto('/log/show'); await expect(page.locator('body')).toContainText('cox_only_steering_boat'); - await expect(page.locator('body')).toContainText('(cox2 - handgesteuert)'); await expect(page.locator('body')).toContainText('Ottensheim (25 km)'); @@ -302,7 +300,7 @@ test("Cox can start and finish trip with cox steering only", async ({ page }, te await page.getByPlaceholder("Passwort").press("Enter"); await page.goto("/log/show"); - await page.getByText('(cox2 - handgesteuert)').click(); + await page.getByRole("link", { name: "cox_only_steering_boat" }).click(); page.once("dialog", (dialog) => { dialog.accept().catch(() => {}); }); @@ -371,7 +369,7 @@ test("Kiosk can start and finish trip in one stop", async ({ page }, testInfo) = await page.getByPlaceholder("Passwort").press("Enter"); await page.goto("/log/show"); - await page.getByText('(cox2)').click(); + await page.getByRole('link', { name: 'Joe' }).nth(1).click(); page.once("dialog", (dialog) => { dialog.accept().catch(() => {}); }); diff --git a/migration.sql b/migration.sql index 365aa72..1d18740 100644 --- a/migration.sql +++ b/migration.sql @@ -28,7 +28,10 @@ CREATE TABLE IF NOT EXISTS "family" ( CREATE TABLE IF NOT EXISTS "role" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" text NOT NULL UNIQUE, - "cluster" text + "formatted_name" text, + "desc" text, + "cluster" text, + "hide_in_lists" BOOLEAN NOT NULL DEFAULT false ); CREATE TABLE IF NOT EXISTS "user_role" ( @@ -222,6 +225,15 @@ CREATE TABLE IF NOT EXISTS "distance" ( ); +CREATE TABLE IF NOT EXISTS "activity" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + text TEXT NOT NULL, + relevant_for TEXT NOT NULL, -- e.g. user_id=123;trip_id=456 + keep_until DATETIME +); + + CREATE TRIGGER IF NOT EXISTS prevent_multiple_roles_same_cluster BEFORE INSERT ON user_role BEGIN diff --git a/src/lib.rs b/src/lib.rs index 61615f9..b21dcb4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ #![allow(clippy::blocks_in_conditions)] +use std::ops::Deref; + pub mod model; #[cfg(feature = "rowing-tera")] @@ -22,6 +24,74 @@ pub(crate) const FOERDERND: i64 = 8500; pub(crate) const SCHECKBUCH: i64 = 3000; pub(crate) const EINSCHREIBGEBUEHR: i64 = 3000; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NonEmptyString(String); + +impl NonEmptyString { + pub fn new(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(NonEmptyString(s)) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +// Implement Deref to allow automatic dereferencing to &str +impl Deref for NonEmptyString { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +// This allows &NonEmptyString to be converted to &str +impl AsRef for NonEmptyString { + fn as_ref(&self) -> &str { + &self.0 + } +} + +// This allows NonEmptyString to be converted to String with .into() +impl From for String { + fn from(s: NonEmptyString) -> Self { + s.0 + } +} + +impl TryFrom<&str> for NonEmptyString { + type Error = &'static str; + + fn try_from(s: &str) -> Result { + if s.is_empty() { + Err("String cannot be empty") + } else { + Ok(NonEmptyString(s.to_string())) + } + } +} + +impl TryFrom for NonEmptyString { + type Error = &'static str; + + fn try_from(s: String) -> Result { + if s.is_empty() { + Err("String cannot be empty") + } else { + Ok(NonEmptyString(s)) + } + } +} + #[cfg(test)] #[macro_export] macro_rules! testdb { diff --git a/src/main.rs b/src/main.rs index 6cc87a7..0a57574 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use rot::rest; use rot::tera; use rot::{scheduled, tera::Config}; -use sqlx::{pool::PoolOptions, sqlite::SqliteConnectOptions, ConnectOptions}; +use sqlx::{ConnectOptions, pool::PoolOptions, sqlite::SqliteConnectOptions}; #[macro_use] extern crate rocket; diff --git a/src/model/activity.rs b/src/model/activity.rs new file mode 100644 index 0000000..c75b813 --- /dev/null +++ b/src/model/activity.rs @@ -0,0 +1,113 @@ +use std::ops::DerefMut; + +use super::{role::Role, user::User}; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; + +#[derive(FromRow, Debug, Serialize, Deserialize, Clone)] +pub struct Activity { + pub id: i64, + pub created_at: NaiveDateTime, + pub text: String, + pub relevant_for: String, + pub keep_until: Option, +} + +pub struct ActivityBuilder { + text: String, + relevant_for: String, + keep_until: Option, +} + +impl ActivityBuilder { + #[must_use] + pub fn new(text: &str) -> Self { + Self { + text: text.into(), + relevant_for: String::new(), + keep_until: None, + } + } + + #[must_use] + pub fn relevant_for_user(self, user: &User) -> Self { + Self { + relevant_for: format!("{}user-{};", self.relevant_for, user.id), + ..self + } + } + + #[must_use] + pub fn relevant_for_role(self, role: &Role) -> Self { + Self { + relevant_for: format!("{}role-{};", self.relevant_for, role.id), + ..self + } + } + + pub async fn save(self, db: &SqlitePool) { + Activity::create(db, &self.text, &self.relevant_for, self.keep_until).await; + } + + pub async fn save_tx(self, db: &mut Transaction<'_, Sqlite>) { + Activity::create_with_tx(db, &self.text, &self.relevant_for, self.keep_until).await; + } +} + +impl Activity { + pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option { + sqlx::query_as!( + Self, + "SELECT id, created_at, text, relevant_for, keep_until FROM activity WHERE id like ?", + id + ) + .fetch_one(db) + .await + .ok() + } + pub(super) async fn create_with_tx( + db: &mut Transaction<'_, Sqlite>, + text: &str, + relevant_for: &str, + keep_until: Option, + ) { + sqlx::query!( + "INSERT INTO activity(text, relevant_for, keep_until) VALUES (?, ?, ?)", + text, + relevant_for, + keep_until + ) + .execute(db.deref_mut()) + .await + .unwrap(); + } + + pub(super) async fn create( + db: &SqlitePool, + text: &str, + relevant_for: &str, + keep_until: Option, + ) { + let mut tx = db.begin().await.unwrap(); + Self::create_with_tx(&mut tx, text, relevant_for, keep_until).await; + tx.commit().await.unwrap(); + } + + pub async fn for_user(db: &SqlitePool, user: &User) -> Vec { + let user_str = format!("user-{};", user.id); + sqlx::query_as!( + Self, + " +SELECT id, created_at, text, relevant_for, keep_until FROM activity +WHERE + relevant_for like CONCAT('%', ?, '%') +ORDER BY created_at DESC; + ", + user_str + ) + .fetch_all(db) + .await + .unwrap() + } +} diff --git a/src/model/boat.rs b/src/model/boat.rs index 4776cf9..22818f4 100644 --- a/src/model/boat.rs +++ b/src/model/boat.rs @@ -2,8 +2,8 @@ use std::ops::DerefMut; use chrono::NaiveDateTime; use itertools::Itertools; -use rocket::serde::{Deserialize, Serialize}; use rocket::FromForm; +use rocket::serde::{Deserialize, Serialize}; use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; use crate::model::boathouse::Boathouse; diff --git a/src/model/boatdamage.rs b/src/model/boatdamage.rs index bcdb377..3f6c742 100644 --- a/src/model/boatdamage.rs +++ b/src/model/boatdamage.rs @@ -1,7 +1,7 @@ use crate::model::{boat::Boat, user::User}; use chrono::NaiveDateTime; -use rocket::serde::{Deserialize, Serialize}; use rocket::FromForm; +use rocket::serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool}; use super::log::Log; diff --git a/src/model/event.rs b/src/model/event.rs index 699f5e7..4af2276 100644 --- a/src/model/event.rs +++ b/src/model/event.rs @@ -2,8 +2,8 @@ use std::io::Write; use chrono::{Duration, NaiveDate, NaiveTime}; use ics::{ - properties::{DtEnd, DtStart, Summary}, ICalendar, + properties::{DtEnd, DtStart, Summary}, }; use serde::Serialize; use sqlx::{FromRow, Row, SqlitePool}; @@ -578,6 +578,11 @@ mod test { let today = Local::now().date_naive().format("%Y%m%d").to_string(); let actual = Event::get_ics_feed(&pool).await; - assert_eq!(format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:ics-rs\r\nBEGIN:VEVENT\r\nUID:event-1@rudernlinz.at\r\nDTSTAMP:19900101T180000\r\nDTSTART:{today}T100000\r\nDTEND:{today}T130000\r\nSUMMARY:test-planned-event \r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"), actual); + assert_eq!( + format!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:ics-rs\r\nBEGIN:VEVENT\r\nUID:event-1@rudernlinz.at\r\nDTSTAMP:19900101T180000\r\nDTSTART:{today}T100000\r\nDTEND:{today}T130000\r\nSUMMARY:test-planned-event \r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + ), + actual + ); } } diff --git a/src/model/family.rs b/src/model/family.rs index 55aa5eb..8794779 100644 --- a/src/model/family.rs +++ b/src/model/family.rs @@ -1,13 +1,13 @@ use std::ops::DerefMut; use serde::Serialize; -use sqlx::{sqlite::SqliteQueryResult, FromRow, Sqlite, SqlitePool, Transaction}; +use sqlx::{FromRow, Sqlite, SqlitePool, Transaction, sqlite::SqliteQueryResult}; use super::user::User; #[derive(FromRow, Serialize, Clone)] pub struct Family { - id: i64, + pub(crate) id: i64, } #[derive(Serialize, Clone)] @@ -91,4 +91,18 @@ GROUP BY family.id;" .await .unwrap() } + + pub async fn clean_families_without_members(db: &SqlitePool) { + sqlx::query( + "DELETE FROM family +WHERE id NOT IN ( + SELECT DISTINCT family_id + FROM user + WHERE family_id IS NOT NULL +);", + ) + .execute(db) + .await + .unwrap(); + } } diff --git a/src/model/logbook.rs b/src/model/logbook.rs index 4b104bf..e670128 100644 --- a/src/model/logbook.rs +++ b/src/model/logbook.rs @@ -2,14 +2,14 @@ use std::ops::DerefMut; use chrono::{Datelike, Duration, Local, NaiveDateTime}; use rocket::FromForm; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; use super::{ boat::Boat, log::Log, notification::Notification, role::Role, rower::Rower, user::User, }; -#[derive(FromRow, Serialize, Clone, Debug)] +#[derive(FromRow, Serialize, Deserialize, Clone, Debug)] pub struct Logbook { pub id: i64, pub boat_id: i64, @@ -105,7 +105,7 @@ impl TryFrom for LogToFinalize { } } -#[derive(Serialize, Debug)] +#[derive(Serialize, Deserialize, Debug)] pub struct LogbookWithBoatAndRowers { #[serde(flatten)] pub logbook: Logbook, @@ -823,7 +823,13 @@ ORDER BY departure DESC if difference > Duration::hours(1) { let vorstand = Role::find_by_name(db, "Vorstand").await.unwrap(); let logbook = LogbookWithBoatAndRowers::from(db, self.clone()).await; - let mut msg = format!("{} hat folgenden Logbuch-Eintrag jetzt gelöscht, welcher bereits vor über einer Stunde begonnen wurde: Schiffsführer: {}, Steuerperson: {}, Abfahrt: {}", user.name, logbook.steering_user.name, logbook.steering_user.name, logbook.logbook.departure.format("%Y-%m-%d %H:%M")); + let mut msg = format!( + "{} hat folgenden Logbuch-Eintrag jetzt gelöscht, welcher bereits vor über einer Stunde begonnen wurde: Schiffsführer: {}, Steuerperson: {}, Abfahrt: {}", + user.name, + logbook.steering_user.name, + logbook.steering_user.name, + logbook.logbook.departure.format("%Y-%m-%d %H:%M") + ); if let Some(destination) = logbook.logbook.destination { msg.push_str(&format!(", Ziel: {}", destination)); } else { diff --git a/src/model/mail.rs b/src/model/mail.rs index 9879630..35328a9 100644 --- a/src/model/mail.rs +++ b/src/model/mail.rs @@ -1,15 +1,15 @@ use std::{error::Error, fs}; use lettre::{ - message::{header::ContentType, Attachment, MultiPart, SinglePart}, + Address, Message, SmtpTransport, Transport, + message::{Attachment, MultiPart, SinglePart, header::ContentType}, transport::smtp::authentication::Credentials, - Message, SmtpTransport, Transport, }; use sqlx::{Sqlite, SqlitePool, Transaction}; use crate::tera::admin::mail::MailToSend; -use super::{family::Family, log::Log, role::Role, user::User}; +use super::{activity::ActivityBuilder, family::Family, log::Log, role::Role, user::User}; pub struct Mail {} @@ -79,7 +79,9 @@ impl Mail { .build(); // Send the email - mailer.send(&email).unwrap(); + if let Err(e) = mailer.send(&email) { + Log::create_with_tx(db, format!("Mail nicht versandt: {e:?}")).await; + } Ok(()) } @@ -251,6 +253,12 @@ Der Vorstand"); // Send the email mailer.send(&email).unwrap(); + ActivityBuilder::new(&format!( + "{user} hat die Info-Mail bzgl. Gebühren gesendet bekommen." + )) + .relevant_for_user(&user) + .save(db) + .await; } } } @@ -367,6 +375,12 @@ Der Vorstand"); // Send the email mailer.send(&email).unwrap(); + ActivityBuilder::new(&format!( + "{user} hat die Mahn-Mail bzgl. Gebühren gesendet bekommen." + )) + .relevant_for_user(&user) + .save(db) + .await; } } } @@ -374,3 +388,13 @@ Der Vorstand"); } } } + +pub(crate) fn valid_mails(mails: &str) -> bool { + let splitted = mails.split(','); + for single_rec in splitted { + if single_rec.parse::
().is_err() { + return false; + } + } + true +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 8cc6613..6e531d1 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -14,6 +14,7 @@ use self::{ use boatreservation::{BoatReservation, BoatReservationWithDetails}; use std::collections::HashMap; +pub mod activity; pub mod boat; pub mod boatdamage; pub mod boathouse; diff --git a/src/model/personal/cal.rs b/src/model/personal/cal.rs index f0fc1ef..2f5d68d 100644 --- a/src/model/personal/cal.rs +++ b/src/model/personal/cal.rs @@ -1,6 +1,6 @@ use std::io::Write; -use ics::{components::Property, ICalendar}; +use ics::{ICalendar, components::Property}; use sqlx::SqlitePool; use crate::model::{event::Event, trip::Trip, user::User}; diff --git a/src/model/role.rs b/src/model/role.rs index c8accad..6617cc8 100644 --- a/src/model/role.rs +++ b/src/model/role.rs @@ -1,5 +1,6 @@ -use std::ops::DerefMut; +use std::{cmp::Ordering, fmt::Display, ops::DerefMut}; +use super::{activity::ActivityBuilder, user::AdminUser}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; @@ -7,22 +8,79 @@ use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; pub struct Role { pub(crate) id: i64, pub(crate) name: String, + pub(crate) formatted_name: Option, + pub(crate) desc: Option, + pub(crate) hide_in_lists: bool, pub(crate) cluster: Option, } +// Implement PartialEq to compare roles based only on id +impl PartialEq for Role { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +// Implement Eq to indicate that equality is reflexive +impl Eq for Role {} + +// Implement PartialOrd if you need to sort or compare roles +impl PartialOrd for Role { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.id.cmp(&other.id)) + } +} + +// Implement Ord if you need total ordering (for sorting) +impl Ord for Role { + fn cmp(&self, other: &Self) -> Ordering { + self.id.cmp(&other.id) + } +} + +impl Display for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + impl Role { pub async fn all(db: &SqlitePool) -> Vec { - sqlx::query_as!(Role, "SELECT id, name, cluster FROM role") - .fetch_all(db) - .await - .unwrap() + sqlx::query_as!( + Role, + "SELECT id, name, formatted_name, desc, hide_in_lists, cluster FROM role" + ) + .fetch_all(db) + .await + .unwrap() + } + + pub async fn all_cluster(db: &SqlitePool, cluster: &str) -> Vec { + sqlx::query_as!( + Role, + r#"SELECT id, + CASE WHEN formatted_name IS NOT NULL AND formatted_name != '' + THEN formatted_name + ELSE name + END AS "name!: String", + '' as formatted_name, + desc, + hide_in_lists, + cluster + FROM role + WHERE cluster = ?"#, + cluster + ) + .fetch_all(db) + .await + .unwrap() } pub async fn find_by_id(db: &SqlitePool, name: i32) -> Option { sqlx::query_as!( Self, " -SELECT id, name, cluster +SELECT id, name, formatted_name, desc, hide_in_lists, cluster FROM role WHERE id like ? ", @@ -36,7 +94,7 @@ WHERE id like ? sqlx::query_as!( Self, " -SELECT id, name, cluster +SELECT id, name, formatted_name, desc, hide_in_lists, cluster FROM role WHERE id like ? ", @@ -47,26 +105,11 @@ WHERE id like ? .ok() } - pub async fn find_by_cluster_tx(db: &mut Transaction<'_, Sqlite>, name: i32) -> Option { - sqlx::query_as!( - Self, - " -SELECT id, name, cluster -FROM role -WHERE cluster = ? - ", - name - ) - .fetch_one(db.deref_mut()) - .await - .ok() - } - pub async fn find_by_name(db: &SqlitePool, name: &str) -> Option { sqlx::query_as!( Self, " -SELECT id, name, cluster +SELECT id, name, formatted_name, desc, hide_in_lists, cluster FROM role WHERE name like ? ", @@ -81,7 +124,7 @@ WHERE name like ? sqlx::query_as!( Self, " -SELECT id, name, cluster +SELECT id, name, formatted_name, desc, hide_in_lists, cluster FROM role WHERE name like ? ", @@ -92,6 +135,30 @@ WHERE name like ? .ok() } + pub async fn update( + &self, + db: &SqlitePool, + updated_by: &AdminUser, + formatted_name: &str, + desc: &str, + ) -> Result<(), String> { + sqlx::query!( + "UPDATE role SET formatted_name=?, desc=? WHERE id=?", + formatted_name, + desc, + self.id + ) + .execute(db) + .await + .map_err(|e| e.to_string())?; + + ActivityBuilder::new(&format!( + "{updated_by} hat Rolle {self} von {self:#?} auf FORMATTED_NAME={formatted_name}, DESC={desc} aktualisiert." + )).relevant_for_role(self).save(db).await; + + Ok(()) + } + pub async fn names_from_role(&self, db: &SqlitePool) -> Vec { let query = format!( "SELECT u.name diff --git a/src/model/trip.rs b/src/model/trip.rs index 36c0773..b654721 100644 --- a/src/model/trip.rs +++ b/src/model/trip.rs @@ -47,7 +47,7 @@ pub struct TripUpdate<'a> { pub is_locked: bool, } -impl<'a> TripUpdate<'a> { +impl TripUpdate<'_> { fn cancelled(&self) -> bool { self.max_people == -1 } @@ -567,9 +567,11 @@ mod test { let last_notification = &Notification::for_user(&pool, &cox).await[0]; - assert!(last_notification - .message - .starts_with("cox2 hat eine Ausfahrt zur selben Zeit")); + assert!( + last_notification + .message + .starts_with("cox2 hat eine Ausfahrt zur selben Zeit") + ); } #[sqlx::test] diff --git a/src/model/user/basic.rs b/src/model/user/basic.rs new file mode 100644 index 0000000..b593c67 --- /dev/null +++ b/src/model/user/basic.rs @@ -0,0 +1,550 @@ +// TODO: put back in `src/model/user/mod.rs` once that is cleaned up + +use super::{AllowedToEditPaymentStatusUser, ManageUserUser, User}; +use crate::model::{ + activity::ActivityBuilder, family::Family, mail::valid_mails, notification::Notification, + role::Role, +}; +use chrono::NaiveDate; +use rocket::{fs::TempFile, tokio::io::AsyncReadExt}; +use sqlx::SqlitePool; + +impl User { + pub(crate) async fn add_note( + &self, + db: &SqlitePool, + updated_by: &ManageUserUser, + user: &User, + note: &str, + ) -> Result<(), String> { + let note = note.trim(); + + ActivityBuilder::new(&format!("({updated_by}) {note}")) + .relevant_for_user(user) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn update_mail( + &self, + db: &SqlitePool, + updated_by: &ManageUserUser, + new_mail: &str, + ) -> Result<(), String> { + let new_mail = new_mail.trim(); + + if !valid_mails(new_mail) { + return Err(format!( + "{new_mail} ist kein gültiges Format für eine Mailadresse" + )); + } + + sqlx::query!("UPDATE user SET mail = ? where id = ?", new_mail, self.id) + .execute(db) + .await + .unwrap(); //Okay, because we can only create a User of a valid id + + let msg = match &self.mail { + Some(old_mail) => { + format!( + "{updated_by} hat die Mail-Adresse von {self} von {old_mail} auf {new_mail} geändert." + ) + } + None => { + format!("{updated_by} eine neue Mail-Adresse für {self} hinzugefügt: {new_mail}") + } + }; + + ActivityBuilder::new(&msg) + .relevant_for_user(self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn update_phone( + &self, + db: &SqlitePool, + updated_by: &ManageUserUser, + new_phone: &str, + ) { + let new_phone = new_phone.trim(); + + let query = if new_phone.is_empty() { + if self.phone.is_none() { + return; // nothing to do + } + sqlx::query!("UPDATE user SET phone = NULL where id = ?", self.id) + } else { + if let Some(old_phone) = &self.phone { + if old_phone == new_phone { + return; //nothing to do + } + } + sqlx::query!("UPDATE user SET phone = ? where id = ?", new_phone, self.id) + }; + query.execute(db).await.unwrap(); //Okay, because we can only create a User of a valid id + + let msg = match &self.phone { + Some(old_phone) if new_phone.is_empty() => format!( + "{updated_by} hat die Telefonnummer von {self} entfernt (alte Nummer: {old_phone})" + ), + Some(old_phone) => format!( + "{updated_by} hat die Telefonnummer von {self} von {old_phone} auf {new_phone} geändert." + ), + None => format!( + "{updated_by} hat eine neue Telefonnummer für {self} hinzugefügt: {new_phone}" + ), + }; + + ActivityBuilder::new(&msg) + .relevant_for_user(self) + .save(db) + .await; + } + + pub(crate) async fn update_address( + &self, + db: &SqlitePool, + updated_by: &ManageUserUser, + new_address: &str, + ) { + let new_address = new_address.trim(); + + let query = if new_address.is_empty() { + if self.address.is_none() { + return; // nothing to do + } + sqlx::query!("UPDATE user SET address = NULL where id = ?", self.id) + } else { + if let Some(old_address) = &self.address { + if old_address == new_address { + return; //nothing to do + } + } + sqlx::query!( + "UPDATE user SET address = ? where id = ?", + new_address, + self.id + ) + }; + query.execute(db).await.unwrap(); //Okay, because we can only create a User of a valid id + + let msg = match &self.address { + Some(old_address) if new_address.is_empty() => format!( + "{updated_by} hat die Adresse von {self} entfernt (alte Adresse: {old_address})" + ), + Some(old_address) => format!( + "{updated_by} hat die Adresse von {self} von {old_address} auf {new_address} geändert." + ), + None => format!("{updated_by} hat eine Adresse für {self} hinzugefügt: {new_address}"), + }; + + ActivityBuilder::new(&msg) + .relevant_for_user(self) + .save(db) + .await; + } + + pub(crate) async fn update_nickname( + &self, + db: &SqlitePool, + updated_by: &ManageUserUser, + new_nickname: &str, + ) -> Result<(), String> { + let new_nickname = new_nickname.trim(); + + let query = if new_nickname.is_empty() { + sqlx::query!("UPDATE user SET nickname = NULL where id = ?", self.id) + } else { + sqlx::query!( + "UPDATE user SET nickname = ? where id = ?", + new_nickname, + self.id + ) + }; + query.execute(db).await.unwrap(); //Okay, because we can only create a User of a valid id + + let msg = match &self.nickname { + Some(old_nickname) if new_nickname.is_empty() => format!( + "{updated_by} hat den Sitznamen von {self} entfernt (alter Spitzname: {old_nickname})" + ), + Some(old_nickname) => format!( + "{updated_by} hat den Spitznamen von {self} von {old_nickname} auf {new_nickname} geändert." + ), + None => format!( + "{updated_by} hat einen neuen Spitznamen für {self} hinzugefügt: {new_nickname}" + ), + }; + ActivityBuilder::new(&msg) + .relevant_for_user(self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn update_member_since( + &self, + db: &SqlitePool, + updated_by: &ManageUserUser, + new_member_since_date: &NaiveDate, + ) { + sqlx::query!( + "UPDATE user SET member_since_date = ? where id = ?", + new_member_since_date, + self.id + ) + .execute(db) + .await + .unwrap(); //Okay, because we can only create a User of a valid id + + let msg = match &self.member_since_date { + Some(old_member_since_date) => format!( + "{updated_by} hat das Beitrittsdatum von {self} von {old_member_since_date} auf {new_member_since_date} geändert." + ), + None => format!( + "{updated_by} hat ein neues Beitrittsdatum für {self} hinzugefügt: {new_member_since_date}" + ), + }; + + ActivityBuilder::new(&msg) + .relevant_for_user(self) + .save(db) + .await; + } + + pub(crate) async fn update_birthdate( + &self, + db: &SqlitePool, + updated_by: &ManageUserUser, + new_birthdate: &NaiveDate, + ) { + sqlx::query!( + "UPDATE user SET birthdate = ? where id = ?", + new_birthdate, + self.id + ) + .execute(db) + .await + .unwrap(); //Okay, because we can only create a User of a valid id + + let msg = match &self.birthdate { + Some(old_birthdate) => format!( + "{updated_by} hat das Geburtsdatum von {self} von {old_birthdate} auf {new_birthdate} geändert." + ), + None => { + format!("{updated_by} hat ein Geburtsdatum für {self} hinzugefügt: {new_birthdate}") + } + }; + + ActivityBuilder::new(&msg) + .relevant_for_user(self) + .save(db) + .await; + } + + pub(crate) async fn update_family( + &self, + db: &SqlitePool, + updated_by: &ManageUserUser, + family: Option, + ) { + if let Some(family) = family { + let family_id = family.id; + sqlx::query!( + "UPDATE user SET family_id = ? where id = ?", + family_id, + self.id + ) + .execute(db) + .await + .unwrap(); + ActivityBuilder::new(&format!( + "{updated_by} hat {self} zu einer Familie hinzugefügt." + )) + .relevant_for_user(self) + .save(db) + .await; + } else { + sqlx::query!("UPDATE user SET family_id = NULL where id = ?", self.id) + .execute(db) + .await + .unwrap(); + ActivityBuilder::new(&format!( + "{updated_by} hat die Familienzugehörigkeit von {self} gelöscht." + )) + .relevant_for_user(self) + .save(db) + .await; + }; + + Family::clean_families_without_members(db).await; + } + + pub(crate) async fn change_skill( + &self, + db: &SqlitePool, + updated_by: &ManageUserUser, + skill: Option, + ) -> Result<(), String> { + let old_skill = self.skill(db).await; + + let member = Role::find_by_name(db, "Donau Linz").await.unwrap(); + let cox = Role::find_by_name(db, "cox").await.unwrap(); + let bootsfuehrer = Role::find_by_name(db, "Bootsführer").await.unwrap(); + + match (old_skill, skill) { + (None, new) if new == Some(cox.clone()) => { + self.add_role(db, updated_by, &cox).await?; + Notification::create_for_role( + db, + &member, + &format!( + "Liebes Vereinsmitglied, {self} ist ab sofort Steuerperson 🎉 Hip hip ...!" + ), + "Neue Steuerperson", + None, + None, + ) + .await; + ActivityBuilder::new(&format!("{updated_by} hat {self} zur Steuerperson gemacht")) + .relevant_for_user(self) + .save(db) + .await; + } + (old, new) if old == Some(cox.clone()) && new == Some(bootsfuehrer.clone()) => { + self.remove_role(db, updated_by, &cox).await?; + self.add_role(db, updated_by, &bootsfuehrer).await?; + Notification::create_for_role( + db, + &member, + &format!( + "Liebes Vereinsmitglied, {self} ist ab sofort Bootsführer:in 🎉 Hip hip ...!" + ), + "Neue:r Bootsführer:in", + None, + None, + ) + .await; + ActivityBuilder::new(&format!("{updated_by} hat {self} zum Bootsführer gemacht")) + .relevant_for_user(self) + .save(db) + .await; + } + (old, None) => { + if let Some(old) = old { + self.remove_role(db, updated_by, &old).await?; + let vorstand = Role::find_by_name(db, "Vorstand").await.unwrap(); + Notification::create_for_role( + db, + &vorstand, + &format!("Lieber Vorstand, {self} ist ab kein {old} mehr."), + "Steuerperson --", + None, + None, + ) + .await; + ActivityBuilder::new(&format!("{updated_by} hat {self} zum normalen Mitlgied gemacht (keine Steuerperson/Schiffsführer mehr)")) + .relevant_for_user(self) + .save(db) + .await; + } + } + (old, new) => return Err(format!("Not allowed to change from {old:?} to {new:?}")), + }; + + Ok(()) + } + + pub(crate) async fn change_financial( + &self, + db: &SqlitePool, + updated_by: &ManageUserUser, + financial: Option, + ) -> Result<(), String> { + let mut new = String::new(); + let mut old = String::new(); + + if let Some(old_financial) = self.financial(db).await { + self.remove_role(db, updated_by, &old_financial).await?; + old.push_str(&old_financial.name); + } else { + old.push_str("Keine Ermäßigung"); + } + + if let Some(new_financial) = financial { + self.add_role(db, updated_by, &new_financial).await?; + new.push_str(&new_financial.name); + } else { + new.push_str("Keine Ermäßigung"); + } + + ActivityBuilder::new(&format!( + "{updated_by} hat die Ermäßigung von {self} von {old} auf {new} geändert" + )) + .relevant_for_user(self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn remove_role( + &self, + db: &SqlitePool, + updated_by: &ManageUserUser, + role: &Role, + ) -> Result<(), String> { + if !self.has_role(db, &role.name).await { + return Err(format!( + "Kann Rolle {role} von User {self} nicht entfernen, da der User die Rolle gar nicht hat" + )); + } + + sqlx::query!( + "DELETE FROM user_role WHERE user_id = ? and role_id = ?", + self.id, + role.id + ) + .execute(db) + .await + .unwrap(); + + if !role.hide_in_lists && role.cluster.is_none() { + ActivityBuilder::new(&format!( + "{updated_by} hat die Rolle {role} von {self} entfernt." + )) + .relevant_for_user(self) + .save(db) + .await; + } + + Ok(()) + } + + pub(crate) async fn has_not_paid( + &self, + db: &SqlitePool, + updated_by: &AllowedToEditPaymentStatusUser, + ) { + let paid = Role::find_by_name(db, "paid").await.unwrap(); + + sqlx::query!( + "DELETE FROM user_role WHERE user_id = ? and role_id = ?", + self.id, + paid.id + ) + .execute(db) + .await + .unwrap(); + + ActivityBuilder::new(&format!( + "{updated_by} hat den Bezahlstatus von {self} auf 'nicht bezahlt' gesetzt." + )) + .relevant_for_user(self) + .save(db) + .await; + } + pub(crate) async fn has_paid( + &self, + db: &SqlitePool, + updated_by: &AllowedToEditPaymentStatusUser, + ) { + let paid = Role::find_by_name(db, "paid").await.unwrap(); + + sqlx::query!( + "INSERT INTO user_role(user_id, role_id) VALUES (?, ?)", + self.id, + paid.id + ) + .execute(db) + .await + .expect("paid role has no group"); + + ActivityBuilder::new(&format!( + "{updated_by} hat den Bezahlstatus von {self} auf 'bezahlt' gesetzt." + )) + .relevant_for_user(self) + .save(db) + .await; + } + + pub(crate) async fn add_role( + &self, + db: &SqlitePool, + updated_by: &ManageUserUser, + role: &Role, + ) -> Result<(), String> { + if self.has_role(db, &role.name).await { + return Err(format!( + "Kann Rolle {role} von User {self} nicht hinzufügen, da der User die Rolle schon hat" + )); + } + + sqlx::query!( + "INSERT INTO user_role(user_id, role_id) VALUES (?, ?)", + self.id, + role.id + ) + .execute(db) + .await + .map_err(|_| { + format!( + "User already has a role in the cluster '{}'", + role.cluster + .clone() + .expect("db trigger can't activate on empty string") + ) + })?; + + if !role.hide_in_lists && role.cluster.is_none() { + ActivityBuilder::new(&format!( + "{updated_by} hat die Rolle '{role}' dem Benutzer {self} hinzugefügt." + )) + .relevant_for_user(self) + .save(db) + .await; + } + + Ok(()) + } + + pub(crate) async fn add_membership_pdf( + &self, + db: &SqlitePool, + updated_by: &ManageUserUser, + membership_pdf: &TempFile<'_>, + ) -> Result<(), String> { + if self.has_membership_pdf(db).await { + return Err(format!("User {self} hat bereits eine Beitrittserklärung.")); + } + if membership_pdf.len() == 0 { + return Err("Keine Beitrittserklärung mitgeschickt.".to_string()); + } + + let mut stream = membership_pdf.open().await.unwrap(); + let mut buffer = Vec::new(); + stream.read_to_end(&mut buffer).await.unwrap(); + sqlx::query!( + "UPDATE user SET membership_pdf = ? where id = ?", + buffer, + self.id + ) + .execute(db) + .await + .unwrap(); //Okay, because we can only create a User of a valid id + + ActivityBuilder::new(&format!( + "{updated_by} hat die Mitgliedserklärung (PDF) für user {self} hinzugefügt." + )) + .relevant_for_user(self) + .save(db) + .await; + + Ok(()) + } +} diff --git a/src/model/user/clubmember.rs b/src/model/user/clubmember.rs new file mode 100644 index 0000000..260edfa --- /dev/null +++ b/src/model/user/clubmember.rs @@ -0,0 +1,167 @@ +use super::User; +use crate::{ + model::{ + activity::ActivityBuilder, notification::Notification, role::Role, user::ManageUserUser, + }, + special_user, +}; +use rocket::async_trait; +use sqlx::SqlitePool; + +special_user!(ClubMemberUser, +"Donau Linz", +"Förderndes Mitglied", +"Unterstützend"); + +impl ClubMemberUser { + async fn add_membership_role(&self, db: &SqlitePool, role: &Role) { + sqlx::query!( + "INSERT INTO user_role(user_id, role_id) VALUES (?, ?)", + self.id, + role.id + ) + .execute(db) + .await + .unwrap(); + } + + async fn remove_membership_role(&self, db: &SqlitePool) { + let role = Role::find_by_name(db, "Förderndes Mitglied").await.unwrap(); + sqlx::query!( + "DELETE FROM user_role WHERE user_id = ? and role_id = ?", + self.id, + role.id + ) + .execute(db) + .await + .unwrap(); + + let role = Role::find_by_name(db, "Unterstützend").await.unwrap(); + sqlx::query!( + "DELETE FROM user_role WHERE user_id = ? and role_id = ?", + self.id, + role.id + ) + .execute(db) + .await + .unwrap(); + + let role = Role::find_by_name(db, "Donau Linz").await.unwrap(); + sqlx::query!( + "DELETE FROM user_role WHERE user_id = ? and role_id = ?", + self.id, + role.id + ) + .execute(db) + .await + .unwrap(); + } + async fn new_membership_role(&self, db: &SqlitePool, role: &str) -> Result<(), String> { + let role = Role::find_by_name(db, role).await.unwrap(); + self.remove_membership_role(db).await; + self.add_membership_role(db, &role).await; + Ok(()) + } + + pub(crate) async fn move_to_regular( + self, + db: &SqlitePool, + modified_by: &ManageUserUser, + ) -> Result<(), String> { + if self.has_role(db, "Donau Linz").await { + return Err(format!("User {self} ist bereits reguläres Mitglied.")); + } + + self.new_membership_role(db, "Donau Linz").await?; + + Notification::create_for_steering_people( + db, + &format!( + "Liebe Steuerberechtigte, {} hat upgegraded und ist nun ein neues reguläres Mitglied. 🎉", + self.name, + ), + "Neues Vereinsmitglied", + None, + None, + ) + .await; + + ActivityBuilder::new(&format!( + "{modified_by} hat {self} zu einem regulären hochgestuft." + )) + .relevant_for_user(&self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn move_to_unterstuetzend( + self, + db: &SqlitePool, + modified_by: &ManageUserUser, + ) -> Result<(), String> { + if self.has_role(db, "Unterstützend").await { + return Err(format!("User {self} ist bereits unterstützendes Mitglied.")); + } + + self.new_membership_role(db, "Unterstützend").await?; + + if let Some(vorstand) = Role::find_by_name(db, "vorstand").await { + Notification::create_for_role( + db, + &vorstand, + &format!( + "Lieber Vorstand, der Mitgliedstatus von {} hat sich geändert auf 'Unterstützendes Mitglied'.", + self.name, + ), + "Neues unterstützendes Vereinsmitglied", + None, + None, + ) + .await; + } + + ActivityBuilder::new(&format!( + "{modified_by} hat {self} zu einem unterstützenden Mitglied gemacht." + )) + .relevant_for_user(&self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn move_to_foerdernd( + self, + db: &SqlitePool, + modified_by: &ManageUserUser, + ) -> Result<(), String> { + if self.has_role(db, "Förderndes Mitglied").await { + return Err(format!("User {self} ist bereits förderndes Mitglied.")); + } + + self.new_membership_role(db, "Förderndes Mitglied").await?; + + if let Some(vorstand) = Role::find_by_name(db, "vorstand").await { + Notification::create_for_role( + db, + &vorstand, + &format!( + "Lieber Vorstand, der Mitgliedstatus von {} hat sich geändert auf 'Förderndes Mitglied'.", + self.name, + ), + "Neues förderndes Vereinsmitglied", + None, + None, + ) + .await; + } + + ActivityBuilder::new(&format!( + "{modified_by} hat {self} zu ein förderndes Mitglied gemacht." + )) + .relevant_for_user(&self) + .save(db) + .await; + + Ok(()) + } +} diff --git a/src/model/user/fee.rs b/src/model/user/fee.rs index 4f3febf..55317bd 100644 --- a/src/model/user/fee.rs +++ b/src/model/user/fee.rs @@ -1,7 +1,7 @@ use super::User; use crate::{ - model::family::Family, BOAT_STORAGE, EINSCHREIBGEBUEHR, FAMILY_THREE_OR_MORE, FAMILY_TWO, - FOERDERND, REGULAR, RENNRUDERBEITRAG, STUDENT_OR_PUPIL, UNTERSTUETZEND, + BOAT_STORAGE, EINSCHREIBGEBUEHR, FAMILY_THREE_OR_MORE, FAMILY_TWO, FOERDERND, REGULAR, + RENNRUDERBEITRAG, STUDENT_OR_PUPIL, UNTERSTUETZEND, model::family::Family, }; use chrono::{Datelike, Local, NaiveDate}; use serde::Serialize; diff --git a/src/model/user/foerdernd.rs b/src/model/user/foerdernd.rs new file mode 100644 index 0000000..6019654 --- /dev/null +++ b/src/model/user/foerdernd.rs @@ -0,0 +1,101 @@ +use super::{ManageUserUser, User, regular::ClubMember}; +use crate::{ + NonEmptyString, + model::{activity::ActivityBuilder, mail::Mail, notification::Notification, role::Role}, + special_user, +}; +use chrono::NaiveDate; +use rocket::{async_trait, fs::TempFile}; +use sqlx::SqlitePool; + +special_user!(FoerderndUser, +"Förderndes Mitglied"); + +impl ClubMember for FoerderndUser {} + +impl FoerderndUser { + pub(crate) async fn send_welcome_mail_to_user( + &self, + db: &SqlitePool, + smtp_pw: &str, + ) -> Result<(), String> { + let Some(mail) = &self.mail else { + return Err(format!( + "Couldn't send welcome mail, as the user {self} has no mail..." + )); + }; + + Mail::send_single( + db, + mail, + "Willkommen im ASKÖ Ruderverein Donau Linz!", + format!( +"Hallo {0}, + +herzlich willkommen im ASKÖ Ruderverein Donau Linz! Wir freuen uns sehr, dich als neues Mitglied in unserem Verein begrüßen zu dürfen. + +Um dir den Einstieg zu erleichtern, findest du in unserem Handbuch alle wichtigen Informationen über unseren Verein: https://rudernlinz.at/book. Bei weiteren Fragen stehen dir die Adressen info@rudernlinz.at (für allgemeine Fragen) und it@rudernlinz.at (bei technischen Fragen) jederzeit zur Verfügung. + +Damit du dich noch mehr verbunden fühlst (:-)), haben wir im Bootshaus ein WLAN für Vereinsmitglieder 'ASKÖ Ruderverein Donau Linz' eingerichtet. Das Passwort dafür lautet 'donau1921' (ohne Anführungszeichen). Bitte gib das Passwort an keine vereinsfremden Personen weiter. + +Riemen- & Dollenbruch +ASKÖ Ruderverein Donau Linz", self.name), + smtp_pw, + ).await?; + + ActivityBuilder::new(&format!( + "User {self} hat die Info-Mail bzgl. neues förderndes Mitglied (Handbuch und WLAN Infos) an {mail} gesendet bekommen" + )) + .relevant_for_user(self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn create( + db: &SqlitePool, + created_by: &ManageUserUser, + smtp_pw: &str, + name: NonEmptyString, + mail: &str, + financial: Option, + birthdate: &NaiveDate, + member_since: &NaiveDate, + phone: NonEmptyString, + address: NonEmptyString, + membership_pdf: &TempFile<'_>, + ) -> Result<(), String> { + let role = Role::find_by_name(db, "Förderndes Mitglied").await.unwrap(); + let user = Self::create_member( + db, + created_by, + &role, + name, + mail, + financial, + birthdate, + member_since, + phone, + address, + membership_pdf, + ) + .await?; + + let user = Self::new(db, &user).await.unwrap(); + user.send_welcome_mail_to_user(db, smtp_pw).await?; + + if let Some(vorstand) = Role::find_by_name(db, "Vorstand").await { + Notification::create_for_role( + db, + &vorstand, + &format!("Lieber Vorstand, es gibt ein neues förderndes Mitglied: {user}"), + "Neues unterstützendes Vereinsmitglied", + None, + None, + ) + .await; + } + + Ok(()) + } +} diff --git a/src/model/user/member.rs b/src/model/user/member.rs new file mode 100644 index 0000000..9762fda --- /dev/null +++ b/src/model/user/member.rs @@ -0,0 +1,54 @@ +use super::ScheckbuchUser; +use crate::model::{ + logbook::{Logbook, LogbookWithBoatAndRowers}, + user::User, +}; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; + +#[derive(Serialize, Deserialize)] +pub(crate) enum Member { + SchnupperInterest(User), + Schnupperant(User), + Scheckbuch(Vec), + Regular(User), + Foerdernd(User), + Unterstuetzend(User), +} + +impl Member { + pub(crate) async fn from(db: &SqlitePool, user: User) -> Self { + if ScheckbuchUser::new(db, &user).await.is_some() { + Self::Scheckbuch(Logbook::completed_with_user(db, &user).await) + } else if user.has_role(db, "schnupper-interessierte").await { + Self::SchnupperInterest(user) + } else if user.has_role(db, "schnupperant").await { + Self::Schnupperant(user) + } else if user.has_role(db, "Donau Linz").await { + Self::Regular(user) + } else if user.has_role(db, "Förderndes Mitglied").await { + Self::Foerdernd(user) + } else if user.has_role(db, "Unterstützend").await { + Self::Unterstuetzend(user) + } else { + panic!("User {user} has no membership_type!!"); + } + } + + pub(crate) fn is_club_member(&self) -> bool { + matches!( + self, + Member::Regular(_) | Member::Foerdernd(_) | Member::Unterstuetzend(_) + ) + } + pub(crate) fn supposed_to_pay(&self) -> bool { + matches!( + self, + Member::Schnupperant(_) + | Member::Scheckbuch(_) + | Member::Regular(_) + | Member::Foerdernd(_) + | Member::Unterstuetzend(_) + ) + } +} diff --git a/src/model/user/mod.rs b/src/model/user/mod.rs index 707143f..9f00d37 100644 --- a/src/model/user/mod.rs +++ b/src/model/user/mod.rs @@ -1,21 +1,21 @@ -use std::ops::{Deref, DerefMut}; +use std::{fmt::Display, ops::DerefMut}; -use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; +use argon2::{Argon2, PasswordHasher, password_hash::SaltString}; use chrono::{Datelike, Local, NaiveDate}; use log::info; +use rocket::async_trait; use rocket::{ - async_trait, - http::{Cookie, Status}, - request::{self, FromRequest, Outcome}, - time::{Duration, OffsetDateTime}, - tokio::io::AsyncReadExt, Request, + http::{Cookie, Status}, + request::{FromRequest, Outcome}, + time::{Duration, OffsetDateTime}, }; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; +use super::activity::ActivityBuilder; use super::{ - family::Family, + Day, log::Log, logbook::Logbook, mail::Mail, @@ -24,13 +24,20 @@ use super::{ role::Role, stat::Stat, tripdetails::TripDetails, - Day, }; -use crate::{tera::admin::user::UserEditForm, AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD}; +use crate::AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD; use scheckbuch::ScheckbuchUser; +mod basic; +pub(crate) mod clubmember; mod fee; -mod scheckbuch; +pub(crate) mod foerdernd; +pub(crate) mod member; +pub(crate) mod regular; +pub(crate) mod scheckbuch; +pub(crate) mod schnupperant; +pub(crate) mod schnupperinterest; +pub(crate) mod unterstuetzend; #[derive(FromRow, Serialize, Deserialize, Clone, Debug, Eq, Hash, PartialEq)] pub struct User { @@ -53,6 +60,12 @@ pub struct User { pub user_token: String, } +impl Display for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct UserWithDetails { #[serde(flatten)] @@ -100,128 +113,6 @@ impl User { self.has_role_tx(db, "cox").await || self.has_role_tx(db, "Bootsführer").await } - pub async fn send_welcome_email(&self, db: &SqlitePool, smtp_pw: &str) -> Result<(), String> { - let Some(mail) = &self.mail else { - return Err(format!( - "Could not send welcome mail, because user {} has no email address", - self.name - )); - }; - - if self.has_role(db, "Donau Linz").await { - self.send_welcome_mail_full_member(db, mail, smtp_pw) - .await?; - } else if self.has_role(db, "schnupperant").await { - self.send_welcome_mail_schnupper(db, mail, smtp_pw).await?; - } else if let Some(scheckbuch) = ScheckbuchUser::new(db, &self).await { - scheckbuch.notify(db, mail, smtp_pw).await?; - } else { - return Err(format!( - "Could not send welcome mail, because user {} is not in Donau Linz or scheckbuch or schnupperant group", - self.name - )); - } - - Log::create( - db, - format!("Willkommensemail wurde an {} versandt", self.name), - ) - .await; - - Ok(()) - } - - async fn send_welcome_mail_schnupper( - &self, - db: &SqlitePool, - mail: &str, - smtp_pw: &str, - ) -> Result<(), String> { - // 2 things to do: - // 1. Send mail to user - Mail::send_single( - db, - mail, - "Schnupperrudern beim ASKÖ Ruderverein Donau Linz", - format!( -"Hallo {0}, - -es freut uns sehr, dich bei unserem Schnupperkurs willkommen heißen zu dürfen. Detaillierte Informationen folgen noch, ich werde sie dir ein paar Tage vor dem Termin zusenden. - -Riemen- & Dollenbruch, -ASKÖ Ruderverein Donau Linz", self.name), - smtp_pw, - ).await?; - - // 2. Notify all coxes - let coxes = Role::find_by_name(db, "schnupper-betreuer").await.unwrap(); - Notification::create_for_role( - db, - &coxes, - &format!( - "Liebe Schnupper-Betreuer, {} nimmt am Schnupperkurs teil.", - self.name - ), - "Neue(r) Schnupperteilnehmer:in ", - None, - None, - ) - .await; - - Ok(()) - } - - async fn send_welcome_mail_full_member( - &self, - db: &SqlitePool, - mail: &str, - smtp_pw: &str, - ) -> Result<(), String> { - // 2 things to do: - // 1. Send mail to user - Mail::send_single( - db, - mail, - "Willkommen im ASKÖ Ruderverein Donau Linz!", - format!( -"Hallo {0}, - -herzlich willkommen im ASKÖ Ruderverein Donau Linz! Wir freuen uns sehr, dich als neues Mitglied in unserem Verein begrüßen zu dürfen. - -Um dir den Einstieg zu erleichtern, findest du in unserem Handbuch alle wichtigen Informationen über unseren Verein: https://rudernlinz.at/book. Bei weiteren Fragen stehen dir die Adressen info@rudernlinz.at (für allgemeine Fragen) und it@rudernlinz.at (bei technischen Fragen) jederzeit zur Verfügung. - -Du kannst auch gerne unserer Signal-Gruppe beitreten, um auf dem Laufenden zu bleiben und dich mit anderen Mitgliedern auszutauschen: https://signal.group/#CjQKICFrq6zSsRHxrucS3jEcQn6lknEXacAykwwLV3vNLKxPEhA17jxz7cpjfu3JZokLq1TH - -Für die Organisation unserer Ausfahrten nutzen wir app.rudernlinz.at. Logge dich einfach mit deinem Namen ('{0}' ohne Anführungszeichen) ein, beim ersten Mal kannst du das Passwortfeld leer lassen. Unter 'Geplante Ausfahrten' kannst du dich jederzeit zu den Ausfahrten anmelden. - -Beim nächsten Treffen im Verein, erinnere jemand vom Vorstand (https://rudernlinz.at/unser-verein/vorstand/) bitte daran, deinen Fingerabdruck zu registrieren, damit du Zugang zum Bootshaus erhältst. - -Damit du dich noch mehr verbunden fühlst (:-)), haben wir im Bootshaus ein WLAN für Vereinsmitglieder 'ASKÖ Ruderverein Donau Linz' eingerichtet. Das Passwort dafür lautet 'donau1921' (ohne Anführungszeichen). Bitte gib das Passwort an keine vereinsfremden Personen weiter. - -Wir freuen uns darauf, dich bald am Wasser zu sehen und gemeinsam tolle Erfahrungen zu sammeln! - -Riemen- & Dollenbruch -ASKÖ Ruderverein Donau Linz", self.name), - smtp_pw, - ).await?; - - // 2. Notify all coxes - Notification::create_for_steering_people( - db, - &format!( - "Liebe Steuerberechtigte, seit {} gibt es ein neues Mitglied: {}", - self.member_since_date.clone().unwrap(), - self.name - ), - "Neues Vereinsmitglied", - None, - None, - ) - .await; - - Ok(()) - } - pub async fn amount_boats(&self, db: &SqlitePool) -> i64 { sqlx::query!( "SELECT COUNT(*) as count FROM boat WHERE owner = ?", @@ -262,7 +153,7 @@ ASKÖ Ruderverein Donau Linz", self.name), } pub async fn allowed_to_update_always_show_trip(&self, db: &SqlitePool) -> bool { - AllowedToUpdateTripToAlwaysBeShownUser::new(db, &self) + AllowedToUpdateTripToAlwaysBeShownUser::new(db, self) .await .is_some() } @@ -301,10 +192,44 @@ ASKÖ Ruderverein Donau Linz", self.name), .into_iter().map(|r| r.name).collect() } + pub async fn financial(&self, db: &SqlitePool) -> Option { + sqlx::query_as!( + Role, + " + SELECT r.id, r.name, r.formatted_name, r.desc, r.hide_in_lists, r.cluster +FROM role r +JOIN user_role ur ON r.id = ur.role_id +WHERE ur.user_id = ? +AND r.cluster = 'financial'; + ", + self.id + ) + .fetch_optional(db) + .await + .unwrap() + } + + pub async fn skill(&self, db: &SqlitePool) -> Option { + sqlx::query_as!( + Role, + " + SELECT r.id, r.name, r.formatted_name, r.desc, r.hide_in_lists, r.cluster +FROM role r +JOIN user_role ur ON r.id = ur.role_id +WHERE ur.user_id = ? +AND r.cluster = 'skill'; + ", + self.id + ) + .fetch_optional(db) + .await + .unwrap() + } + pub async fn real_roles(&self, db: &SqlitePool) -> Vec { sqlx::query_as!( Role, - "SELECT r.id, r.name, r.cluster + "SELECT r.id, r.name, r.cluster, r.formatted_name, r.desc, r.hide_in_lists FROM role r JOIN user_role ur ON r.id = ur.role_id JOIN user u ON u.id = ur.user_id @@ -494,22 +419,6 @@ ORDER BY last_access DESC .unwrap() } - pub async fn create(db: &SqlitePool, name: &str) -> bool { - let name = name.trim(); - sqlx::query!("INSERT INTO USER(name) VALUES (?)", name) - .execute(db) - .await - .is_ok() - } - - pub async fn create_with_mail(db: &SqlitePool, name: &str, mail: &str) -> bool { - let name = name.trim(); - sqlx::query!("INSERT INTO USER(name, mail) VALUES (?, ?)", name, mail) - .execute(db) - .await - .is_ok() - } - pub async fn update_ergo(&self, db: &SqlitePool, dob: i32, weight: i64, sex: &str) { sqlx::query!( "UPDATE user SET dob = ?, weight = ?, sex = ? where id = ?", @@ -523,88 +432,6 @@ ORDER BY last_access DESC .unwrap(); //Okay, because we can only create a User of a valid id } - pub async fn update(&self, db: &SqlitePool, data: UserEditForm<'_>) -> Result<(), String> { - let mut db = db.begin().await.map_err(|e| e.to_string())?; - - let mut family_id = data.family_id; - - if family_id.is_some_and(|x| x == -1) { - family_id = Some(Family::insert_tx(&mut db).await) - } - - if !self.has_membership_pdf_tx(&mut db).await { - if let Some(membership_pdf) = data.membership_pdf { - let mut stream = membership_pdf.open().await.unwrap(); - let mut buffer = Vec::new(); - stream.read_to_end(&mut buffer).await.unwrap(); - sqlx::query!( - "UPDATE user SET membership_pdf = ? where id = ?", - buffer, - self.id - ) - .execute(db.deref_mut()) - .await - .unwrap(); //Okay, because we can only create a User of a valid id - } - } - - sqlx::query!( - "UPDATE user SET dob = ?, weight = ?, sex = ?, member_since_date=?, birthdate=?, mail=?, nickname=?, notes=?, phone=?, address=?, family_id = ? where id = ?", - data.dob, - data.weight, - data.sex, - data.member_since_date, - data.birthdate, - data.mail, - data.nickname, - data.notes, - data.phone, - data.address, - family_id, - self.id - ) - .execute(db.deref_mut()) - .await - .unwrap(); //Okay, because we can only create a User of a valid id - - // handle roles - sqlx::query!("DELETE FROM user_role WHERE user_id = ?", self.id) - .execute(db.deref_mut()) - .await - .unwrap(); - - for role_id in data.roles.into_keys() { - let role = Role::find_by_id_tx(&mut db, role_id.parse::().unwrap()) - .await - .unwrap(); - self.add_role_tx(&mut db, &role).await?; - } - - db.commit().await.map_err(|e| e.to_string())?; - - Ok(()) - } - - pub async fn add_role(&self, db: &SqlitePool, role: &Role) -> Result<(), String> { - sqlx::query!( - "INSERT INTO user_role(user_id, role_id) VALUES (?, ?)", - self.id, - role.id - ) - .execute(db) - .await - .map_err(|_| { - format!( - "User already has a role in the cluster '{}'", - role.cluster - .clone() - .expect("db trigger can't activate on empty string") - ) - })?; - - Ok(()) - } - async fn send_end_mail_scheckbuch( &self, db: &mut Transaction<'_, Sqlite>, @@ -631,44 +458,11 @@ ASKÖ Ruderverein Donau Linz", self.name), smtp_pw, ).await?; - Ok(()) - } - - pub async fn add_role_tx( - &self, - db: &mut Transaction<'_, Sqlite>, - role: &Role, - ) -> Result<(), String> { - sqlx::query!( - "INSERT INTO user_role(user_id, role_id) VALUES (?, ?)", - self.id, - role.id - ) - .execute(db.deref_mut()) - .await - .map_err(|_| { - format!( - "User already has a role in the cluster '{}'", - role.cluster - .clone() - .expect("db trigger can't activate on empty string") - ) - })?; + ActivityBuilder::new(&format!("User {self} hat eine Mail bekommen, dass seine 5 Ausfahrten mit der heutigen Ausfahrt aufgebraucht sind, und dass der nächste Schritt eine Vereinsmitgliedschaft wäre (inkl. Links zu Beitrittserklärung + Info, dass sie an info@ geschickt werden soll.")).relevant_for_user(self).save_tx(db).await; Ok(()) } - pub async fn remove_role(&self, db: &SqlitePool, role: &Role) { - sqlx::query!( - "DELETE FROM user_role WHERE user_id = ? and role_id = ?", - self.id, - role.id - ) - .execute(db) - .await - .unwrap(); - } - pub async fn login(db: &SqlitePool, name: &str, pw: &str) -> Result { let name = name.trim().to_lowercase(); // just to make sure... let Some(user) = User::find_by_name(db, &name).await else { @@ -711,13 +505,14 @@ ASKÖ Ruderverein Donau Linz", self.name), }; if user.deleted { - Log::create( - db, - format!("User ({name}) already deleted (tried to login)."), - ) + ActivityBuilder::new(&format!( + "User {user} wollte sich einloggen, klappte jedoch nicht weil er gelöscht wurde." + )) + .relevant_for_user(&user) + .save(db) .await; return Err(LoginError::InvalidAuthenticationCombo); //User existed sometime ago; has - //been deleted + //been deleted } if let Some(user_pw) = user.pw.as_ref() { @@ -725,7 +520,12 @@ ASKÖ Ruderverein Donau Linz", self.name), if password_hash == user_pw { return Ok(user); } - Log::create(db, format!("User {name} supplied the wrong PW")).await; + ActivityBuilder::new(&format!( + "User {user} wollte sich einloggen, hat jedoch das falsche Passwort angegeben." + )) + .relevant_for_user(&user) + .save(db) + .await; Err(LoginError::InvalidAuthenticationCombo) } else { info!("User {name} has no PW set"); @@ -738,6 +538,12 @@ ASKÖ Ruderverein Donau Linz", self.name), .execute(db) .await .unwrap(); //Okay, because we can only create a User of a valid id + + // TODO: add responsible person + ActivityBuilder::new(&format!("Passwort von User {self} wurde zurückgesetzt.")) + .relevant_for_user(self) + .save(db) + .await; } pub async fn update_pw(&self, db: &SqlitePool, pw: &str) { @@ -746,6 +552,12 @@ ASKÖ Ruderverein Donau Linz", self.name), .execute(db) .await .unwrap(); //Okay, because we can only create a User of a valid id + ActivityBuilder::new(&format!( + "Passwort von User {self} wurde erfolgreich geändert." + )) + .relevant_for_user(self) + .save(db) + .await; } fn get_hashed_pw(pw: &str) -> String { @@ -767,11 +579,15 @@ ASKÖ Ruderverein Donau Linz", self.name), .unwrap(); //Okay, because we can only create a User of a valid id } - pub async fn delete(&self, db: &SqlitePool) { + pub async fn delete(&self, db: &SqlitePool, deleted_by: &ManageUserUser) { sqlx::query!("UPDATE user SET deleted=1 WHERE id=?", self.id) .execute(db) .await .unwrap(); //Okay, because we can only create a User of a valid id + ActivityBuilder::new(&format!("User {self} wurde von {deleted_by} gelöscht.")) + .relevant_for_user(self) + .save(db) + .await; } pub async fn get_days(&self, db: &SqlitePool) -> Vec { @@ -802,9 +618,9 @@ ASKÖ Ruderverein Donau Linz", self.name), pub(crate) async fn amount_days_to_show(&self, db: &SqlitePool) -> i64 { if self.allowed_to_steer(db).await { let end_of_year = NaiveDate::from_ymd_opt(Local::now().year(), 12, 31).unwrap(); //Ok, - //december - //has 31 - //days + //december + //has 31 + //days let days_left_in_year = end_of_year .signed_duration_since(Local::now().date_naive()) .num_days() @@ -813,9 +629,9 @@ ASKÖ Ruderverein Donau Linz", self.name), if days_left_in_year <= 31 { let end_of_next_year = NaiveDate::from_ymd_opt(Local::now().year() + 1, 12, 31).unwrap(); //Ok, - //december - //has 31 - //days + //december + //has 31 + //days end_of_next_year .signed_duration_since(Local::now().date_naive()) .num_days() @@ -863,6 +679,10 @@ ASKÖ Ruderverein Donau Linz", self.name), None,None ) .await; + ActivityBuilder::new(&format!("5 Scheckbuchausfahrten von {self} wurden mit der heutigen Ausfahrt aufgebraucht. Info-Mail wurde an {self} geschickt + alle Steuerberechtigten informiert, dass wir pot. ein neues Mitglied haben")) + .relevant_for_user(self) + .save_tx(db) + .await; } a if a > 5 => { let board = Role::find_by_name_tx(db, "Vorstand").await.unwrap(); @@ -877,6 +697,10 @@ ASKÖ Ruderverein Donau Linz", self.name), None,None ) .await; + ActivityBuilder::new(&format!("{self} hat nun bereits die {amount_trips}. seiner 5 Scheckbuchausfahrten absolviert. Vorstand wurde via Notification informiert.")) + .relevant_for_user(self) + .save_tx(db) + .await; } _ => {} } @@ -896,6 +720,12 @@ ASKÖ Ruderverein Donau Linz", self.name), None,None ) .await; + ActivityBuilder::new(&format!( + "{self} hat das heurige Fahrtenabzeichen geschafft! Der Vorstand + {self} wurde via Notification informiert." + )) + .relevant_for_user(self) + .save_tx(db) + .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; } @@ -914,6 +744,10 @@ ASKÖ Ruderverein Donau Linz", self.name), None,None ) .await; + ActivityBuilder::new(&format!("{self} hat den Äquatorpreis in {level} geschafft! Der Vorstand + {self} wurde via Notification informiert.")) + .relevant_for_user(self) + .save_tx(db) + .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; } @@ -924,7 +758,7 @@ ASKÖ Ruderverein Donau Linz", self.name), impl<'r> FromRequest<'r> for User { type Error = LoginError; - async fn from_request(req: &'r Request<'_>) -> request::Outcome { + async fn from_request(req: &'r Request<'_>) -> rocket::request::Outcome { match req.cookies().get_private("loggedin_user") { Some(user_id) => match user_id.value().parse::() { Ok(user_id) => { @@ -954,12 +788,12 @@ impl<'r> FromRequest<'r> for User { #[macro_export] macro_rules! special_user { ($name:ident, $($role:tt)*) => { - #[derive(Debug)] + #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct $name { pub(crate) user: User, } - impl Deref for $name { + impl std::ops::Deref for $name { type Target = User; fn deref(&self) -> &Self::Target { &self.user @@ -973,20 +807,20 @@ macro_rules! special_user { } #[async_trait] - impl<'r> FromRequest<'r> for $name { - type Error = LoginError; - async fn from_request(req: &'r Request<'_>) -> request::Outcome { + impl<'r> rocket::request::FromRequest<'r> for $name { + type Error = $crate::model::user::LoginError; + async fn from_request(req: &'r rocket::request::Request<'_>) -> rocket::request::Outcome { let db = req.rocket().state::().unwrap(); match User::from_request(req).await { - Outcome::Success(user) => { + rocket::request::Outcome::Success(user) => { if special_user!(@check_roles user, db, $($role)*) { - Outcome::Success($name { user }) + rocket::request::Outcome::Success($name { user }) } else { - Outcome::Forward(Status::Forbidden) + rocket::request::Outcome::Forward(rocket::http::Status::Forbidden) } } - Outcome::Error(f) => Outcome::Error(f), - Outcome::Forward(f) => Outcome::Forward(f), + rocket::request::Outcome::Error(f) => rocket::request::Outcome::Error(f), + rocket::request::Outcome::Forward(f) => rocket::request::Outcome::Forward(f), } } } @@ -1000,6 +834,12 @@ macro_rules! special_user { } } } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } + } }; (@check_roles $user:ident, $db:ident, $(+$role:expr),* $(,-$neg_role:expr)*) => { { @@ -1021,8 +861,10 @@ special_user!(TechUser, +"tech"); special_user!(ErgoUser, +"ergo"); special_user!(SteeringUser, +"cox", +"Bootsführer"); special_user!(AdminUser, +"admin"); -special_user!(AllowedForPlannedTripsUser, +"Donau Linz", +"scheckbuch"); -special_user!(DonauLinzUser, +"Donau Linz", -"Unterstützend", -"Förderndes Mitglied"); +special_user!(AllowedForPlannedTripsUser, +"Donau Linz", +"scheckbuch", +"Förderndes Mitglied"); +special_user!(DonauLinzUser, +"Donau Linz", -"Unterstützend", -"Förderndes Mitglied"); // TODO: +// remove -> +// RegularUser special_user!(SchnupperBetreuerUser, +"schnupper-betreuer"); special_user!(VorstandUser, +"admin", +"Vorstand"); special_user!(EventUser, +"manage_events"); @@ -1036,7 +878,8 @@ pub struct UserWithRolesAndMembershipPdf { #[serde(flatten)] pub user: User, pub membership_pdf: bool, - pub roles: Vec, + pub roles: Vec, // TODO: remove + pub proper_roles: Vec, } impl UserWithRolesAndMembershipPdf { @@ -1045,6 +888,7 @@ impl UserWithRolesAndMembershipPdf { Self { roles: user.roles(db).await, + proper_roles: user.real_roles(db).await, user, membership_pdf, } @@ -1076,9 +920,7 @@ impl UserWithMembershipPdf { #[cfg(test)] mod test { - use std::collections::HashMap; - - use crate::{tera::admin::user::UserEditForm, testdb}; + use crate::testdb; use super::User; use sqlx::SqlitePool; @@ -1125,52 +967,6 @@ mod test { assert_eq!(res.len(), 4); } - #[sqlx::test] - fn test_succ_create() { - let pool = testdb!(); - - assert_eq!(User::create(&pool, "new-user-name".into()).await, true); - } - - #[sqlx::test] - fn test_duplicate_name_create() { - let pool = testdb!(); - - assert_eq!(User::create(&pool, "admin".into()).await, false); - } - - #[sqlx::test] - fn test_update() { - let pool = testdb!(); - - let user = User::find_by_id(&pool, 1).await.unwrap(); - user.update( - &pool, - UserEditForm { - id: 1, - dob: None, - weight: None, - sex: Some("m".into()), - roles: HashMap::new(), - member_since_date: None, - birthdate: None, - mail: None, - nickname: None, - notes: None, - phone: None, - address: None, - family_id: None, - membership_pdf: None, - }, - ) - .await - .unwrap(); - - let user = User::find_by_id(&pool, 1).await.unwrap(); - - assert_eq!(user.sex, Some("m".into())); - } - #[sqlx::test] fn succ_login_with_test_db() { let pool = testdb!(); @@ -1182,17 +978,21 @@ mod test { #[sqlx::test] fn wrong_pw() { let pool = testdb!(); - assert!(User::login(&pool, "admin".into(), "admi".into()) - .await - .is_err()); + assert!( + User::login(&pool, "admin".into(), "admi".into()) + .await + .is_err() + ); } #[sqlx::test] fn wrong_username() { let pool = testdb!(); - assert!(User::login(&pool, "admi".into(), "admin".into()) - .await - .is_err()); + assert!( + User::login(&pool, "admi".into(), "admin".into()) + .await + .is_err() + ); } #[sqlx::test] @@ -1211,9 +1011,11 @@ mod test { let pool = testdb!(); let user = User::find_by_id(&pool, 1).await.unwrap(); - assert!(User::login(&pool, "admin".into(), "abc".into()) - .await - .is_err()); + assert!( + User::login(&pool, "admin".into(), "abc".into()) + .await + .is_err() + ); user.update_pw(&pool, "abc".into()).await; diff --git a/src/model/user/regular.rs b/src/model/user/regular.rs new file mode 100644 index 0000000..438a8a6 --- /dev/null +++ b/src/model/user/regular.rs @@ -0,0 +1,154 @@ +use super::{ManageUserUser, User}; +use crate::{ + NonEmptyString, + model::{activity::ActivityBuilder, mail::Mail, notification::Notification, role::Role}, + special_user, +}; +use chrono::NaiveDate; +use rocket::{async_trait, fs::TempFile, tokio::io::AsyncReadExt}; +use sqlx::SqlitePool; + +special_user!(RegularUser, +"Donau Linz"); + +pub trait ClubMember { + async fn create_member( + db: &SqlitePool, + created_by: &ManageUserUser, + role: &Role, + name: NonEmptyString, + mail: &str, + financial: Option, + birthdate: &NaiveDate, + member_since: &NaiveDate, + phone: NonEmptyString, + address: NonEmptyString, + membership_pdf: &TempFile<'_>, + ) -> Result { + if membership_pdf.len() == 0 { + return Err("Keine Beitrittserklärung mitgeschickt.".to_string()); + } + + let mut stream = membership_pdf.open().await.unwrap(); + let mut buffer = Vec::new(); + stream.read_to_end(&mut buffer).await.unwrap(); + + let name = name.as_str(); + let phone = phone.as_str(); + let address = address.as_str(); + + sqlx::query!( + "INSERT INTO user(name, member_since_date, birthdate, mail, phone, address, membership_pdf) + VALUES (?,?,?,?,?,?,?)", + name, member_since, birthdate, mail, phone, address,buffer + ) + .execute(db) + .await + .map_err(|e| e.to_string())?; + + let user = User::find_by_name(db, name).await.unwrap(); + user.change_financial(db, created_by, financial).await?; + user.add_role(db, created_by, role).await?; + + ActivityBuilder::new(&format!( + "{created_by} hat Mitglied {user} mit der Rolle {role} angelegt." + )) + .relevant_for_user(&user) + .save(db) + .await; + + Ok(user) + } +} + +impl ClubMember for RegularUser {} + +impl RegularUser { + pub(crate) async fn send_welcome_mail_to_user( + &self, + db: &SqlitePool, + smtp_pw: &str, + ) -> Result<(), String> { + let Some(mail) = &self.mail else { + return Err(format!( + "Couldn't send welcome mail, as the user {self} has no mail..." + )); + }; + + Mail::send_single( + db, + mail, + "Willkommen im ASKÖ Ruderverein Donau Linz!", + format!( +"Hallo {0}, + +herzlich willkommen im ASKÖ Ruderverein Donau Linz! Wir freuen uns sehr, dich als neues Mitglied in unserem Verein begrüßen zu dürfen. + +Um dir den Einstieg zu erleichtern, findest du in unserem Handbuch alle wichtigen Informationen über unseren Verein: https://rudernlinz.at/book. Bei weiteren Fragen stehen dir die Adressen info@rudernlinz.at (für allgemeine Fragen) und it@rudernlinz.at (bei technischen Fragen) jederzeit zur Verfügung. + +Du kannst auch gerne unserer Signal-Gruppe beitreten, um auf dem Laufenden zu bleiben und dich mit anderen Mitgliedern auszutauschen: https://signal.group/#CjQKICFrq6zSsRHxrucS3jEcQn6lknEXacAykwwLV3vNLKxPEhA17jxz7cpjfu3JZokLq1TH + +Für die Organisation unserer Ausfahrten nutzen wir app.rudernlinz.at. Logge dich einfach mit deinem Namen ('{0}' ohne Anführungszeichen) ein, beim ersten Mal kannst du das Passwortfeld leer lassen. Unter 'Geplante Ausfahrten' kannst du dich jederzeit zu den Ausfahrten anmelden. + +Beim nächsten Treffen im Verein, erinnere jemand vom Vorstand (https://rudernlinz.at/unser-verein/vorstand/) bitte daran, deinen Fingerabdruck zu registrieren, damit du Zugang zum Bootshaus erhältst. + +Damit du dich noch mehr verbunden fühlst (:-)), haben wir im Bootshaus ein WLAN für Vereinsmitglieder 'ASKÖ Ruderverein Donau Linz' eingerichtet. Das Passwort dafür lautet 'donau1921' (ohne Anführungszeichen). Bitte gib das Passwort an keine vereinsfremden Personen weiter. + +Wir freuen uns darauf, dich bald am Wasser zu sehen und gemeinsam tolle Erfahrungen zu sammeln! + +Riemen- & Dollenbruch +ASKÖ Ruderverein Donau Linz", self.name), + smtp_pw, + ).await?; + + ActivityBuilder::new(&format!("Willkommensmail für {self} wurde an {mail} verschickt (Handbuch, Signal-Gruppe, App-Info, Fingerprint, WLAN).")) + .relevant_for_user(self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn create( + db: &SqlitePool, + created_by: &ManageUserUser, + smtp_pw: &str, + name: NonEmptyString, + mail: &str, + financial: Option, + birthdate: &NaiveDate, + member_since: &NaiveDate, + phone: NonEmptyString, + address: NonEmptyString, + membership_pdf: &TempFile<'_>, + ) -> Result<(), String> { + let role = Role::find_by_name(db, "Donau Linz").await.unwrap(); + let user = Self::create_member( + db, + created_by, + &role, + name, + mail, + financial, + birthdate, + member_since, + phone, + address, + membership_pdf, + ) + .await?; + + let user = Self::new(db, &user).await.unwrap(); + user.send_welcome_mail_to_user(db, smtp_pw).await?; + + Notification::create_for_steering_people( + db, + &format!("Liebe Steuerberechtigte, es gibt ein neues Mitglied: {user} 🎉"), + "Neues Vereinsmitglied", + None, + None, + ) + .await; + + Ok(()) + } +} diff --git a/src/model/user/scheckbuch.rs b/src/model/user/scheckbuch.rs index 3fc04fe..694433b 100644 --- a/src/model/user/scheckbuch.rs +++ b/src/model/user/scheckbuch.rs @@ -1,58 +1,237 @@ -use super::User; -use crate::model::user::LoginError; +use super::foerdernd::FoerderndUser; +use super::regular::RegularUser; +use super::unterstuetzend::UnterstuetzendUser; +use super::{ManageUserUser, User}; +use crate::NonEmptyString; +use crate::model::activity::ActivityBuilder; +use crate::model::role::Role; use crate::{ - model::{mail::Mail, notification::Notification, role::Role}, - special_user, SCHECKBUCH, + SCHECKBUCH, + model::{mail::Mail, notification::Notification}, + special_user, }; +use chrono::NaiveDate; use rocket::async_trait; -use rocket::http::Status; -use rocket::request; -use rocket::request::FromRequest; -use rocket::request::Outcome; -use rocket::Request; +use rocket::fs::TempFile; use sqlx::SqlitePool; -use std::ops::Deref; special_user!(ScheckbuchUser, +"scheckbuch"); impl ScheckbuchUser { - async fn from(user: User, db: &SqlitePool, mail: &str, smtp_pw: &str) -> Result<(), String> { - // TODO: see when/how to invoke this function (explicit `Neue Person hinzufügen` button? - // Button to transition existing users to scheckbuch? Automatically called when - // `scheckbuch` is newly selected as role? - if user.has_role(db, "scheckbuch").await { - return Err("User is already a scheckbuch".into()); - } - - // TODO: do we allow e.g. DonauLinz to scheckbuch? - - let scheckbuch = Role::find_by_name(db, "scheckbuch").await.unwrap(); - user.add_role(db, &scheckbuch).await.unwrap(); - - // TODO: remove all other `membership_type` roles - let new_user = Self::new(db, &user).await.unwrap(); - - new_user.notify(db, mail, smtp_pw).await - } - - pub(crate) async fn notify( + async fn set_data_for_clubmember( &self, db: &SqlitePool, - mail: &str, - smtp_pw: &str, + changed_by: &ManageUserUser, + member_since: &NaiveDate, + birthdate: &NaiveDate, + phone: NonEmptyString, + address: NonEmptyString, + membership_pdf: &TempFile<'_>, ) -> Result<(), String> { - self.send_welcome_mail_to_user(db, mail, smtp_pw).await?; - self.notify_coxes_about_new_scheckbuch(db).await; + self.user.update_birthdate(db, changed_by, birthdate).await; + self.user + .update_member_since(db, changed_by, member_since) + .await; + + self.user.update_phone(db, changed_by, &phone).await; + self.user.update_address(db, changed_by, &address).await; + self.user + .add_membership_pdf(db, changed_by, membership_pdf) + .await?; + + Ok(()) + } + pub(crate) async fn convert_to_regular_user( + self, + db: &SqlitePool, + smtp_pw: &str, + changed_by: &ManageUserUser, + member_since: &NaiveDate, + birthdate: &NaiveDate, + phone: NonEmptyString, + address: NonEmptyString, + membership_pdf: &TempFile<'_>, + ) -> Result<(), String> { + self.set_data_for_clubmember( + db, + changed_by, + member_since, + birthdate, + phone, + address, + membership_pdf, + ) + .await?; + + // Change roles + let regular = Role::find_by_name(db, "Donau Linz").await.unwrap(); + let scheckbook = Role::find_by_name(db, "scheckbuch").await.unwrap(); + self.user.remove_role(db, changed_by, &scheckbook).await?; + self.user.add_role(db, changed_by, ®ular).await?; + + // Notify + let regular = RegularUser::new(db, &self.user).await.unwrap(); + regular.send_welcome_mail_to_user(db, smtp_pw).await?; + Notification::create_for_steering_people( + db, + &format!( + "Liebe Steuerberechtigte, {} hatte ein Scheckbuch und ist nun seit {} es ein neues reguläres Mitglied. 🎉", + self.name, + self.member_since_date.clone().unwrap() + ), + "Neues Vereinsmitglied", + None, + None, + ) + .await; + + ActivityBuilder::new(&format!( + "{changed_by} hat den Scheckbuch-User {self} auf ein reguläres Mitglied upgegraded! Die Steuerpersonen wurden via Notification informiert." + )) + .relevant_for_user(&self) + .save(db) + .await; Ok(()) } - async fn send_welcome_mail_to_user( + pub(crate) async fn convert_to_unterstuetzend_user( + self, + db: &SqlitePool, + smtp_pw: &str, + changed_by: &ManageUserUser, + member_since: &NaiveDate, + birthdate: &NaiveDate, + phone: NonEmptyString, + address: NonEmptyString, + membership_pdf: &TempFile<'_>, + ) -> Result<(), String> { + // Set data + self.set_data_for_clubmember( + db, + changed_by, + member_since, + birthdate, + phone, + address, + membership_pdf, + ) + .await?; + + // Change roles + let unterstuetzend = Role::find_by_name(db, "Unterstützend").await.unwrap(); + let scheckbook = Role::find_by_name(db, "scheckbuch").await.unwrap(); + self.user.remove_role(db, changed_by, &scheckbook).await?; + self.user.add_role(db, changed_by, &unterstuetzend).await?; + + let unterstuetzend = UnterstuetzendUser::new(db, &self.user).await.unwrap(); + unterstuetzend + .send_welcome_mail_to_user(db, smtp_pw) + .await?; + if let Some(vorstand) = Role::find_by_name(db, "vorstand").await { + Notification::create_for_role( + db, + &vorstand, + &format!( + "Lieber Vorstand, {} hatte ein Scheckbuch und ist nun seit {} es ein neues unterstützendes Mitglied.", + self.name, + self.member_since_date.clone().unwrap() + ), + "Neues unterstützendes Vereinsmitglied", + None, + None, + ) + .await; + } + ActivityBuilder::new(&format!("{changed_by} hat den Scheckbuch-User {self} auf ein unterstützendes Mitglied upgegraded!")) + .relevant_for_user(&self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn convert_to_foerdernd_user( + self, + db: &SqlitePool, + smtp_pw: &str, + changed_by: &ManageUserUser, + member_since: &NaiveDate, + birthdate: &NaiveDate, + phone: NonEmptyString, + address: NonEmptyString, + membership_pdf: &TempFile<'_>, + ) -> Result<(), String> { + // Set data + self.set_data_for_clubmember( + db, + changed_by, + member_since, + birthdate, + phone, + address, + membership_pdf, + ) + .await?; + + // Change roles + let unterstuetzend = Role::find_by_name(db, "Förderndes Mitglied").await.unwrap(); + let scheckbook = Role::find_by_name(db, "scheckbuch").await.unwrap(); + self.user.remove_role(db, changed_by, &scheckbook).await?; + self.user.add_role(db, changed_by, &unterstuetzend).await?; + + let foerdernd = FoerderndUser::new(db, &self.user).await.unwrap(); + foerdernd.send_welcome_mail_to_user(db, smtp_pw).await?; + if let Some(vorstand) = Role::find_by_name(db, "vorstand").await { + Notification::create_for_role( + db, + &vorstand, + &format!( + "Lieber Vorstand, {} hatte ein Scheckbuch und ist nun seit {} es ein neues förderndes Mitglied.", + self.name, + self.member_since_date.clone().unwrap() + ), + "Neues förderndes Vereinsmitglied", + None, + None, + ) + .await; + } + ActivityBuilder::new(&format!( + "{changed_by} hat den Scheckbuch-User {self} auf ein förderndes Mitglied upgegraded!" + )) + .relevant_for_user(&self) + .save(db) + .await; + + Ok(()) + } + + // TODO: make private + pub(crate) async fn notify(&self, db: &SqlitePool, smtp_pw: &str) -> Result<(), String> { + self.notify_coxes_about_new_scheckbuch(db).await; + self.send_welcome_mail_to_user(db, smtp_pw).await?; + + ActivityBuilder::new(&format!( + "{self} hat eine Info-Mail bekommen (Erklärung Scheckbuch, Ruderapp) und alle Steuerberechtigten wurden informiert." + )) + .relevant_for_user(self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn send_welcome_mail_to_user( &self, db: &SqlitePool, - mail: &str, smtp_pw: &str, ) -> Result<(), String> { + let Some(mail) = &self.mail else { + return Err( + "Kann Mail nicht versenden, weil der User keine Mailadresse hinterlegt hat.".into(), + ); + }; Mail::send_single( db, mail, @@ -88,4 +267,38 @@ ASKÖ Ruderverein Donau Linz", self.name, SCHECKBUCH/100), ) .await; } + + pub(crate) async fn create( + db: &SqlitePool, + created_by: &ManageUserUser, + smtp_pw: &str, + name: NonEmptyString, + mail: &str, + ) -> Result<(), String> { + let role = Role::find_by_name(db, "scheckbuch").await.unwrap(); + + let name = name.as_str(); + sqlx::query!( + "INSERT INTO user(name, mail) + VALUES (?,?)", + name, + mail + ) + .execute(db) + .await + .map_err(|e| e.to_string())?; + + let user = User::find_by_name(db, name).await.unwrap(); + user.add_role(db, created_by, &role).await?; + + let user = Self::new(db, &user).await.unwrap(); + user.notify(db, smtp_pw).await?; + + ActivityBuilder::new(&format!("{created_by} hat Scheckbuch {user} angelegt.")) + .relevant_for_user(&user) + .save(db) + .await; + + Ok(()) + } } diff --git a/src/model/user/schnupperant.rs b/src/model/user/schnupperant.rs new file mode 100644 index 0000000..b5addb5 --- /dev/null +++ b/src/model/user/schnupperant.rs @@ -0,0 +1,402 @@ +use super::foerdernd::FoerderndUser; +use super::regular::RegularUser; +use super::scheckbuch::ScheckbuchUser; +use super::schnupperinterest::SchnupperInterestUser; +use super::unterstuetzend::UnterstuetzendUser; +use super::{ManageUserUser, User}; +use crate::NonEmptyString; +use crate::model::activity::ActivityBuilder; +use crate::model::role::Role; +use crate::{ + model::{mail::Mail, notification::Notification}, + special_user, +}; +use chrono::NaiveDate; +use rocket::async_trait; +use rocket::fs::TempFile; +use sqlx::SqlitePool; + +special_user!(SchnupperantUser, +"schnupperant"); + +impl SchnupperantUser { + async fn set_data_for_clubmember( + &self, + db: &SqlitePool, + changed_by: &ManageUserUser, + member_since: &NaiveDate, + birthdate: &NaiveDate, + phone: NonEmptyString, + address: NonEmptyString, + membership_pdf: &TempFile<'_>, + ) -> Result<(), String> { + self.user.update_birthdate(db, changed_by, birthdate).await; + self.user + .update_member_since(db, changed_by, member_since) + .await; + + self.user.update_phone(db, changed_by, &phone).await; + self.user.update_address(db, changed_by, &address).await; + self.user + .add_membership_pdf(db, changed_by, membership_pdf) + .await?; + + Ok(()) + } + pub(crate) async fn convert_to_regular_user( + self, + db: &SqlitePool, + smtp_pw: &str, + changed_by: &ManageUserUser, + member_since: &NaiveDate, + birthdate: &NaiveDate, + phone: NonEmptyString, + address: NonEmptyString, + membership_pdf: &TempFile<'_>, + ) -> Result<(), String> { + self.set_data_for_clubmember( + db, + changed_by, + member_since, + birthdate, + phone, + address, + membership_pdf, + ) + .await?; + + // Change roles + let regular = Role::find_by_name(db, "Donau Linz").await.unwrap(); + let scheckbook = Role::find_by_name(db, "schnupperant").await.unwrap(); + self.user.remove_role(db, changed_by, &scheckbook).await?; + self.user.add_role(db, changed_by, ®ular).await?; + + // Notify + let regular = RegularUser::new(db, &self.user).await.unwrap(); + regular.send_welcome_mail_to_user(db, smtp_pw).await?; + Notification::create_for_steering_people( + db, + &format!( + "Liebe Steuerberechtigte, {} nahm an unserem Schnupperkurs teil und ist nun seit {} ein neues reguläres Mitglied. 🎉", + self.name, + self.member_since_date.clone().unwrap() + ), + "Neues Vereinsmitglied", + None, + None, + ) + .await; + + ActivityBuilder::new(&format!( + "{changed_by} hat den Schnupperant {self} auf ein reguläres Mitglied upgegraded!" + )) + .relevant_for_user(&self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn move_to_scheckbook( + self, + db: &SqlitePool, + changed_by: &ManageUserUser, + smtp_pw: &str, + ) -> Result<(), String> { + let schnupperant = Role::find_by_name(db, "schnupperant").await.unwrap(); + let scheckbook = Role::find_by_name(db, "scheckbuch").await.unwrap(); + self.user.remove_role(db, changed_by, &schnupperant).await?; + self.user.add_role(db, changed_by, &scheckbook).await?; + + if let Some(no_einschreibgebuehr) = Role::find_by_name(db, "no-einschreibgebuehr").await { + self.add_role(db, changed_by, &no_einschreibgebuehr) + .await + .expect("role doesn't have a group"); + } + + let scheckbook = ScheckbuchUser::new(db, &self.user).await.unwrap(); + scheckbook.notify(db, smtp_pw).await?; + + Notification::create_for_steering_people( + db, + &format!( + "Liebe Steuerberechtigte, {} hat unseren Schnupperkurs absolviert und nun ein Scheckbuch. Wie immer, freuen wir uns wenn du uns beim A+F Rudern unterstützt oder selber Ausfahrten ausschreibst. Bitte beachte, dass Scheckbuch-Personen nur Ausfahrten sehen, bei denen 'Scheckbuch-Anmeldungen erlauben' ausgewählt wurde.", + self.name + ), + "Neues Scheckbuch", + None,None + ) + .await; + + ActivityBuilder::new(&format!( + "{changed_by} hat dem ehemaligen Schnupperant {self} nun ein Scheckbuch gegeben" + )) + .relevant_for_user(&self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn move_to_schnupperinterest( + self, + db: &SqlitePool, + changed_by: &ManageUserUser, + ) -> Result<(), String> { + let schnupperinterest = Role::find_by_name(db, "schnupper-interessierte") + .await + .unwrap(); + let schnupperant = Role::find_by_name(db, "schnupperant").await.unwrap(); + self.user.remove_role(db, changed_by, &schnupperant).await?; + self.user + .add_role(db, changed_by, &schnupperinterest) + .await?; + + let schnupperinterest = SchnupperInterestUser::new(db, &self.user).await.unwrap(); + schnupperinterest.notify(db).await?; + + if let Some(role) = Role::find_by_name(db, "schnupper-betreuer").await { + Notification::create_for_role( + db, + &role, + &format!( + "Lieber Schnupperbetreuer, {} hat sich vom Schnupperkurs abgemeldet.", + self.name + ), + "Schnupperkurs Abmeldung", + None, + None, + ) + .await; + } + + ActivityBuilder::new(&format!( + "{changed_by} hat dem eigentlichen Schnupperanten {self} wieder auf die 'Interessierten'-Liste zurückgegeben." + )) + .relevant_for_user(&self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn convert_to_unterstuetzend_user( + self, + db: &SqlitePool, + smtp_pw: &str, + changed_by: &ManageUserUser, + member_since: &NaiveDate, + birthdate: &NaiveDate, + phone: NonEmptyString, + address: NonEmptyString, + membership_pdf: &TempFile<'_>, + ) -> Result<(), String> { + // Set data + self.set_data_for_clubmember( + db, + changed_by, + member_since, + birthdate, + phone, + address, + membership_pdf, + ) + .await?; + + // Change roles + let unterstuetzend = Role::find_by_name(db, "Unterstützend").await.unwrap(); + let scheckbook = Role::find_by_name(db, "schnupperant").await.unwrap(); + self.user.remove_role(db, changed_by, &scheckbook).await?; + self.user.add_role(db, changed_by, &unterstuetzend).await?; + if let Some(no_einschreibgebuehr) = Role::find_by_name(db, "no-einschreibgebuehr").await { + self.add_role(db, changed_by, &no_einschreibgebuehr) + .await + .expect("role doesn't have a group"); + } + + let unterstuetzend = UnterstuetzendUser::new(db, &self.user).await.unwrap(); + unterstuetzend + .send_welcome_mail_to_user(db, smtp_pw) + .await?; + if let Some(vorstand) = Role::find_by_name(db, "vorstand").await { + Notification::create_for_role( + db, + &vorstand, + &format!( + "Lieber Vorstand, {} nahm am Schnupperkurs teil und ist nun seit {} es ein neues unterstützendes Mitglied.", + self.name, + self.member_since_date.clone().unwrap() + ), + "Neues unterstützendes Vereinsmitglied", + None, + None, + ) + .await; + } + + ActivityBuilder::new(&format!( + "{changed_by} hat den Schnupperant {self} auf ein unterstützendes Mitglied upgegraded!" + )) + .relevant_for_user(&self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn convert_to_foerdernd_user( + self, + db: &SqlitePool, + smtp_pw: &str, + changed_by: &ManageUserUser, + member_since: &NaiveDate, + birthdate: &NaiveDate, + phone: NonEmptyString, + address: NonEmptyString, + membership_pdf: &TempFile<'_>, + ) -> Result<(), String> { + // Set data + self.set_data_for_clubmember( + db, + changed_by, + member_since, + birthdate, + phone, + address, + membership_pdf, + ) + .await?; + + // Change roles + let unterstuetzend = Role::find_by_name(db, "Förderndes Mitglied").await.unwrap(); + let scheckbook = Role::find_by_name(db, "schnupperant").await.unwrap(); + self.user.remove_role(db, changed_by, &scheckbook).await?; + self.user.add_role(db, changed_by, &unterstuetzend).await?; + if let Some(no_einschreibgebuehr) = Role::find_by_name(db, "no-einschreibgebuehr").await { + self.add_role(db, changed_by, &no_einschreibgebuehr) + .await + .expect("role doesn't have a group"); + } + + let foerdernd = FoerderndUser::new(db, &self.user).await.unwrap(); + foerdernd.send_welcome_mail_to_user(db, smtp_pw).await?; + if let Some(vorstand) = Role::find_by_name(db, "vorstand").await { + Notification::create_for_role( + db, + &vorstand, + &format!( + "Lieber Vorstand, {} nahm am Schnupperkurs teil und ist nun seit {} es ein neues förderndes Mitglied.", + self.name, + self.member_since_date.clone().unwrap() + ), + "Neues förderndes Vereinsmitglied", + None, + None, + ) + .await; + } + + ActivityBuilder::new(&format!( + "{changed_by} hat den Schnupperant {self} auf ein förderndes Mitglied upgegraded!" + )) + .relevant_for_user(&self) + .save(db) + .await; + + Ok(()) + } + + // TODO: make private + pub(crate) async fn notify(&self, db: &SqlitePool, smtp_pw: &str) -> Result<(), String> { + self.notify_coxes_about_new_scheckbuch(db).await; + self.send_welcome_mail_to_user(db, smtp_pw).await?; + + ActivityBuilder::new(&format!( + "{self} hat eine Mail bekommen (Inhalt: wir freuen uns auf ihn + senden detailliertere Infos später zu) und die Schnupperbetreuer wurden via Notification informiert." + )) + .relevant_for_user(self) + .save(db) + .await; + + Ok(()) + } + + async fn send_welcome_mail_to_user( + &self, + db: &SqlitePool, + smtp_pw: &str, + ) -> Result<(), String> { + let Some(mail) = &self.mail else { + return Err(format!( + "Couldn't send mail, because user {self} has no mail" + )); + }; + Mail::send_single( + db, + mail, + "ASKÖ Ruderverein Donau Linz | Anmeldung Schnupperkurs", + format!( +"Hallo {0}, + +es freut uns sehr, dich bei unserem Schnupperkurs willkommen heißen zu dürfen. Detaillierte Informationen folgen noch, ich werde sie dir ein paar Tage vor dem Termin zusenden. + +Riemen- & Dollenbruch, +ASKÖ Ruderverein Donau Linz", self.name), + smtp_pw, + ).await?; + + Ok(()) + } + + async fn notify_coxes_about_new_scheckbuch(&self, db: &SqlitePool) { + if let Some(role) = Role::find_by_name(db, "schnupper-betreuer").await { + Notification::create_for_role( + db, + &role, + &format!( + "Lieber Schnupperbetreuer, {} hat sich zum Schnupperkurs angemeldet.", + self.name + ), + "Neuer Schnupperant", + None, + None, + ) + .await; + } + } + + pub(crate) async fn create( + db: &SqlitePool, + created_by: &ManageUserUser, + smtp_pw: &str, + name: NonEmptyString, + mail: &str, + ) -> Result<(), String> { + let role = Role::find_by_name(db, "schnupperant").await.unwrap(); + + let name = name.as_str(); + sqlx::query!( + "INSERT INTO user(name, mail) + VALUES (?,?)", + name, + mail + ) + .execute(db) + .await + .map_err(|e| e.to_string())?; + + let user = User::find_by_name(db, name).await.unwrap(); + user.add_role(db, created_by, &role).await?; + + let user = Self::new(db, &user).await.unwrap(); + user.notify(db, smtp_pw).await?; + + ActivityBuilder::new(&format!( + "{created_by} hat {user} zur fixen Schnupperkurs-Anmeldung hinzugefügt." + )) + .relevant_for_user(&user) + .save(db) + .await; + + Ok(()) + } +} diff --git a/src/model/user/schnupperinterest.rs b/src/model/user/schnupperinterest.rs new file mode 100644 index 0000000..d046048 --- /dev/null +++ b/src/model/user/schnupperinterest.rs @@ -0,0 +1,162 @@ +use super::scheckbuch::ScheckbuchUser; +use super::schnupperant::SchnupperantUser; +use super::{ManageUserUser, User}; +use crate::NonEmptyString; +use crate::model::activity::ActivityBuilder; +use crate::model::role::Role; +use crate::{model::notification::Notification, special_user}; +use rocket::async_trait; +use sqlx::SqlitePool; + +special_user!(SchnupperInterestUser, +"schnupper-interessierte"); + +impl SchnupperInterestUser { + pub(crate) async fn move_to_scheckbook( + self, + db: &SqlitePool, + changed_by: &ManageUserUser, + smtp_pw: &str, + ) -> Result<(), String> { + let schnupperinterest = Role::find_by_name(db, "schnupper-interessierte") + .await + .unwrap(); + let scheckbook = Role::find_by_name(db, "scheckbuch").await.unwrap(); + self.user + .remove_role(db, changed_by, &schnupperinterest) + .await?; + self.user.add_role(db, changed_by, &scheckbook).await?; + + let scheckbook = ScheckbuchUser::new(db, &self.user).await.unwrap(); + scheckbook.notify(db, smtp_pw).await?; + + Notification::create_for_steering_people( + db, + &format!( + "Liebe Steuerberechtigte, {} wollte unseren Schnupperkurs absolviert und nun ein Scheckbuch. Wie immer, freuen wir uns wenn du uns beim A+F Rudern unterstützt oder selber Ausfahrten ausschreibst. Bitte beachte, dass Scheckbuch-Personen nur Ausfahrten sehen, bei denen 'Scheckbuch-Anmeldungen erlauben' ausgewählt wurde.", + self.name + ), + "Neues Scheckbuch", + None, + None + ) + .await; + + ActivityBuilder::new(&format!( + "Der Schnupperinteressierte {self} hat sich (ohne Schnupperkurs) doch gleich direkt für ein Scheckbuch entschieden. {changed_by} hat dieses eingerichtet." + )) + .relevant_for_user(&self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn move_to_schnupperant( + self, + db: &SqlitePool, + changed_by: &ManageUserUser, + smtp_pw: &str, + ) -> Result<(), String> { + let schnupperinterest = Role::find_by_name(db, "schnupper-interessierte") + .await + .unwrap(); + let schnupperant = Role::find_by_name(db, "schnupperant").await.unwrap(); + self.user + .remove_role(db, changed_by, &schnupperinterest) + .await?; + self.user.add_role(db, changed_by, &schnupperant).await?; + + let schnupperant = SchnupperantUser::new(db, &self.user).await.unwrap(); + schnupperant.notify(db, smtp_pw).await?; + + if let Some(role) = Role::find_by_name(db, "schnupper-betreuer").await { + Notification::create_for_role( + db, + &role, + &format!( + "Lieber Schnupperbetreuer, {} hat sich zum Schnupperkurs angemeldet.", + self.name + ), + "Neuer Schnupper-Interessierte:r", + None, + None, + ) + .await; + } + ActivityBuilder::new(&format!( + "Der Schnupperinteressierte {self} hat sich zum Schnupperkurs angemeldet." + )) + .relevant_for_user(&self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn notify(&self, db: &SqlitePool) -> Result<(), String> { + self.notify_schnupperbetreuer_about_new_interest(db).await; + + ActivityBuilder::new(&format!( + "Der Schnupperbetreuer hat eine Info via Notification bekommen, dass {self} Interesse an einen Schnupperkurs hat." + )) + .relevant_for_user(self) + .save(db) + .await; + + Ok(()) + } + + async fn notify_schnupperbetreuer_about_new_interest(&self, db: &SqlitePool) { + if let Some(role) = Role::find_by_name(db, "schnupper-betreuer").await { + Notification::create_for_role( + db, + &role, + &format!( + "Lieber Schnupperbetreuer, {} hat Interesse zum Schnupperkurs bekundet.", + self.name + ), + "Neuer Schnupper-Interessierte:r", + None, + None, + ) + .await; + } + } + + pub(crate) async fn create( + db: &SqlitePool, + created_by: &ManageUserUser, + name: NonEmptyString, + mail: &str, + ) -> Result<(), String> { + let role = Role::find_by_name(db, "schnupper-interessierte") + .await + .unwrap(); + + let name = name.as_str(); + sqlx::query!( + "INSERT INTO user(name, mail) + VALUES (?,?)", + name, + mail + ) + .execute(db) + .await + .map_err(|e| e.to_string())?; + + let user = User::find_by_name(db, name).await.unwrap(); + user.add_role(db, created_by, &role).await?; + + let user = Self::new(db, &user).await.unwrap(); + user.notify(db).await?; + + ActivityBuilder::new(&format!( + "{created_by} hat Schnupper-Interessierten {user} angelegt." + )) + .relevant_for_user(&user) + .save(db) + .await; + + Ok(()) + } +} diff --git a/src/model/user/unterstuetzend.rs b/src/model/user/unterstuetzend.rs new file mode 100644 index 0000000..7399406 --- /dev/null +++ b/src/model/user/unterstuetzend.rs @@ -0,0 +1,101 @@ +use super::{ManageUserUser, User, regular::ClubMember}; +use crate::{ + NonEmptyString, + model::{activity::ActivityBuilder, mail::Mail, notification::Notification, role::Role}, + special_user, +}; +use chrono::NaiveDate; +use rocket::{async_trait, fs::TempFile}; +use sqlx::SqlitePool; + +special_user!(UnterstuetzendUser, +"Unterstützend"); + +impl ClubMember for UnterstuetzendUser {} + +impl UnterstuetzendUser { + pub(crate) async fn send_welcome_mail_to_user( + &self, + db: &SqlitePool, + smtp_pw: &str, + ) -> Result<(), String> { + let Some(mail) = &self.mail else { + return Err(format!( + "Couldn't send welcome mail, as the user {self} has no mail..." + )); + }; + + Mail::send_single( + db, + mail, + "Willkommen im ASKÖ Ruderverein Donau Linz!", + format!( +"Hallo {0}, + +herzlich willkommen im ASKÖ Ruderverein Donau Linz! Wir freuen uns sehr, dich als neues Mitglied in unserem Verein begrüßen zu dürfen. + +Um dir den Einstieg zu erleichtern, findest du in unserem Handbuch alle wichtigen Informationen über unseren Verein: https://rudernlinz.at/book. Bei weiteren Fragen stehen dir die Adressen info@rudernlinz.at (für allgemeine Fragen) und it@rudernlinz.at (bei technischen Fragen) jederzeit zur Verfügung. + +Damit du dich noch mehr verbunden fühlst (:-)), haben wir im Bootshaus ein WLAN für Vereinsmitglieder 'ASKÖ Ruderverein Donau Linz' eingerichtet. Das Passwort dafür lautet 'donau1921' (ohne Anführungszeichen). Bitte gib das Passwort an keine vereinsfremden Personen weiter. + +Riemen- & Dollenbruch +ASKÖ Ruderverein Donau Linz", self.name), + smtp_pw, + ).await?; + + ActivityBuilder::new(&format!( + "{self} hat eine Mail an {mail} bekommen, mit Infos dass er/sie nun ein unterstützendes Mitglied ist (Handbuch, WLAN)." + )) + .relevant_for_user(self) + .save(db) + .await; + + Ok(()) + } + + pub(crate) async fn create( + db: &SqlitePool, + created_by: &ManageUserUser, + smtp_pw: &str, + name: NonEmptyString, + mail: &str, + financial: Option, + birthdate: &NaiveDate, + member_since: &NaiveDate, + phone: NonEmptyString, + address: NonEmptyString, + membership_pdf: &TempFile<'_>, + ) -> Result<(), String> { + let role = Role::find_by_name(db, "Unterstützend").await.unwrap(); + let user = Self::create_member( + db, + created_by, + &role, + name, + mail, + financial, + birthdate, + member_since, + phone, + address, + membership_pdf, + ) + .await?; + + let user = Self::new(db, &user).await.unwrap(); + user.send_welcome_mail_to_user(db, smtp_pw).await?; + + if let Some(vorstand) = Role::find_by_name(db, "Vorstand").await { + Notification::create_for_role( + db, + &vorstand, + &format!("Lieber Vorstand, es gibt ein neues unterstützendes Mitglied: {user}"), + "Neues unterstützendes Vereinsmitglied", + None, + None, + ) + .await; + } + + Ok(()) + } +} diff --git a/src/rest/mod.rs b/src/rest/mod.rs index bf08819..113d560 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -1,4 +1,4 @@ -use rocket::{form::Form, post, routes, Build, FromForm, Rocket, State}; +use rocket::{Build, FromForm, Rocket, State, form::Form, post, routes}; use serde_json::json; use sqlx::SqlitePool; diff --git a/src/scheduled/weather.rs b/src/scheduled/weather.rs index 990764e..3da3f20 100644 --- a/src/scheduled/weather.rs +++ b/src/scheduled/weather.rs @@ -96,7 +96,9 @@ struct DailyWeather { } fn fetch(api_key: &str) -> Result { - let url = format!("https://api.openweathermap.org/data/3.0/onecall?lat=48.31970&lon=14.29451&units=metric&exclude=current,minutely,hourly,alert&appid={api_key}"); + let url = format!( + "https://api.openweathermap.org/data/3.0/onecall?lat=48.31970&lon=14.29451&units=metric&exclude=current,minutely,hourly,alert&appid={api_key}" + ); match ureq::get(&url).call() { Ok(mut response) => { diff --git a/src/tera/admin/boat.rs b/src/tera/admin/boat.rs index 56bf5fd..8defa13 100644 --- a/src/tera/admin/boat.rs +++ b/src/tera/admin/boat.rs @@ -5,13 +5,14 @@ use crate::model::{ user::{User, UserWithDetails, VorstandUser}, }; use rocket::{ + Route, State, form::Form, get, post, request::FlashMessage, response::{Flash, Redirect}, - routes, Route, State, + routes, }; -use rocket_dyn_templates::{tera::Context, Template}; +use rocket_dyn_templates::{Template, tera::Context}; use sqlx::SqlitePool; #[get("/boat")] @@ -245,9 +246,11 @@ mod test { let rocket = rocket::build().manage(db.clone()); let rocket = crate::tera::config(rocket); - assert!(Boat::find_by_name(&db, "completely-new-boat".into()) - .await - .is_none()); + assert!( + Boat::find_by_name(&db, "completely-new-boat".into()) + .await + .is_none() + ); let client = Client::tracked(rocket).await.unwrap(); let login = client diff --git a/src/tera/admin/event.rs b/src/tera/admin/event.rs index 2bccdcf..26e7112 100644 --- a/src/tera/admin/event.rs +++ b/src/tera/admin/event.rs @@ -1,8 +1,9 @@ use rocket::{ + FromForm, Route, State, form::Form, get, post, put, response::{Flash, Redirect}, - routes, FromForm, Route, State, + routes, }; use serde::Serialize; use sqlx::SqlitePool; @@ -32,8 +33,8 @@ async fn create( let trip_details_id = TripDetails::create(db, data.tripdetails).await; let trip_details = TripDetails::find_by_id(db, trip_details_id).await.unwrap(); //Okay, bc. we - //just created - //the object + //just created + //the object Event::create( db, diff --git a/src/tera/admin/mail.rs b/src/tera/admin/mail.rs index 953bf23..e886a2d 100644 --- a/src/tera/admin/mail.rs +++ b/src/tera/admin/mail.rs @@ -1,9 +1,9 @@ use rocket::form::Form; use rocket::fs::TempFile; use rocket::response::{Flash, Redirect}; -use rocket::{get, request::FlashMessage, routes, Route, State}; -use rocket::{post, FromForm}; -use rocket_dyn_templates::{tera::Context, Template}; +use rocket::{FromForm, post}; +use rocket::{Route, State, get, request::FlashMessage, routes}; +use rocket_dyn_templates::{Template, tera::Context}; use sqlx::SqlitePool; use crate::model::log::Log; diff --git a/src/tera/admin/mod.rs b/src/tera/admin/mod.rs index 1690f45..53653ff 100644 --- a/src/tera/admin/mod.rs +++ b/src/tera/admin/mod.rs @@ -1,6 +1,6 @@ use csv::ReaderBuilder; -use rocket::{form::Form, get, post, routes, FromForm, Route, State}; -use rocket_dyn_templates::{context, Template}; +use rocket::{FromForm, Route, State, form::Form, get, post, routes}; +use rocket_dyn_templates::{Template, context}; use sqlx::SqlitePool; use crate::{ @@ -12,6 +12,7 @@ pub mod boat; pub mod event; pub mod mail; pub mod notification; +pub mod role; pub mod schnupper; pub mod user; @@ -81,6 +82,7 @@ pub fn routes() -> Vec { ret.append(&mut notification::routes()); ret.append(&mut mail::routes()); ret.append(&mut event::routes()); + ret.append(&mut role::routes()); ret.append(&mut routes![rss, show_rss, show_list, list]); ret } diff --git a/src/tera/admin/notification.rs b/src/tera/admin/notification.rs index fe6578b..098e171 100644 --- a/src/tera/admin/notification.rs +++ b/src/tera/admin/notification.rs @@ -6,13 +6,14 @@ use crate::model::{ }; use itertools::Itertools; use rocket::{ + FromForm, Route, State, form::Form, get, post, request::FlashMessage, response::{Flash, Redirect}, - routes, FromForm, Route, State, + routes, }; -use rocket_dyn_templates::{tera::Context, Template}; +use rocket_dyn_templates::{Template, tera::Context}; use sqlx::SqlitePool; #[get("/notification")] diff --git a/src/tera/admin/role.rs b/src/tera/admin/role.rs new file mode 100644 index 0000000..faa6393 --- /dev/null +++ b/src/tera/admin/role.rs @@ -0,0 +1,65 @@ +use crate::model::{ + role::Role, + user::{AdminUser, UserWithDetails, VorstandUser}, +}; +use rocket::{ + FromForm, Route, State, + form::Form, + get, post, + request::FlashMessage, + response::{Flash, Redirect}, + routes, +}; +use rocket_dyn_templates::{Template, tera::Context}; +use sqlx::SqlitePool; + +#[get("/role")] +async fn index( + db: &State, + admin: VorstandUser, + flash: Option>, +) -> Template { + let roles = Role::all(db).await; + + let mut context = Context::new(); + if let Some(msg) = flash { + context.insert("flash", &msg.into_inner()); + } + context.insert("roles", &roles); + context.insert( + "loggedin_user", + &UserWithDetails::from_user(admin.user, db).await, + ); + + Template::render("admin/role", context.into_json()) +} +#[derive(FromForm)] +pub struct RoleToUpdate<'r> { + pub formatted_name: &'r str, + pub desc: &'r str, +} + +#[post("/role/", data = "")] +async fn update( + db: &State, + data: Form>, + role_id: i32, + admin: AdminUser, +) -> Flash { + let role = Role::find_by_id(db, role_id).await; + let Some(role) = role else { + return Flash::error(Redirect::to("/admin/role"), "Role does not exist!"); + }; + + match role + .update(db, &admin, &data.formatted_name, &data.desc) + .await + { + Ok(_) => Flash::success(Redirect::to("/admin/role"), "Rolle bearbeitet"), + Err(e) => Flash::error(Redirect::to("/admin/role"), e), + } +} + +pub fn routes() -> Vec { + routes![index, update] +} diff --git a/src/tera/admin/schnupper.rs b/src/tera/admin/schnupper.rs index 9a751e1..a53c155 100644 --- a/src/tera/admin/schnupper.rs +++ b/src/tera/admin/schnupper.rs @@ -3,8 +3,8 @@ use crate::model::{ user::{SchnupperBetreuerUser, User, UserWithDetails}, }; use futures::future::join_all; -use rocket::{get, request::FlashMessage, routes, Route, State}; -use rocket_dyn_templates::{tera::Context, Template}; +use rocket::{Route, State, get, request::FlashMessage, routes}; +use rocket_dyn_templates::{Template, tera::Context}; use sqlx::SqlitePool; #[get("/schnupper")] diff --git a/src/tera/admin/user.rs b/src/tera/admin/user.rs index fe4e607..d023935 100644 --- a/src/tera/admin/user.rs +++ b/src/tera/admin/user.rs @@ -1,21 +1,25 @@ -use std::collections::HashMap; - use crate::{ model::{ + activity::Activity, family::Family, log::Log, logbook::Logbook, + mail::valid_mails, role::Role, user::{ - AdminUser, AllowedToEditPaymentStatusUser, ManageUserUser, SchnupperBetreuerUser, User, - UserWithDetails, UserWithMembershipPdf, UserWithRolesAndMembershipPdf, VorstandUser, + AdminUser, AllowedToEditPaymentStatusUser, ManageUserUser, User, UserWithDetails, + UserWithMembershipPdf, UserWithRolesAndMembershipPdf, VorstandUser, + clubmember::ClubMemberUser, foerdernd::FoerderndUser, member::Member, + regular::RegularUser, scheckbuch::ScheckbuchUser, schnupperant::SchnupperantUser, + schnupperinterest::SchnupperInterestUser, unterstuetzend::UnterstuetzendUser, }, }, tera::Config, }; +use chrono::NaiveDate; use futures::future::join_all; -use lettre::Address; use rocket::{ + FromForm, Request, Route, State, form::Form, fs::TempFile, get, @@ -23,9 +27,9 @@ use rocket::{ post, request::{FlashMessage, FromRequest, Outcome}, response::{Flash, Redirect}, - routes, FromForm, Request, Route, State, + routes, }; -use rocket_dyn_templates::{tera::Context, Template}; +use rocket_dyn_templates::{Template, tera::Context}; use sqlx::SqlitePool; // Custom request guard to extract the Referer header @@ -63,6 +67,7 @@ async fn index( let allowed_to_edit = ManageUserUser::new(db, &user).await.is_some(); let users: Vec = join_all(user_futures).await; + let financial = Role::all_cluster(db, "financial").await; let roles = Role::all(db).await; let families = Family::all_with_members(db).await; @@ -74,6 +79,7 @@ async fn index( context.insert("allowed_to_edit", &allowed_to_edit); context.insert("users", &users); context.insert("roles", &roles); + context.insert("financial", &financial); context.insert("families", &families); context.insert("loggedin_user", &UserWithDetails::from_user(user, db).await); @@ -94,6 +100,7 @@ async fn index_admin( let users: Vec = join_all(user_futures).await; let user: User = user.user; + let financial = Role::all_cluster(db, "financial").await; let allowed_to_edit = ManageUserUser::new(db, &user).await.is_some(); let roles = Role::all(db).await; @@ -106,12 +113,74 @@ async fn index_admin( context.insert("allowed_to_edit", &allowed_to_edit); context.insert("users", &users); context.insert("roles", &roles); + context.insert("financial", &financial); context.insert("families", &families); context.insert("loggedin_user", &UserWithDetails::from_user(user, db).await); Template::render("admin/user/index", context.into_json()) } +#[get("/user/")] +async fn view( + db: &State, + admin: VorstandUser, + flash: Option>, + user: i32, +) -> Result> { + let Some(user) = User::find_by_id(db, user).await else { + return Err(Flash::error( + Redirect::to("/admin/user"), + format!("User mit ID {} gibts ned", user), + )); + }; + if user.name == "Externe Steuerperson" { + return Err(Flash::error( + Redirect::to("/admin/user"), + "Diese besondere Person kannst du dir leider nicht anschauen, mein lieber neugieriger Ruderant!", + )); + } + + let member = Member::from(db, user.clone()).await; + let fee = user.fee(db).await; + let activities = Activity::for_user(db, &user).await; + let financial = Role::all_cluster(db, "financial").await; + let user_financial = user.financial(db).await; + let skill = Role::all_cluster(db, "skill").await; + let user_skill = user.skill(db).await; + + let user = UserWithRolesAndMembershipPdf::from_user(db, user).await; + + let admin: User = admin.into_inner(); + let allowed_to_edit = ManageUserUser::new(db, &admin).await.is_some(); + + let roles = Role::all(db).await; + let families = Family::all_with_members(db).await; + + let mut context = Context::new(); + if let Some(msg) = flash { + context.insert("flash", &msg.into_inner()); + } + context.insert("allowed_to_edit", &allowed_to_edit); + context.insert("user", &user); + context.insert("is_clubmember", &member.is_club_member()); + context.insert("supposed_to_pay", &member.supposed_to_pay()); + context.insert("fee", &fee); + context.insert("skill", &skill); + context.insert("user_skill", &user_skill); + context.insert("financial", &financial); + context.insert("user_financial", &user_financial); + context.insert("member", &member); + context.insert("activities", &activities); + context.insert("roles", &roles); + context.insert("families", &families); + context.insert( + "loggedin_user", + &UserWithDetails::from_user(admin, db).await, + ); + + Ok(Template::render("admin/user/view", context.into_json())) +} + #[get("/user/fees")] async fn fees( db: &State, @@ -184,28 +253,9 @@ async fn fees_paid( let user = User::find_by_id(db, user_id).await.unwrap(); res.push_str(&format!("{} + ", user.name)); if user.has_role(db, "paid").await { - Log::create( - db, - format!( - "{} set fees NOT paid for '{}'", - calling_user.user.name, user.name - ), - ) - .await; - user.remove_role(db, &Role::find_by_name(db, "paid").await.unwrap()) - .await; + user.has_not_paid(db, &calling_user).await; } else { - Log::create( - db, - format!( - "{} set fees paid for '{}'", - calling_user.user.name, user.name - ), - ) - .await; - user.add_role(db, &Role::find_by_name(db, "paid").await.unwrap()) - .await - .expect("paid role has no group"); + user.has_paid(db, &calling_user).await; } } @@ -217,26 +267,6 @@ async fn fees_paid( ) } -#[get("/user//send-welcome-mail")] -async fn send_welcome_mail( - db: &State, - _admin: ManageUserUser, - config: &State, - user: i32, -) -> Flash { - let Some(user) = User::find_by_id(db, user).await else { - return Flash::error(Redirect::to("/admin/user"), "User does not exist"); - }; - - match user.send_welcome_email(db, &config.smtp_pw).await { - Ok(()) => Flash::success( - Redirect::to("/admin/user"), - format!("Willkommens-Email wurde an {} versandt.", user.name), - ), - Err(e) => Flash::error(Redirect::to("/admin/user"), e), - } -} - #[get("/user//reset-pw")] async fn resetpw(db: &State, admin: ManageUserUser, user: i32) -> Flash { let user = User::find_by_id(db, user).await; @@ -263,7 +293,7 @@ async fn delete(db: &State, admin: ManageUserUser, user: i32) -> Fla Log::create(db, format!("{} deleted user: {user:?}", admin.user.name)).await; match user { Some(user) => { - user.delete(db).await; + user.delete(db, &admin).await; Flash::success( Redirect::to("/admin/user"), format!("Benutzer {} gelöscht", user.name), @@ -274,45 +304,432 @@ async fn delete(db: &State, admin: ManageUserUser, user: i32) -> Fla } #[derive(FromForm, Debug)] -pub struct UserEditForm<'a> { - pub(crate) id: i32, - pub(crate) dob: Option, - pub(crate) weight: Option, - pub(crate) sex: Option, - pub(crate) roles: HashMap, - pub(crate) member_since_date: Option, - pub(crate) birthdate: Option, - pub(crate) mail: Option, - pub(crate) nickname: Option, - pub(crate) notes: Option, - pub(crate) phone: Option, - pub(crate) address: Option, - pub(crate) family_id: Option, - pub(crate) membership_pdf: Option>, +pub struct MailUpdateForm { + mail: String, } -#[post("/user", data = "", format = "multipart/form-data")] -async fn update( +#[post("/user//change-mail", data = "")] +async fn update_mail( db: &State, - data: Form>, + data: Form, admin: ManageUserUser, + id: i32, ) -> Flash { - let user = User::find_by_id(db, data.id).await; - Log::create( - db, - format!("{} updated user from {user:?} to {data:?}", admin.user.name), - ) - .await; - let Some(user) = user else { + let Some(user) = User::find_by_id(db, id).await else { return Flash::error( Redirect::to("/admin/user"), - format!("User with ID {} does not exist!", data.id), + format!("User with ID {} does not exist!", id), ); }; - match user.update(db, data.into_inner()).await { - Ok(_) => Flash::success(Redirect::to("/admin/user"), "Successfully updated user"), - Err(e) => Flash::error(Redirect::to("/admin/user"), e), + match user.update_mail(db, &admin, &data.mail).await { + Ok(_) => Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Mailadresse erfolgreich geändert", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", user.id)), e), + } +} + +#[derive(FromForm, Debug)] +pub struct AddNoteForm { + note: String, +} + +#[post("/user//new-note", data = "")] +async fn add_note( + db: &State, + data: Form, + admin: ManageUserUser, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + + match user.add_note(db, &admin, &user, &data.note).await { + Ok(_) => Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Notiz hinzugefügt", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", user.id)), e), + } +} + +#[derive(FromForm, Debug)] +pub struct PhoneUpdateForm { + phone: String, +} + +#[post("/user//change-phone", data = "")] +async fn update_phone( + db: &State, + data: Form, + admin: ManageUserUser, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + + user.update_phone(db, &admin, &data.phone).await; + Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Telefonnummer erfolgreich geändert", + ) +} + +#[derive(FromForm, Debug)] +pub struct AddressUpdateForm { + address: String, +} + +#[post("/user//change-address", data = "")] +async fn update_address( + db: &State, + data: Form, + admin: ManageUserUser, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + + user.update_address(db, &admin, &data.address).await; + + Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Adresse erfolgreich geändert", + ) +} + +#[derive(FromForm, Debug)] +pub struct FamilyUpdateForm { + family_id: Option, +} + +#[post("/user//change-family", data = "")] +async fn update_family( + db: &State, + data: Form, + admin: ManageUserUser, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + + let family = match data.family_id { + Some(-1) => Some( + Family::find_by_id(db, Family::insert(db).await) + .await + .unwrap(), + ), + Some(id) => match Family::find_by_id(db, id).await { + Some(f) => Some(f), + None => { + return Flash::error( + Redirect::to("/admin/user/{id}"), + format!("Family with ID {} does not exist!", id), + ); + } + }, + None => None, + }; + + user.update_family(db, &admin, family).await; + + Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Familie erfolgreich geändert", + ) +} + +#[derive(FromForm, Debug)] +pub struct ChangeSkillForm { + skill_id: String, +} + +#[post("/user//change-skill", data = "")] +async fn change_skill( + db: &State, + data: Form, + admin: ManageUserUser, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + + let skill = if data.skill_id.is_empty() { + None + } else { + let Ok(skill_id) = data.skill_id.parse() else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + "Skill_id is not a number", + ); + }; + Role::find_by_id(db, skill_id).await + }; + + match user.change_skill(db, &admin, skill).await { + Ok(()) => Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Skill erfolgreich geändert", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", user.id)), e), + } +} + +#[derive(FromForm, Debug)] +pub struct ChangeFinancialForm { + financial_id: String, +} + +#[post("/user//change-financial", data = "")] +async fn change_financial( + db: &State, + data: Form, + admin: ManageUserUser, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + + let financial = if data.financial_id.is_empty() { + None + } else { + let Ok(financial_id) = data.financial_id.parse() else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + "Finacial_id is not a number", + ); + }; + Role::find_by_id(db, financial_id).await + }; + + match user.change_financial(db, &admin, financial).await { + Ok(()) => Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Ermäßigung erfolgreich geändert", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", user.id)), e), + } +} + +#[derive(FromForm, Debug)] +pub struct AddMembershipPDFForm<'a> { + membership_pdf: TempFile<'a>, +} + +#[post("/user//add-membership-pdf", data = "")] +async fn add_membership_pdf( + db: &State, + data: Form>, + admin: ManageUserUser, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + + match user + .add_membership_pdf(db, &admin, &data.membership_pdf) + .await + { + Ok(_) => Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Beitrittserklärung erfolgreich hinzugefügt", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", user.id)), e), + } +} + +#[derive(FromForm, Debug)] +pub struct NicknameUpdateForm { + nickname: String, +} + +#[post("/user//change-nickname", data = "")] +async fn update_nickname( + db: &State, + data: Form, + admin: ManageUserUser, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + + match user.update_nickname(db, &admin, &data.nickname).await { + Ok(_) => Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Spitzname erfolgreich geändert", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", user.id)), e), + } +} + +#[derive(FromForm, Debug)] +pub struct MemberSinceUpdateForm { + member_since: String, +} + +#[post("/user//change-member-since", data = "")] +async fn update_member_since( + db: &State, + data: Form, + admin: ManageUserUser, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + let Ok(new_member_since_date) = NaiveDate::parse_from_str(&data.member_since, "%Y-%m-%d") + else { + return Flash::error( + Redirect::to("/admin/user/{id}"), + format!( + "Beitrittsdatum {} ist nicht im YYYY-MM-DD Format", + &data.member_since + ), + ); + }; + + user.update_member_since(db, &admin, &new_member_since_date) + .await; + + Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Beitrittsdatum erfolgreich geändert", + ) +} + +#[derive(FromForm, Debug)] +pub struct BirthdateUpdateForm { + birthdate: String, +} + +#[post("/user//change-birthdate", data = "")] +async fn update_birthdate( + db: &State, + data: Form, + admin: ManageUserUser, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + let Ok(new_birthdate) = NaiveDate::parse_from_str(&data.birthdate, "%Y-%m-%d") else { + return Flash::error( + Redirect::to("/admin/user/{id}"), + format!( + "Geburtsdatum {} ist nicht im YYYY-MM-DD Format", + &data.birthdate + ), + ); + }; + + user.update_birthdate(db, &admin, &new_birthdate).await; + + Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Geburtstag erfolgreich geändert", + ) +} + +#[derive(FromForm, Debug)] +pub struct AddRoleForm { + role_id: i32, +} + +#[post("/user//add-role", data = "")] +async fn add_role( + db: &State, + data: Form, + admin: ManageUserUser, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + let Some(role) = Role::find_by_id(db, data.role_id).await else { + return Flash::error( + Redirect::to("/admin/user/{user_id}"), + format!("Role with ID {} does not exist!", data.role_id), + ); + }; + + match user.add_role(db, &admin, &role).await { + Ok(_) => Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Rolle erfolgreich hinzugefügt", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", user.id)), e), + } +} + +#[get("/user//remove-role/")] +async fn remove_role( + db: &State, + admin: ManageUserUser, + user_id: i32, + role_id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, user_id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", user_id), + ); + }; + let Some(role) = Role::find_by_id(db, role_id).await else { + return Flash::error( + Redirect::to("/admin/user/{user_id}"), + format!("Role with ID {} does not exist!", role_id), + ); + }; + + match user.remove_role(db, &admin, &role).await { + Ok(_) => Flash::success( + Redirect::to(format!("/admin/user/{}", user.id)), + "Rolle erfolgreich gelöscht", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", user.id)), e), } } @@ -336,149 +753,716 @@ async fn download_membership_pdf( (ContentType::PDF, user.membership_pdf.unwrap()) } +//#[derive(FromForm, Debug)] +//struct UserAddScheckbuchForm<'r> { +// name: &'r str, +// mail: &'r str, +//} + +//#[post("/user/new/scheckbuch", data = "")] +//async fn create_scheckbuch( +// db: &State, +// data: Form>, +// admin: VorstandUser, +// config: &State, +//) -> Flash { +// // 1. Check mail adress +// let mail = data.mail.trim(); +// if mail.parse::
().is_err() { +// return Flash::error( +// Redirect::to("/admin/user/scheckbuch"), +// "Keine gültige Mailadresse".to_string(), +// ); +// } +// +// // 2. Check name +// let name = data.name.trim(); +// if User::find_by_name(db, name).await.is_some() { +// return Flash::error( +// Redirect::to("/admin/user/scheckbuch"), +// "Kann kein Scheckbuch erstellen, der Name wird bereits von einem User verwendet" +// .to_string(), +// ); +// } +// +// // 3. Create user +// User::create_with_mail(db, name, mail).await; +// let user = User::find_by_name(db, name).await.unwrap(); +// +// // 4. Add 'scheckbuch' role +// let scheckbuch = Role::find_by_name(db, "scheckbuch").await.unwrap(); +// user.add_role(db, &scheckbuch) +// .await +// .expect("new user has no roles yet"); +// +// // 4. Send welcome mail (+ notification) +// user.send_welcome_email(db, &config.smtp_pw).await.unwrap(); +// +// Log::create( +// db, +// format!("{} created new scheckbuch: {data:?}", admin.name), +// ) +// .await; +// Flash::success(Redirect::to("/admin/user/scheckbuch"), format!("Scheckbuch erfolgreich erstellt. Eine E-Mail in der alles erklärt wird, wurde an {mail} verschickt.")) +//} + #[derive(FromForm, Debug)] -struct UserAddForm<'r> { - name: &'r str, +pub struct SchnupperantToRegularForm<'a> { + membertype: String, + member_since: String, + birthdate: String, + phone: String, + address: String, + membership_pdf: TempFile<'a>, } -#[post("/user/new", data = "")] -async fn create( +#[post("/user//schnupperant-to-regular", data = "")] +async fn schnupperant_to_regular( db: &State, - data: Form>, + data: Form>, admin: ManageUserUser, -) -> Flash { - if User::create(db, data.name).await { - Log::create( - db, - format!("{} created new user: {data:?}", admin.user.name), - ) - .await; - Flash::success(Redirect::to("/admin/user"), "Successfully created user") - } else { - Flash::error( - Redirect::to("/admin/user"), - format!("User {} already exists", data.name), - ) - } -} - -#[derive(FromForm, Debug)] -struct UserAddScheckbuchForm<'r> { - name: &'r str, - mail: &'r str, -} - -#[post("/user/new/scheckbuch", data = "")] -async fn create_scheckbuch( - db: &State, - data: Form>, - admin: VorstandUser, config: &State, -) -> Flash { - // 1. Check mail adress - let mail = data.mail.trim(); - if mail.parse::
().is_err() { - return Flash::error( - Redirect::to("/admin/user/scheckbuch"), - "Keine gültige Mailadresse".to_string(), - ); - } - - // 2. Check name - let name = data.name.trim(); - if User::find_by_name(db, name).await.is_some() { - return Flash::error( - Redirect::to("/admin/user/scheckbuch"), - "Kann kein Scheckbuch erstellen, der Name wird bereits von einem User verwendet" - .to_string(), - ); - } - - // 3. Create user - User::create_with_mail(db, name, mail).await; - let user = User::find_by_name(db, name).await.unwrap(); - - // 4. Add 'scheckbuch' role - let scheckbuch = Role::find_by_name(db, "scheckbuch").await.unwrap(); - user.add_role(db, &scheckbuch) - .await - .expect("new user has no roles yet"); - - // 4. Send welcome mail (+ notification) - user.send_welcome_email(db, &config.smtp_pw).await.unwrap(); - - Log::create( - db, - format!("{} created new scheckbuch: {data:?}", admin.name), - ) - .await; - Flash::success(Redirect::to("/admin/user/scheckbuch"), format!("Scheckbuch erfolgreich erstellt. Eine E-Mail in der alles erklärt wird, wurde an {mail} verschickt.")) -} - -#[get("/user/move/schnupperant//to/scheckbuch")] -async fn schnupper_to_scheckbuch( - db: &State, id: i32, - admin: SchnupperBetreuerUser, - config: &State, ) -> Flash { let Some(user) = User::find_by_id(db, id).await else { return Flash::error( - Redirect::to("/admin/schnupper"), - "user id not found".to_string(), + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + let Ok(birthdate) = NaiveDate::parse_from_str(&data.birthdate, "%Y-%m-%d") else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + format!( + "Geburtsdatum {} ist nicht im YYYY-MM-DD Format", + &data.birthdate + ), + ); + }; + let Ok(member_since) = NaiveDate::parse_from_str(&data.member_since, "%Y-%m-%d") else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + format!( + "Beitrittsdatum {} ist nicht im YYYY-MM-DD Format", + &data.birthdate + ), ); }; - if !user.has_role(db, "schnupperant").await { + let Some(user) = SchnupperantUser::new(db, &user).await else { return Flash::error( - Redirect::to("/admin/schnupper"), - "kein schnupperant...".to_string(), + Redirect::to(format!("/admin/user/{id}")), + "User ist kein Schnupperant", + ); + }; + + let Ok(phone) = data.phone.clone().try_into() else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + "Vereinsmitglied braucht eine Telefonnummer", + ); + }; + let Ok(address) = data.address.clone().try_into() else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + "Vereinsmitglied braucht eine Adresse", + ); + }; + let response = match &*data.membertype { + "regular" => { + user.convert_to_regular_user( + db, + &config.smtp_pw, + &admin, + &member_since, + &birthdate, + phone, + address, + &data.membership_pdf, + ) + .await + } + "unterstuetzend" => { + user.convert_to_unterstuetzend_user( + db, + &config.smtp_pw, + &admin, + &member_since, + &birthdate, + phone, + address, + &data.membership_pdf, + ) + .await + } + "foerdernd" => { + user.convert_to_foerdernd_user( + db, + &config.smtp_pw, + &admin, + &member_since, + &birthdate, + phone, + address, + &data.membership_pdf, + ) + .await + } + _ => { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + "Membertype gibts ned", + ); + } + }; + + match response { + Ok(_) => Flash::success( + Redirect::to(format!("/admin/user/{}", id)), + "Mitgliedstyp umgewandelt und Infos versendet", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", id)), e), + } +} + +#[derive(FromForm, Debug)] +pub struct ScheckToRegularForm<'a> { + membertype: String, + member_since: String, + birthdate: String, + phone: String, + address: String, + membership_pdf: TempFile<'a>, +} + +#[post("/user//scheckbook-to-regular", data = "")] +async fn scheckbook_to_regular( + db: &State, + data: Form>, + admin: ManageUserUser, + config: &State, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + let Ok(birthdate) = NaiveDate::parse_from_str(&data.birthdate, "%Y-%m-%d") else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + format!( + "Geburtsdatum {} ist nicht im YYYY-MM-DD Format", + &data.birthdate + ), + ); + }; + let Ok(member_since) = NaiveDate::parse_from_str(&data.member_since, "%Y-%m-%d") else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + format!( + "Beitrittsdatum {} ist nicht im YYYY-MM-DD Format", + &data.birthdate + ), + ); + }; + + let Some(user) = ScheckbuchUser::new(db, &user).await else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + "User ist kein Scheckbuchuser", + ); + }; + + let Ok(phone) = data.phone.clone().try_into() else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + "Vereinsmitglied braucht eine Telefonnummer", + ); + }; + let Ok(address) = data.address.clone().try_into() else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + "Vereinsmitglied braucht eine Adresse", + ); + }; + let response = match &*data.membertype { + "regular" => { + user.convert_to_regular_user( + db, + &config.smtp_pw, + &admin, + &member_since, + &birthdate, + phone, + address, + &data.membership_pdf, + ) + .await + } + "unterstuetzend" => { + user.convert_to_unterstuetzend_user( + db, + &config.smtp_pw, + &admin, + &member_since, + &birthdate, + phone, + address, + &data.membership_pdf, + ) + .await + } + "foerdernd" => { + user.convert_to_foerdernd_user( + db, + &config.smtp_pw, + &admin, + &member_since, + &birthdate, + phone, + address, + &data.membership_pdf, + ) + .await + } + _ => { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + "Membertype gibts ned", + ); + } + }; + + match response { + Ok(_) => Flash::success( + Redirect::to(format!("/admin/user/{}", id)), + "Mitgliedstyp umgewandelt und Infos versendet", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", id)), e), + } +} + +#[derive(FromForm, Debug)] +pub struct ChangeMembertypeForm { + membertype: String, +} + +#[post("/user//change-membertype", data = "")] +async fn change_membertype( + db: &State, + admin: ManageUserUser, + data: Form, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + + let Some(user) = ClubMemberUser::new(db, &user).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User {user} ist kein Vereinsmitglied"), + ); + }; + + let response = match &*data.membertype { + "regular" => user.move_to_regular(db, &admin).await, + "unterstuetzend" => user.move_to_unterstuetzend(db, &admin).await, + "foerdernd" => user.move_to_foerdernd(db, &admin).await, + _ => { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + "Membertype gibt's ned", + ); + } + }; + + match response { + Ok(_) => Flash::success( + Redirect::to(format!("/admin/user/{}", id)), + "Mitgliedstyp umgewandelt und Infos versendet", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", id)), e), + } +} + +#[get("/user//schnupperant-to-scheckbuch")] +async fn schnupperant_to_scheckbook( + db: &State, + admin: ManageUserUser, + config: &State, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + + let Some(user) = SchnupperantUser::new(db, &user).await else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + format!("User {user} ist kein Schnupperant"), + ); + }; + + match user.move_to_scheckbook(db, &admin, &config.smtp_pw).await { + Ok(_) => Flash::success( + Redirect::to(format!("/admin/user/{}", id)), + "Mitgliedstyp umgewandelt und Infos versendet", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", id)), e), + } +} + +#[get("/user//schnupperinterest-to-schnupperant")] +async fn schnupperinterest_to_schnupperant( + db: &State, + admin: ManageUserUser, + config: &State, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + + let Some(user) = SchnupperInterestUser::new(db, &user).await else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + format!("User {user} ist kein Schnupperinteressierter"), + ); + }; + + match user.move_to_schnupperant(db, &admin, &config.smtp_pw).await { + Ok(_) => Flash::success( + Redirect::to(format!("/admin/user/{}", id)), + "Mitgliedstyp umgewandelt und Infos versendet", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", id)), e), + } +} + +#[get("/user//schnupperant-to-schnupperinterest")] +async fn schnupperant_to_schnupperinterest( + db: &State, + admin: ManageUserUser, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + + let Some(user) = SchnupperantUser::new(db, &user).await else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + format!("User {user} ist kein Schnupperant"), + ); + }; + + match user.move_to_schnupperinterest(db, &admin).await { + Ok(_) => Flash::success( + Redirect::to(format!("/admin/user/{}", id)), + "Mitgliedstyp umgewandelt.", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", id)), e), + } +} +#[get("/user//schnupperinterest-to-scheckbuch")] +async fn schnupperinterest_to_scheckbuch( + db: &State, + admin: ManageUserUser, + config: &State, + id: i32, +) -> Flash { + let Some(user) = User::find_by_id(db, id).await else { + return Flash::error( + Redirect::to("/admin/user"), + format!("User with ID {} does not exist!", id), + ); + }; + + let Some(user) = SchnupperInterestUser::new(db, &user).await else { + return Flash::error( + Redirect::to(format!("/admin/user/{id}")), + format!("User {user} ist kein Schnupperinteressierter"), + ); + }; + + match user.move_to_scheckbook(db, &admin, &config.smtp_pw).await { + Ok(_) => Flash::success( + Redirect::to(format!("/admin/user/{}", id)), + "Mitgliedstyp umgewandelt und Infos versendet", + ), + Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", id)), e), + } +} + +#[derive(FromForm, Debug)] +pub struct AddClubMemberForm<'a> { + name: String, + mail: String, + financial_id: String, + membertype: String, + member_since: String, + birthdate: String, + phone: String, + address: String, + membership_pdf: TempFile<'a>, +} + +#[post("/user/new/clubmember", data = "")] +async fn add_club_member( + db: &State, + data: Form>, + admin: ManageUserUser, + config: &State, +) -> Flash { + if !valid_mails(&data.mail) { + return Flash::error( + Redirect::to("/admin/user"), + format!( + "{} ist kein gültiges Format für eine Mailadresse", + &data.mail + ), ); } - let schnupperant = Role::find_by_name(db, "schnupperant").await.unwrap(); - let paid = Role::find_by_name(db, "paid").await.unwrap(); - user.remove_role(db, &schnupperant).await; - user.remove_role(db, &paid).await; + let financial = if data.financial_id.is_empty() { + None + } else { + let Ok(financial_id) = data.financial_id.parse() else { + return Flash::error(Redirect::to("/admin/user"), "Finacial_id is not a number"); + }; + Role::find_by_id(db, financial_id).await + }; - let scheckbuch = Role::find_by_name(db, "scheckbuch").await.unwrap(); - user.add_role(db, &scheckbuch) - .await - .expect("just removed 'schnupperant' thus can't have a role with that group"); + let Ok(birthdate) = NaiveDate::parse_from_str(&data.birthdate, "%Y-%m-%d") else { + return Flash::error( + Redirect::to("/admin/user/"), + format!( + "Geburtsdatum {} ist nicht im YYYY-MM-DD Format", + &data.birthdate + ), + ); + }; + let Ok(member_since) = NaiveDate::parse_from_str(&data.member_since, "%Y-%m-%d") else { + return Flash::error( + Redirect::to("/admin/user"), + format!( + "Beitrittsdatum {} ist nicht im YYYY-MM-DD Format", + &data.birthdate + ), + ); + }; - if let Some(no_einschreibgebuehr) = Role::find_by_name(db, "no-einschreibgebuehr").await { - user.add_role(db, &no_einschreibgebuehr) + let Ok(phone) = data.phone.clone().try_into() else { + return Flash::error( + Redirect::to("/admin/user"), + "Vereinsmitglied braucht eine Telefonnummer", + ); + }; + let Ok(address) = data.address.clone().try_into() else { + return Flash::error( + Redirect::to("/admin/user"), + "Vereinsmitglied braucht eine Adresse", + ); + }; + let Ok(name) = data.name.clone().try_into() else { + return Flash::error( + Redirect::to("/admin/user"), + "Vereinsmitglied braucht einen Namen", + ); + }; + let response = match &*data.membertype { + "regular" => { + RegularUser::create( + db, + &admin, + &config.smtp_pw, + name, + &data.mail, + financial, + &birthdate, + &member_since, + phone, + address, + &data.membership_pdf, + ) .await - .expect("role doesn't have a group"); + } + "unterstuetzend" => { + UnterstuetzendUser::create( + db, + &admin, + &config.smtp_pw, + name, + &data.mail, + financial, + &birthdate, + &member_since, + phone, + address, + &data.membership_pdf, + ) + .await + } + "foerdernd" => { + FoerderndUser::create( + db, + &admin, + &config.smtp_pw, + name, + &data.mail, + financial, + &birthdate, + &member_since, + phone, + address, + &data.membership_pdf, + ) + .await + } + _ => return Flash::error(Redirect::to("/admin/user"), "Membertype gibts ned"), + }; + + match response { + Ok(_) => Flash::success(Redirect::to("/admin/user"), "Mitglied erfolgreich erstellt"), + Err(e) => Flash::error(Redirect::to("/admin/user"), e), + } +} + +#[derive(FromForm, Debug)] +pub struct AddScheckbuchForm { + name: String, + mail: String, +} + +#[post("/user/new/scheckbuch", data = "")] +async fn add_scheckbuch( + db: &State, + data: Form, + admin: ManageUserUser, + config: &State, +) -> Flash { + if !valid_mails(&data.mail) { + return Flash::error( + Redirect::to("/admin/user"), + format!( + "{} ist kein gültiges Format für eine Mailadresse", + &data.mail + ), + ); } - user.send_welcome_email(db, &config.smtp_pw).await.unwrap(); - - Log::create( - db, - format!( - "{} created new scheckbuch (from schnupperant): {}", - admin.name, user.name + let Ok(name) = data.name.clone().try_into() else { + return Flash::error( + Redirect::to("/admin/user"), + "Scheckbuch braucht einen Namen", + ); + }; + match ScheckbuchUser::create(db, &admin, &config.smtp_pw, name, &data.mail).await { + Ok(_) => Flash::success( + Redirect::to("/admin/user"), + "Scheckbuch erfolgreich erstellt", ), - ) - .await; - Flash::success(Redirect::to("/admin/schnupper"), format!("Scheckbuch erfolgreich erstellt. Eine E-Mail in der alles erklärt wird, wurde an {} verschickt.", user.mail.unwrap())) + Err(e) => Flash::error(Redirect::to("/admin/user"), e), + } +} + +#[derive(FromForm, Debug)] +pub struct AddSchnupperForm { + name: String, + mail: String, + schnupper_type: String, +} + +#[post("/user/new/schnupper", data = "")] +async fn add_schnupper( + db: &State, + data: Form, + admin: ManageUserUser, + config: &State, +) -> Flash { + if !valid_mails(&data.mail) { + return Flash::error( + Redirect::to("/admin/user"), + format!( + "{} ist kein gültiges Format für eine Mailadresse", + &data.mail + ), + ); + } + + let Ok(name) = data.name.clone().try_into() else { + return Flash::error( + Redirect::to("/admin/user"), + "Schnupperer braucht einen Namen", + ); + }; + let response = match &*data.schnupper_type { + "schnupperInterested" => SchnupperInterestUser::create(db, &admin, name, &data.mail).await, + "schnupperant" => { + SchnupperantUser::create(db, &admin, &config.smtp_pw, name, &data.mail).await + } + _ => return Flash::error(Redirect::to("/admin/user"), "Schnuppertyp gibts ned"), + }; + match response { + Ok(_) => Flash::success( + Redirect::to("/admin/user"), + "Schnupperer erfolgreich erstellt", + ), + Err(e) => Flash::error(Redirect::to("/admin/user"), e), + } } pub fn routes() -> Vec { routes![ index, index_admin, + view, resetpw, - update, - create, - create_scheckbuch, - schnupper_to_scheckbuch, delete, fees, fees_paid, scheckbuch, download_membership_pdf, - send_welcome_mail + // Updates + update_mail, + update_phone, + update_nickname, + update_member_since, + update_birthdate, + update_address, + update_family, + change_skill, + change_financial, + add_membership_pdf, + add_role, + add_note, + remove_role, + // Moves + scheckbook_to_regular, + schnupperant_to_regular, + schnupperant_to_scheckbook, + schnupperinterest_to_schnupperant, + schnupperant_to_schnupperinterest, + schnupperinterest_to_scheckbuch, + change_membertype, + // Add + add_club_member, + add_scheckbuch, + add_schnupper, ] } diff --git a/src/tera/auth.rs b/src/tera/auth.rs index 511c6b9..9440942 100644 --- a/src/tera/auth.rs +++ b/src/tera/auth.rs @@ -1,4 +1,5 @@ use rocket::{ + FromForm, Request, Route, State, form::Form, get, http::{Cookie, CookieJar}, @@ -8,12 +9,12 @@ use rocket::{ response::{Flash, Redirect}, routes, time::{Duration, OffsetDateTime}, - FromForm, Request, Route, State, }; -use rocket_dyn_templates::{context, tera, Template}; +use rocket_dyn_templates::{Template, context, tera}; use sqlx::SqlitePool; use crate::model::{ + activity::ActivityBuilder, log::Log, user::{LoginError, User}, }; @@ -73,19 +74,21 @@ async fn login( ); } Err(_) => { - return Flash::error(Redirect::to("/auth"), "Falscher Benutzername/Passwort. Du bist Vereinsmitglied und der Login klappt nicht? Kontaktiere unseren Schriftführer oder schreibe eine Mail an info@rudernlinz.at!"); + return Flash::error( + Redirect::to("/auth"), + "Falscher Benutzername/Passwort. Du bist Vereinsmitglied und der Login klappt nicht? Kontaktiere unseren Schriftführer oder schreibe eine Mail an info@rudernlinz.at!", + ); } }; cookies.add_private(Cookie::new("loggedin_user", format!("{}", user.id))); - Log::create( - db, - format!( - "Succ login of {} with this useragent: {}", - login.name, agent.0 - ), - ) + ActivityBuilder::new(&format!( + "{user} hat sich eingeloggt (User-Agent: {})", + agent.0 + )) + .relevant_for_user(&user) + .save(db) .await; // Check for redirect_url cookie and redirect accordingly diff --git a/src/tera/board/achievement.rs b/src/tera/board/achievement.rs index 750ab25..9aa3f83 100644 --- a/src/tera/board/achievement.rs +++ b/src/tera/board/achievement.rs @@ -3,8 +3,8 @@ use crate::model::{ role::Role, user::{User, UserWithDetails, VorstandUser}, }; -use rocket::{get, request::FlashMessage, routes, Route, State}; -use rocket_dyn_templates::{tera::Context, Template}; +use rocket::{Route, State, get, request::FlashMessage, routes}; +use rocket_dyn_templates::{Template, tera::Context}; use sqlx::SqlitePool; #[get("/achievement")] diff --git a/src/tera/board/boathouse.rs b/src/tera/board/boathouse.rs index 1c10408..7d31f95 100644 --- a/src/tera/board/boathouse.rs +++ b/src/tera/board/boathouse.rs @@ -4,13 +4,14 @@ use crate::model::{ user::{AdminUser, UserWithDetails, VorstandUser}, }; use rocket::{ + FromForm, Route, State, form::Form, get, post, request::FlashMessage, response::{Flash, Redirect}, - routes, FromForm, Route, State, + routes, }; -use rocket_dyn_templates::{tera::Context, Template}; +use rocket_dyn_templates::{Template, tera::Context}; use sqlx::SqlitePool; #[get("/boathouse")] diff --git a/src/tera/boatdamage.rs b/src/tera/boatdamage.rs index 2e345ac..2fcc368 100644 --- a/src/tera/boatdamage.rs +++ b/src/tera/boatdamage.rs @@ -1,9 +1,10 @@ use rocket::{ + FromForm, Route, State, form::Form, get, post, request::FlashMessage, response::{Flash, Redirect}, - routes, FromForm, Route, State, + routes, }; use rocket_dyn_templates::Template; use sqlx::SqlitePool; diff --git a/src/tera/boatreservation.rs b/src/tera/boatreservation.rs index a739dfc..b8a8fe7 100644 --- a/src/tera/boatreservation.rs +++ b/src/tera/boatreservation.rs @@ -1,10 +1,11 @@ use chrono::NaiveDate; use rocket::{ + FromForm, Route, State, form::Form, get, post, request::FlashMessage, response::{Flash, Redirect}, - routes, FromForm, Route, State, + routes, }; use rocket_dyn_templates::Template; use sqlx::SqlitePool; diff --git a/src/tera/cox.rs b/src/tera/cox.rs index 31c791c..84ca58a 100644 --- a/src/tera/cox.rs +++ b/src/tera/cox.rs @@ -1,8 +1,9 @@ use rocket::{ + FromForm, Route, State, form::Form, get, post, response::{Flash, Redirect}, - routes, FromForm, Route, State, + routes, }; use sqlx::SqlitePool; @@ -22,7 +23,7 @@ async fn create_ergo( ) -> Flash { let trip_details_id = TripDetails::create(db, data.into_inner()).await; let trip_details = TripDetails::find_by_id(db, trip_details_id).await.unwrap(); //Okay, bc just - //created + //created Trip::new_own_ergo(db, &cox, trip_details).await; //TODO: fix //Log::create( @@ -45,7 +46,7 @@ async fn create( ) -> Flash { let trip_details_id = TripDetails::create(db, data.into_inner()).await; let trip_details = TripDetails::find_by_id(db, trip_details_id).await.unwrap(); //Okay, bc just - //created + //created Trip::new_own(db, &cox, trip_details).await; //TODO: fix //Log::create( @@ -137,9 +138,10 @@ async fn join(db: &State, planned_event_id: i64, cox: SteeringUser) .await; Flash::success(Redirect::to("/planned"), "Danke für's helfen!") } - Err(CoxHelpError::CanceledEvent) => { - Flash::error(Redirect::to("/planned"), "Die Ausfahrt wurde leider abgesagt...") - } + Err(CoxHelpError::CanceledEvent) => Flash::error( + Redirect::to("/planned"), + "Die Ausfahrt wurde leider abgesagt...", + ), Err(CoxHelpError::AlreadyRegisteredAsCox) => { Flash::error(Redirect::to("/planned"), "Du hilfst bereits aus!") } @@ -147,9 +149,10 @@ async fn join(db: &State, planned_event_id: i64, cox: SteeringUser) Redirect::to("/planned"), "Du hast dich bereits als Ruderer angemeldet!", ), - Err(CoxHelpError::DetailsLocked) => { - Flash::error(Redirect::to("/planned"), "Die Bootseinteilung wurde bereits gemacht. Wenn du noch steuern möchtest, frag bitte bei einer bereits angemeldeten Steuerperson nach, ob das noch möglich ist.") - } + Err(CoxHelpError::DetailsLocked) => Flash::error( + Redirect::to("/planned"), + "Die Bootseinteilung wurde bereits gemacht. Wenn du noch steuern möchtest, frag bitte bei einer bereits angemeldeten Steuerperson nach, ob das noch möglich ist.", + ), } } else { Flash::error(Redirect::to("/planned"), "Event gibt's nicht") @@ -197,9 +200,10 @@ async fn remove( Flash::success(Redirect::to("/planned"), "Erfolgreich abgemeldet!") } - Err(TripHelpDeleteError::DetailsLocked) => { - Flash::error(Redirect::to("/planned"), "Die Bootseinteilung wurde bereits gemacht. Wenn du doch nicht steuern kannst, melde dich bitte unbedingt schnellstmöglich bei einer anderen Steuerperson!") - } + Err(TripHelpDeleteError::DetailsLocked) => Flash::error( + Redirect::to("/planned"), + "Die Bootseinteilung wurde bereits gemacht. Wenn du doch nicht steuern kannst, melde dich bitte unbedingt schnellstmöglich bei einer anderen Steuerperson!", + ), Err(TripHelpDeleteError::CoxNotHelping) => { Flash::error(Redirect::to("/planned"), "Steuermann hilft nicht aus...") } diff --git a/src/tera/ergo.rs b/src/tera/ergo.rs index 31b3d0d..5d007d1 100644 --- a/src/tera/ergo.rs +++ b/src/tera/ergo.rs @@ -1,7 +1,8 @@ use std::env; -use chrono::{Datelike, Utc}; +use chrono::Utc; use rocket::{ + FromForm, Route, State, form::Form, fs::TempFile, get, @@ -9,9 +10,9 @@ use rocket::{ post, request::FlashMessage, response::{Flash, Redirect}, - routes, FromForm, Route, State, + routes, }; -use rocket_dyn_templates::{context, Template}; +use rocket_dyn_templates::{Template, context}; use serde::Serialize; use sqlx::SqlitePool; use tera::Context; @@ -145,47 +146,47 @@ pub struct UserAdd { sex: String, } -#[post("/set-data", data = "")] -async fn new_user(db: &State, data: Form, user: User) -> Flash { - if user.has_role(db, "ergo").await { - return Flash::error(Redirect::to("/ergo"), "Du hast deine Daten schon eingegeben. Wenn du sie updaten willst, melde dich bitte bei it@rudernlinz.at"); - } - - // check data - if data.birthyear < 1900 || data.birthyear > chrono::Utc::now().year() - 5 { - return Flash::error(Redirect::to("/ergo"), "Bitte überprüfe dein Geburtsjahr..."); - } - if data.weight < 20 || data.weight > 200 { - return Flash::error(Redirect::to("/ergo"), "Bitte überprüfe dein Gewicht..."); - } - if &data.sex != "f" && &data.sex != "m" { - return Flash::error(Redirect::to("/ergo"), "Bitte überprüfe dein Geschlecht..."); - } - - // set data - user.update_ergo(db, data.birthyear, data.weight, &data.sex) - .await; - - // inform all other `ergo` users - let ergo = Role::find_by_name(db, "ergo").await.unwrap(); - Notification::create_for_role( - db, - &ergo, - &format!("{} nimmt heuer an der Ergochallenge teil 💪", user.name), - "Ergo Challenge", - None, - None, - ) - .await; - - // add to `ergo` group - user.add_role(db, &ergo).await.unwrap(); - - Flash::success( - Redirect::to("/ergo"), - "Du hast deine Daten erfolgreich eingegeben. Viel Spaß beim Schwitzen :-)", - ) -} +//#[post("/set-data", data = "")] +//async fn new_user(db: &State, data: Form, user: User) -> Flash { +// if user.has_role(db, "ergo").await { +// return Flash::error(Redirect::to("/ergo"), "Du hast deine Daten schon eingegeben. Wenn du sie updaten willst, melde dich bitte bei it@rudernlinz.at"); +// } +// +// // check data +// if data.birthyear < 1900 || data.birthyear > chrono::Utc::now().year() - 5 { +// return Flash::error(Redirect::to("/ergo"), "Bitte überprüfe dein Geburtsjahr..."); +// } +// if data.weight < 20 || data.weight > 200 { +// return Flash::error(Redirect::to("/ergo"), "Bitte überprüfe dein Gewicht..."); +// } +// if &data.sex != "f" && &data.sex != "m" { +// return Flash::error(Redirect::to("/ergo"), "Bitte überprüfe dein Geschlecht..."); +// } +// +// // set data +// user.update_ergo(db, data.birthyear, data.weight, &data.sex) +// .await; +// +// // inform all other `ergo` users +// let ergo = Role::find_by_name(db, "ergo").await.unwrap(); +// Notification::create_for_role( +// db, +// &ergo, +// &format!("{} nimmt heuer an der Ergochallenge teil 💪", user.name), +// "Ergo Challenge", +// None, +// None, +// ) +// .await; +// +// // add to `ergo` group +// user.add_role(db, &ergo).await.unwrap(); +// +// Flash::success( +// Redirect::to("/ergo"), +// "Du hast deine Daten erfolgreich eingegeben. Viel Spaß beim Schwitzen :-)", +// ) +//} #[derive(FromForm, Debug)] pub struct ErgoToAdd<'a> { @@ -358,7 +359,10 @@ async fn new_dozen( } pub fn routes() -> Vec { - routes![index, new_thirty, new_dozen, send, reset, update, new_user] + routes![ + index, new_thirty, new_dozen, send, reset, update, + // new_user + ] } #[cfg(test)] diff --git a/src/tera/log.rs b/src/tera/log.rs index 1f33e3a..77225e3 100644 --- a/src/tera/log.rs +++ b/src/tera/log.rs @@ -1,6 +1,7 @@ use std::net::IpAddr; use rocket::{ + Request, Route, State, form::Form, get, http::{Cookie, CookieJar}, @@ -9,9 +10,8 @@ use rocket::{ response::{Flash, Redirect}, routes, time::{Duration, OffsetDateTime}, - Request, Route, State, }; -use rocket_dyn_templates::{context, Template}; +use rocket_dyn_templates::{Template, context}; use sqlx::SqlitePool; use tera::Context; @@ -110,10 +110,13 @@ async fn index( #[get("/show", rank = 3)] async fn show(db: &State, user: DonauLinzUser) -> Template { let logs = Logbook::completed(db).await; + let boats = Boat::all(db).await; + let users = User::all(db).await; + let logtypes = LogType::all(db).await; Template::render( "log.completed", - context!(logs, loggedin_user: &UserWithDetails::from_user(user.into_inner(), db).await), + context!(logs, boats, users, logtypes, loggedin_user: &UserWithDetails::from_user(user.into_inner(), db).await), ) } @@ -215,31 +218,77 @@ async fn create_logbook( user: &DonauLinzUser, smtp_pw: &str, ) -> Flash { - match Logbook::create( - db, - data.into_inner(), - user, smtp_pw - ) - .await - { - Ok(msg) => Flash::success(Redirect::to("/log"), format!("Ausfahrt erfolgreich hinzugefügt{msg}")), - Err(LogbookCreateError::BoatAlreadyOnWater) => Flash::error(Redirect::to("/log"), "Boot schon am Wasser"), - Err(LogbookCreateError::RowerAlreadyOnWater(rower)) => Flash::error(Redirect::to("/log"), format!("Ruderer {} schon am Wasser", rower.name)), - Err(LogbookCreateError::BoatLocked) => Flash::error(Redirect::to("/log"),"Boot gesperrt"), - Err(LogbookCreateError::BoatNotFound) => Flash::error(Redirect::to("/log"), "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)")), - Err(LogbookCreateError::RowerCreateError(rower, e)) => Flash::error(Redirect::to("/log"), format!("Fehler bei Ruderer {rower}: {e}")), - Err(LogbookCreateError::ArrivalNotAfterDeparture) => Flash::error(Redirect::to("/log"), "Ankunftszeit kann nicht vor der Abfahrtszeit sein"), - Err(LogbookCreateError::UserNotAllowedToUseBoat) => Flash::error(Redirect::to("/log"), "Schiffsführer darf dieses Boot nicht verwenden"), - Err(LogbookCreateError::SteeringPersonNotInRowers) => Flash::error(Redirect::to("/log"), "Steuerperson nicht in Liste der Ruderer!"), - Err(LogbookCreateError::ShipmasterNotInRowers) => Flash::error(Redirect::to("/log"), "Schiffsführer nicht in Liste der Ruderer!"), - Err(LogbookCreateError::NotYourEntry) => Flash::error(Redirect::to("/log"), "Nicht deine Ausfahrt!"), - Err(LogbookCreateError::ArrivalSetButNotRemainingTwo) => Flash::error(Redirect::to("/log"), "Ankunftszeit gesetzt aber nicht Distanz + Strecke"), - Err(LogbookCreateError::OnlyAllowedToEndTripsEndingToday) => Flash::error(Redirect::to("/log"), "Nur Ausfahrten, die in der letzten Woche enden dürfen eingetragen werden. Für einen Nachtrag schreibe alle Daten an den Vorstand (info@rudernlinz.at)."), - Err(LogbookCreateError::CantChangeHandoperatableStatusForThisBoat) => Flash::error(Redirect::to("/log"), "Handsteuer-Status dieses Boots kann nicht verändert werden."), - Err(LogbookCreateError::TooFast(km, min)) => Flash::error(Redirect::to("/log"), format!("KM zu groß für die eingegebene Dauer ({km} km in {min} Minuten). Bitte überprüfe deine Start- und Endzeit und versuche es erneut.")), - Err(LogbookCreateError::AlreadyFinalized) => Flash::error(Redirect::to("/log"), "Logbucheintrag wurde bereits abgeschlossen."), - Err(LogbookCreateError::ExternalSteeringPersonMustSteerOrShipmaster) => Flash::error(Redirect::to("/log"), "Wenn du eine 'Externe Steuerperson' hinzufügst, muss diese steuern oder Schiffsführer sein!"), + match Logbook::create(db, data.into_inner(), user, smtp_pw).await { + Ok(msg) => Flash::success( + Redirect::to("/log"), + format!("Ausfahrt erfolgreich hinzugefügt{msg}"), + ), + Err(LogbookCreateError::BoatAlreadyOnWater) => { + Flash::error(Redirect::to("/log"), "Boot schon am Wasser") + } + Err(LogbookCreateError::RowerAlreadyOnWater(rower)) => Flash::error( + Redirect::to("/log"), + format!("Ruderer {} schon am Wasser", rower.name), + ), + Err(LogbookCreateError::BoatLocked) => Flash::error(Redirect::to("/log"), "Boot gesperrt"), + Err(LogbookCreateError::BoatNotFound) => { + Flash::error(Redirect::to("/log"), "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)" + ), + ), + Err(LogbookCreateError::RowerCreateError(rower, e)) => Flash::error( + Redirect::to("/log"), + format!("Fehler bei Ruderer {rower}: {e}"), + ), + Err(LogbookCreateError::ArrivalNotAfterDeparture) => Flash::error( + Redirect::to("/log"), + "Ankunftszeit kann nicht vor der Abfahrtszeit sein", + ), + Err(LogbookCreateError::UserNotAllowedToUseBoat) => Flash::error( + Redirect::to("/log"), + "Schiffsführer darf dieses Boot nicht verwenden", + ), + Err(LogbookCreateError::SteeringPersonNotInRowers) => Flash::error( + Redirect::to("/log"), + "Steuerperson nicht in Liste der Ruderer!", + ), + Err(LogbookCreateError::ShipmasterNotInRowers) => Flash::error( + Redirect::to("/log"), + "Schiffsführer nicht in Liste der Ruderer!", + ), + Err(LogbookCreateError::NotYourEntry) => { + Flash::error(Redirect::to("/log"), "Nicht deine Ausfahrt!") + } + Err(LogbookCreateError::ArrivalSetButNotRemainingTwo) => Flash::error( + Redirect::to("/log"), + "Ankunftszeit gesetzt aber nicht Distanz + Strecke", + ), + Err(LogbookCreateError::OnlyAllowedToEndTripsEndingToday) => Flash::error( + Redirect::to("/log"), + "Nur Ausfahrten, die in der letzten Woche enden dürfen eingetragen werden. Für einen Nachtrag schreibe alle Daten an den Vorstand (info@rudernlinz.at).", + ), + Err(LogbookCreateError::CantChangeHandoperatableStatusForThisBoat) => Flash::error( + Redirect::to("/log"), + "Handsteuer-Status dieses Boots kann nicht verändert werden.", + ), + Err(LogbookCreateError::TooFast(km, min)) => Flash::error( + Redirect::to("/log"), + format!( + "KM zu groß für die eingegebene Dauer ({km} km in {min} Minuten). Bitte überprüfe deine Start- und Endzeit und versuche es erneut." + ), + ), + Err(LogbookCreateError::AlreadyFinalized) => Flash::error( + Redirect::to("/log"), + "Logbucheintrag wurde bereits abgeschlossen.", + ), + Err(LogbookCreateError::ExternalSteeringPersonMustSteerOrShipmaster) => Flash::error( + Redirect::to("/log"), + "Wenn du eine 'Externe Steuerperson' hinzufügst, muss diese steuern oder Schiffsführer sein!", + ), } } @@ -312,7 +361,13 @@ async fn update( let data = data.into_inner(); let Some(logbook) = Logbook::find_by_id(db, data.id).await else { - return Flash::error(Redirect::to("/log"), format!("Logbucheintrag kann nicht bearbeitet werden, da es einen Logbuch-Eintrag mit ID={} nicht gibt", data.id)); + return Flash::error( + Redirect::to("/log"), + format!( + "Logbucheintrag kann nicht bearbeitet werden, da es einen Logbuch-Eintrag mit ID={} nicht gibt", + data.id + ), + ); }; match logbook.update(db, data.clone(), &user.user).await { @@ -353,14 +408,36 @@ async fn home_logbook( ); }; - match logbook.home(db,user, data.into_inner(), smtp_pw).await { + match logbook.home(db, user, data.into_inner(), smtp_pw).await { Ok(_) => Flash::success(Redirect::to("/log"), "Ausfahrt korrekt eingetragen"), - Err(LogbookUpdateError::TooManyRowers(expected, actual)) => Flash::error(Redirect::to("/log"), format!("Zu viele Ruderer (Boot fasst maximal {expected}, es wurden jedoch {actual} Ruderer ausgewählt)")), - Err(LogbookUpdateError::OnlyAllowedToEndTripsEndingToday) => Flash::error(Redirect::to("/log"), "Nur Ausfahrten, die heute enden dürfen eingetragen werden. Für einen Nachtrag schreibe alle Daten dem Vorstand an info@rudernlinz.at."), - Err(LogbookUpdateError::TooFast(km, min)) => Flash::error(Redirect::to("/log"), format!("KM zu groß für die eingegebene Dauer ({km} km in {min} Minuten). Bitte überprüfe deine Start- und Endzeit und versuche es erneut.")), - Err(LogbookUpdateError::AlreadyFinalized) => Flash::error(Redirect::to("/log"), "Logbucheintrag wurde bereits abgeschlossen."), - Err(LogbookUpdateError::ExternalSteeringPersonMustSteerOrShipmaster) => Flash::error(Redirect::to("/log"), "Wenn du eine 'Externe Steuerperson' hinzufügst, muss diese steuern oder Schiffsführer sein!"), - Err(LogbookUpdateError::BoatAlreadyOnWater) => Flash::error(Redirect::to("/log"), "Das Boot war in diesem Zeitraum schon am Wasser. Bitte überprüfe das Datum und die Zeit."), + Err(LogbookUpdateError::TooManyRowers(expected, actual)) => Flash::error( + Redirect::to("/log"), + format!( + "Zu viele Ruderer (Boot fasst maximal {expected}, es wurden jedoch {actual} Ruderer ausgewählt)" + ), + ), + Err(LogbookUpdateError::OnlyAllowedToEndTripsEndingToday) => Flash::error( + Redirect::to("/log"), + "Nur Ausfahrten, die heute enden dürfen eingetragen werden. Für einen Nachtrag schreibe alle Daten dem Vorstand an info@rudernlinz.at.", + ), + Err(LogbookUpdateError::TooFast(km, min)) => Flash::error( + Redirect::to("/log"), + format!( + "KM zu groß für die eingegebene Dauer ({km} km in {min} Minuten). Bitte überprüfe deine Start- und Endzeit und versuche es erneut." + ), + ), + Err(LogbookUpdateError::AlreadyFinalized) => Flash::error( + Redirect::to("/log"), + "Logbucheintrag wurde bereits abgeschlossen.", + ), + Err(LogbookUpdateError::ExternalSteeringPersonMustSteerOrShipmaster) => Flash::error( + Redirect::to("/log"), + "Wenn du eine 'Externe Steuerperson' hinzufügst, muss diese steuern oder Schiffsführer sein!", + ), + Err(LogbookUpdateError::BoatAlreadyOnWater) => Flash::error( + Redirect::to("/log"), + "Das Boot war in diesem Zeitraum schon am Wasser. Bitte überprüfe das Datum und die Zeit.", + ), Err(e) => Flash::error( Redirect::to("/log"), format!("Eintrag {logbook_id} konnte nicht abgesendet werden (Fehler: {e:?})!"), @@ -508,7 +585,7 @@ mod test { use sqlx::SqlitePool; use crate::model::logbook::Logbook; - use crate::tera::{log::Boat, User}; + use crate::tera::{User, log::Boat}; use crate::testdb; #[sqlx::test] diff --git a/src/tera/misc.rs b/src/tera/misc.rs index 929694e..52a2b5e 100644 --- a/src/tera/misc.rs +++ b/src/tera/misc.rs @@ -1,4 +1,4 @@ -use rocket::{get, http::ContentType, routes, Route, State}; +use rocket::{Route, State, get, http::ContentType, routes}; use sqlx::SqlitePool; use crate::model::{event::Event, personal::cal::get_personal_cal, user::User}; diff --git a/src/tera/mod.rs b/src/tera/mod.rs index 91f6ecb..11d68b5 100644 --- a/src/tera/mod.rs +++ b/src/tera/mod.rs @@ -2,7 +2,7 @@ use std::{fs::OpenOptions, io::Write}; use chrono::{Datelike, Local}; use rocket::{ - catch, catchers, + Build, Data, FromForm, Request, Rocket, State, catch, catchers, fairing::{AdHoc, Fairing, Info, Kind}, form::Form, fs::FileServer, @@ -13,7 +13,6 @@ use rocket::{ response::{Flash, Redirect}, routes, time::{Duration, OffsetDateTime}, - Build, Data, FromForm, Request, Rocket, State, }; use rocket_dyn_templates::Template; use serde::Deserialize; @@ -21,6 +20,7 @@ use sqlx::SqlitePool; use tera::Context; use crate::{ + SCHECKBUCH, model::{ logbook::Logbook, notification::Notification, @@ -28,7 +28,6 @@ use crate::{ role::Role, user::{User, UserWithDetails}, }, - SCHECKBUCH, }; pub(crate) mod admin; @@ -202,7 +201,10 @@ async fn blogpost_unpublished( #[catch(403)] //forbidden fn forbidden_error() -> Flash { - Flash::error(Redirect::to("/"), "Keine Berechtigung für diese Aktion. Wenn du der Meinung bist, dass du das machen darfst, melde dich bitte bei it@rudernlinz.at.") + Flash::error( + Redirect::to("/"), + "Keine Berechtigung für diese Aktion. Wenn du der Meinung bist, dass du das machen darfst, melde dich bitte bei it@rudernlinz.at.", + ) } struct Usage {} @@ -328,11 +330,13 @@ mod test { assert_eq!(response.status(), Status::Ok); - assert!(response - .into_string() - .await - .unwrap() - .contains("Ruderassistent")); + assert!( + response + .into_string() + .await + .unwrap() + .contains("Ruderassistent") + ); } #[sqlx::test] diff --git a/src/tera/notification.rs b/src/tera/notification.rs index e7c0069..e677586 100644 --- a/src/tera/notification.rs +++ b/src/tera/notification.rs @@ -1,7 +1,7 @@ use rocket::{ - get, + Route, State, get, response::{Flash, Redirect}, - routes, Route, State, + routes, }; use sqlx::SqlitePool; diff --git a/src/tera/planned.rs b/src/tera/planned.rs index 3102818..54d6aad 100644 --- a/src/tera/planned.rs +++ b/src/tera/planned.rs @@ -1,14 +1,15 @@ use rocket::{ - get, + Route, State, get, request::FlashMessage, response::{Flash, Redirect}, - routes, Route, State, + routes, }; use rocket_dyn_templates::Template; use sqlx::SqlitePool; use tera::Context; use crate::{ + AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD, model::{ log::Log, tripdetails::TripDetails, @@ -16,7 +17,6 @@ use crate::{ user::{AllowedForPlannedTripsUser, User, UserWithDetails}, usertrip::{UserTrip, UserTripDeleteError, UserTripError}, }, - AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD, }; #[get("/")] @@ -81,14 +81,15 @@ async fn join( ), ) .await; - }else{ + } else { Log::create( db, format!( "User {} registered the guest '{}' for trip_details.id={}", user.name, registered_user, trip_details_id ), - ).await; + ) + .await; } Flash::success(Redirect::to("/planned"), "Erfolgreich angemeldet!") } @@ -98,9 +99,10 @@ async fn join( Err(UserTripError::AlreadyRegistered) => { Flash::error(Redirect::to("/planned"), "Du nimmst bereits teil!") } - Err(UserTripError::AlreadyRegisteredAsCox) => { - Flash::error(Redirect::to("/planned"), "Du hilfst bereits als Steuerperson aus!") - } + Err(UserTripError::AlreadyRegisteredAsCox) => Flash::error( + Redirect::to("/planned"), + "Du hilfst bereits als Steuerperson aus!", + ), Err(UserTripError::CantRegisterAtOwnEvent) => Flash::error( Redirect::to("/planned"), "Du kannst bei einer selbst ausgeschriebenen Fahrt nicht mitrudern ;)", @@ -160,7 +162,10 @@ async fn remove_guest( ) .await; - Flash::error(Redirect::to("/planned"), "Die Bootseinteilung wurde bereits gemacht. Wenn du doch nicht mitrudern kannst, melde dich bitte unbedingt schnellstmöglich bei einer angemeldeten Steuerperson!") + Flash::error( + Redirect::to("/planned"), + "Die Bootseinteilung wurde bereits gemacht. Wenn du doch nicht mitrudern kannst, melde dich bitte unbedingt schnellstmöglich bei einer angemeldeten Steuerperson!", + ) } Err(UserTripDeleteError::GuestNotParticipating) => { Flash::error(Redirect::to("/planned"), "Gast nicht angemeldet.") @@ -211,7 +216,10 @@ async fn remove( ) .await; - Flash::error(Redirect::to("/planned"), "Das Boot ist bereits eingeteilt. Bitte kontaktiere den Schiffsführer (Nummern siehe Signalgruppe) falls du dich doch abmelden willst.") + Flash::error( + Redirect::to("/planned"), + "Das Boot ist bereits eingeteilt. Bitte kontaktiere den Schiffsführer (Nummern siehe Signalgruppe) falls du dich doch abmelden willst.", + ) } Err(UserTripDeleteError::NotVisibleToUser) => { Log::create( @@ -223,7 +231,10 @@ async fn remove( ) .await; - Flash::error(Redirect::to("/planned"), "Abmeldung nicht möglich, da du dieses Event eigentlich gar nicht sehen solltest...") + Flash::error( + Redirect::to("/planned"), + "Abmeldung nicht möglich, da du dieses Event eigentlich gar nicht sehen solltest...", + ) } Err(_) => { panic!("Not possible to be here"); diff --git a/src/tera/stat.rs b/src/tera/stat.rs index 3665b4c..11d224b 100644 --- a/src/tera/stat.rs +++ b/src/tera/stat.rs @@ -1,5 +1,5 @@ -use rocket::{get, routes, Route, State}; -use rocket_dyn_templates::{context, Template}; +use rocket::{Route, State, get, routes}; +use rocket_dyn_templates::{Template, context}; use sqlx::SqlitePool; use crate::model::{ diff --git a/src/tera/trailerreservation.rs b/src/tera/trailerreservation.rs index 4746ec9..42317d1 100644 --- a/src/tera/trailerreservation.rs +++ b/src/tera/trailerreservation.rs @@ -1,10 +1,11 @@ use chrono::NaiveDate; use rocket::{ + FromForm, Route, State, form::Form, get, post, request::FlashMessage, response::{Flash, Redirect}, - routes, FromForm, Route, State, + routes, }; use rocket_dyn_templates::Template; use sqlx::SqlitePool; diff --git a/templates/admin/mail.html.tera b/templates/admin/mail.html.tera index c80db2e..1e2ed46 100644 --- a/templates/admin/mail.html.tera +++ b/templates/admin/mail.html.tera @@ -26,26 +26,24 @@ role="alert">

Mitglieds-Beitrags-Info

diff --git a/templates/admin/role.html.tera b/templates/admin/role.html.tera new file mode 100644 index 0000000..2b29697 --- /dev/null +++ b/templates/admin/role.html.tera @@ -0,0 +1,37 @@ +{% import "includes/macros" as macros %} +{% import "includes/forms/boat" as boat %} +{% extends "base" %} +{% block content %} +
+

Rolle

+
+ +
+
+{% endblock content %} diff --git a/templates/admin/user/index.html.tera b/templates/admin/user/index.html.tera index 69e0121..1a572a6 100644 --- a/templates/admin/user/index.html.tera +++ b/templates/admin/user/index.html.tera @@ -5,28 +5,134 @@

Users

{% if allowed_to_edit %}
- Neue Person hinzufügen -
-
-
- - + + Neue Person hinzufügen + +
+ + + +
+ +
+ +
+

Neues Vereinsmitglied

+ +
+ + +
+ {{ macros::input(label='Name', name='name', type="text", required=true) }} + {{ macros::input(label='Mailadresse', name='mail', type="email", required=true, placeholder='user@mail.at') }} + {{ macros::select(label="Finanzielles", data=financial, name='financial_id', display=['name'], default="Keine Ermäßigung") }} + {{ macros::input(label='Mitglied seit', name='member_since', type="date", value=now() | date(), required=true) }} + {{ macros::input(label='Geburtsdatum', name='birthdate', type="date", required=true) }} + {{ macros::input(label='Telefonnummer', name='phone', type="text", required=true) }} + {{ macros::input(label='Adresse', name='address', type="text", required=true) }} + {{ macros::input(label='Beitrittserklärung', name='membership_pdf', type="file", accept='application/pdf', required=true) }} + + +
-
-
- -
- + + +
+ +
+

Neues Scheckbuch

+
+ {{ macros::input(label='Name', name='name', type="text", required=true) }} + {{ macros::input(label='Mailadresse', name='mail', type="email", required=true, placeholder='user@mail.at') }} + +
+
+
+
+ +
+ +
+
+

Neuer Schnupperant

+
+ + +
+ {{ macros::input(label='Name', name='name', type="text", required=true) }} + {{ macros::input(label='Mailadresse', name='mail', type="email", required=true, placeholder='user@mail.at') }} + {{ macros::select(label="Finanzielles", data=financial, name='financial_id', display=['name'], default="Keine Ermäßigung") }} + +
+
+
+
- {% endif %}
@@ -36,26 +142,29 @@ id="filter-js" class="search-bar" placeholder="Suchen nach (Name, [yes|no]-role:, has-[no-]membership-pdf)" /> -
- - - - + + +
@@ -63,113 +172,21 @@ {% for user in users %}
-
- - - - {{ user.name }} - {% if not user.last_access and allowed_to_edit and user.mail %} -
- • Willkommensmail verschicken -
- {% endif %} - {% if user.last_access %}• ⏳ {{ user.last_access | date }}{% endif %} -
- - {% for role in user.roles -%} - {{ role }} - {%- if not loop.last %}, - {% endif -%} - {% endfor %} - -
-
-
- {% if user.pw %} - Passwort zurücksetzen - {% endif %} -
- -
- {% for cluster, cluster_roles in roles | group_by(attribute="cluster") %} - - {# Determine the initially selected role within the cluster #} - {% set_global selected_role_id = "none" %} - {% for role in cluster_roles %} - {% if selected_role_id == "none" and role.name in user.roles %} - {% set_global selected_role_id = role.id %} - {% endif %} - {% endfor %} - {# Set default name to the selected role ID or first role if none selected #} - - {% endfor %} - {% for role in roles %} - {% if not role.cluster %} - {{ macros::checkbox(label=role.name, name="roles[" ~ role.id ~ "]", id=loop.index , checked=role.name in user.roles, disabled=allowed_to_edit == false) }} - {% endif %} - {% endfor %} -
- {% if user.membership_pdf %} - Beitrittserklärung herunterladen - {% else %} - {{ macros::input(label='Beitrittserklärung', name='membership_pdf', id=loop.index, type="file", readonly=allowed_to_edit == false, accept='application/pdf') }} - {% endif %} - {{ macros::input(label='DOB', name='dob', id=loop.index, type="text", value=user.dob, readonly=allowed_to_edit == false) }} - {{ macros::input(label='Weight (kg)', name='weight', id=loop.index, type="text", value=user.weight, readonly=allowed_to_edit == false) }} - {{ macros::input(label='Sex', name='sex', id=loop.index, type="text", value=user.sex, readonly=allowed_to_edit == false) }} - {{ macros::input(label='Mitglied seit', name='member_since_date', id=loop.index, type="text", value=user.member_since_date, readonly=allowed_to_edit == false) }} - {{ macros::input(label='Geburtsdatum', name='birthdate', id=loop.index, type="text", value=user.birthdate, readonly=allowed_to_edit == false) }} - {{ macros::input(label='Mail', name='mail', id=loop.index, type="text", value=user.mail, readonly=allowed_to_edit == false) }} - {{ macros::input(label='Nickname', name='nickname', id=loop.index, type="text", value=user.nickname, readonly=allowed_to_edit == false) }} - {{ macros::input(label='Notizen', name='notes', id=loop.index, type="text", value=user.notes, readonly=allowed_to_edit == false) }} - {{ macros::input(label='Telefon', name='phone', id=loop.index, type="text", value=user.phone, readonly=allowed_to_edit == false) }} - {{ macros::input(label='Adresse', name='address', id=loop.index, type="text", value=user.address, readonly=allowed_to_edit == false) }} - {% if allowed_to_edit %} - {{ macros::select(label="Familie", data=families, name='family_id', selected_id=user.family_id, display=['names'], default="Keine Familie", new_last_entry='Neue Familie anlegen') }} - {% endif %} -
-
- {% if allowed_to_edit %} - - {% endif %} -
-
+ class="border-t bg-white dark:bg-primary-900 py-3 px-4 relative flex justify-between items-center"> + + + {{ user.name }} + {% if user.last_access %}• ⏳ {{ user.last_access | date }}{% endif %} + + + {% for role in user.roles -%} + {{ role }} + {%- if not loop.last %}, + {% endif -%} + {% endfor %} + + + {% include "includes/pencil" %}
{% endfor %} diff --git a/templates/admin/user/scheckbuch.html.tera b/templates/admin/user/scheckbuch.html.tera index 15da9a2..56c4941 100644 --- a/templates/admin/user/scheckbuch.html.tera +++ b/templates/admin/user/scheckbuch.html.tera @@ -56,9 +56,9 @@
{{ user.name }} - Ausfahrten: {{ trips | length }}
    - {% for trip in trips %} -
  • {{ log::show_old(log=trip, state="completed", only_ones=false, index=loop.index) }}
  • - {% endfor %} + {% for trip in trips %} +
  • {{ log::show_old(log=trip, state="completed", only_ones=false, index=loop.index) }}
  • + {% endfor %}
{% if "admin" in loggedin_user.roles or "kassier" in loggedin_user.roles %} diff --git a/templates/admin/user/view.html.tera b/templates/admin/user/view.html.tera new file mode 100644 index 0000000..ce1e54e --- /dev/null +++ b/templates/admin/user/view.html.tera @@ -0,0 +1,435 @@ +{% import "includes/macros" as macros %} +{% import "includes/forms/log" as log %} +{% extends "base" %} +{% block content %} +
+ {% if "admin" in loggedin_user.roles or "Vorstand" in loggedin_user.roles %} + + {% endif %} +

{{ user.name }}

+
+
+

+ Grunddaten +
+ + {% if user.last_access %} + Zuletzt eingeloggt am {{ user.last_access | date(format="%d. %m. %Y") }} + {% else %} + App-Boykott 😢 + {% endif %} + +

+
+
+
+ {{ macros::inputgroup(label='Mailadresse', name='mail', type="text", value=user.mail, readonly=not allowed_to_edit) }} +
+
+ {{ macros::inputgroup(label='Telefonnummer', name='phone', type="text", value=user.phone, readonly=not allowed_to_edit) }} +
+
+ {{ macros::inputgroup(label='Spitzname', name='nickname', type="text", value=user.nickname, readonly=not allowed_to_edit) }} +
+
+ {% if user_financial %} + {{ macros::selectgroup(label="Finanzielles", data=financial, selected_id=user_financial.id, name='financial_id', display=['name'], default="Keine Ermäßigung", readonly=not allowed_to_edit) }} + {% else %} + {{ macros::selectgroup(label="Finanzielles", data=financial, name='financial_id', display=['name'], default="Keine Ermäßigung", readonly=not allowed_to_edit) }} + {% endif %} +
+ {% if allowed_to_edit %} +
+ {{ macros::inputgroup(label='Neue Notiz', name='note', type="text") }} +
+ {% endif %} + {% if user.pw and allowed_to_edit %} + + {% endif %} +
+
+
+
+

+ Mitgliedschaft +
+ + {% if "SchnupperInterest" in member %} + Interessiert am Schnupperkurs + {% elif "Schnupperant" in member %} + Beim nächsten Schnupperkurs angemeldet + {% elif "Scheckbuch" in member %} + {% set logbook = member["Scheckbuch"] %} + Scheckbuch (Ausfahrten: {{ logbook | length }}) + {% elif "Regular" in member %} + Reguläres Vereinsmitglied + {% elif "Foerdernd" in member %} + Förderndes Vereinsmitglied + {% elif "Unterstuetzend" in member %} + Unterstützendes Vereinsmitglied + {% endif %} + +

+
+ {% if is_clubmember %} +
+
+ {{ macros::inputgroup(label='Mitglied seit', name='member_since', type="date", value=user.member_since_date, readonly=not allowed_to_edit) }} +
+
+ {{ macros::inputgroup(label='Geburtsdatum', name='birthdate', type="date", value=user.birthdate, readonly=not allowed_to_edit) }} +
+
+ {{ macros::inputgroup(label='Adresse', name='address', type="text", value=user.address, readonly=not allowed_to_edit) }} +
+
+ {% if user_skill %} + {{ macros::selectgroup(label="Steuererlaubnis", data=skill, selected_id=user_skill.id, name='skill_id', display=['name'], default="Keine Steuerberechtigung", readonly=not allowed_to_edit) }} + {% else %} + {{ macros::selectgroup(label="Steuererlaubnis", data=skill, name='skill_id', display=['name'], default="Keine Steuerberechtigung", readonly=not allowed_to_edit) }} + {% endif %} +
+
+ {{ macros::selectgroup(label="Familie", data=families, name='family_id', selected_id=user.family_id, display=['names'], default="Keine Familie", new_last_entry='Neue Familie anlegen', readonly=not allowed_to_edit) }} +
+
+
+ {% if user.membership_pdf %} + Beitrittserklärung herunterladen ↓ + {% else %} + ⚠️ Aktuell gibt's keine Beitrittserklärung 😢 + {% if allowed_to_edit %} + Das kannst du hier ändern ⤵️ +
+
+ {{ macros::input(label='Neue Beitrittserklärung hochladen', name='membership_pdf', type="file", accept='application/pdf') }} +
+ +
+ {% endif %} + {% endif %} +
+ {% if allowed_to_edit %} + + +
+ +
+
+
+ + +
+ +
+
+
+
+ {% endif %} + {% elif "Scheckbuch" in member %} + {% if allowed_to_edit %} +
+
+ {% for log in logbook %} + {{ log::show_old(log=log, state="completed", only_ones=false, index=loop.index, allowed_to_edit=false) }} + {% endfor %} +
+
+ + {% endif %} + {% elif "SchnupperInterest" in member %} + {% if allowed_to_edit %} + + + + {% endif %} + {% elif "Schnupperant" in member %} + {% if allowed_to_edit %} + + + + {% endif %} + {% endif %} + {% if "Scheckbuch" in member or "Schnupperant" in member %} + {% if allowed_to_edit %} +
+ +
+ +
+ +
+ {% if "Scheckbuch" in member %} + {% set action = "scheckbook-to-regular" %} + {% elif "Schnupperant" in member %} + {% set action = "schnupperant-to-regular" %} + {% endif %} +
+
+ + +
+ {{ macros::input(label='Mitglied seit', name='member_since', type="date", value=now() | date(), required=true) }} + {{ macros::input(label='Geburtsdatum', name='birthdate', type="date", value=user.birthdate, required=true) }} + {{ macros::input(label='Telefonnummer', name='phone', type="text", value=user.phone, required=true) }} + {{ macros::input(label='Adresse', name='address', type="text", value=user.address, required=true) }} + {{ macros::input(label='Beitrittserklärung', name='membership_pdf', type="file", accept='application/pdf', required=true) }} + +
+
+
+
+ {% endif %} + {% endif %} +
+
+ {% if is_clubmember %} +
+

Rollen

+
+
    + {% for role in user.proper_roles -%} + {% if not role.cluster and not role.hide_in_lists %} +
  • + + + {% if role.formatted_name %} + {{ role.formatted_name }} + {% else %} + {{ role.name }} + {% endif %} + +
    + {{ role.desc }} +
    + {% if allowed_to_edit %} + 🗑️ + {% endif %} +
  • + {% endif %} + {% endfor %} +
+ {% if allowed_to_edit %} +
+ +
+ +
+ +
+
+
+ + +
+ +
+
+
+
+ {% endif %} +
+
+ {% endif %} + {% if supposed_to_pay %} +
+

💸-Beitrag

+
+
+ {% if fee %} +
+ {{ fee.name }} + {{ fee.sum_in_cents / 100 }}€ +
+
+ {% for p in fee.parts %} + {{ p.0 }} ({{ p.1 / 100 }}€) + {% if not loop.last %}+{% endif %} + {% endfor %} +
+ {% if "paid" in user.roles %} + ✅ bezahlt + {% else %} + ❌ Zahlung ausständig + {% endif %} + {% else %} + {% if "paid" in user.roles %} + ✅ + {% for key, value in member %} + {% if loop.first %}{{ key }}{% endif %} + {% endfor %} + hat schon bezahlt + {% else %} + ❌ + {% for key, value in member %} + {% if loop.first %}{{ key }}{% endif %} + {% endfor %} + hat noch nicht bezahlt + {% endif %} + {% endif %} +
+
+
+ {% endif %} +
+

Aktivitäten

+
+
+
    + {% for activity in activities %} +
  • + {{ activity.created_at | date(format="%d. %m. %Y") }}: {{ activity.text }} +
  • + {% else %} +
  • Noch keine Aktivität... Stay tuned 😆
  • + {% endfor %} +
+
+
+
+
+

Ergo-Challenge

+
+
+ {{ macros::inputgroup(label='DOB', name='dob', type="text", value=user.dob, readonly=allowed_to_edit == false) }} + {{ macros::inputgroup(label='Weight (kg)', name='weight', type="text", value=user.weight, readonly=allowed_to_edit == false) }} + {{ macros::inputgroup(label='Sex', name='sex', type="text", value=user.sex, readonly=allowed_to_edit == false) }} +
+
+
+
+
+{% endblock content %} diff --git a/templates/ergo/final.html.tera b/templates/ergo/final.html.tera index 8dc4731..c530904 100644 --- a/templates/ergo/final.html.tera +++ b/templates/ergo/final.html.tera @@ -7,7 +7,7 @@ Dirty Thirty

-