Merge pull request 'single-user-edit-page' (#970) from single-user-edit-page into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 13m54s
CI/CD Pipeline / deploy-staging (push) Successful in 7m8s
CI/CD Pipeline / deploy-main (push) Has been skipped

Reviewed-on: #970
This commit is contained in:
philipp 2025-05-03 13:46:39 +02:00
commit 5296b6a6c1
10 changed files with 620 additions and 459 deletions

View File

@ -50,11 +50,16 @@ function editReadOnlyField() {
Array.prototype.forEach.call(editBtns, (btn: HTMLButtonElement) => { Array.prototype.forEach.call(editBtns, (btn: HTMLButtonElement) => {
btn.addEventListener("click", function () { btn.addEventListener("click", function () {
let wrapper = btn.parentElement; let wrapper = btn.parentElement;
let input = wrapper?.querySelector('input'); let input = <HTMLInputElement> wrapper?.querySelector('input.input'),
select = <HTMLSelectElement> wrapper?.querySelector('select.input'),
attribute = 'readonly';
wrapper?.classList.toggle('editable') if(select) attribute = 'disabled';
input?.toggleAttribute('readonly'); let element = input ? input : select;
if(!input?.hasAttribute('readonly')) input?.focus();
element?.toggleAttribute(attribute);
if(!element?.hasAttribute(attribute)) element?.focus();
wrapper?.classList.toggle('editable');
}); });
}); });
} }

View File

@ -1,5 +1,7 @@
#![allow(clippy::blocks_in_conditions)] #![allow(clippy::blocks_in_conditions)]
use std::ops::Deref;
pub mod model; pub mod model;
#[cfg(feature = "rowing-tera")] #[cfg(feature = "rowing-tera")]
@ -22,6 +24,74 @@ pub(crate) const FOERDERND: i64 = 8500;
pub(crate) const SCHECKBUCH: i64 = 3000; pub(crate) const SCHECKBUCH: i64 = 3000;
pub(crate) const EINSCHREIBGEBUEHR: 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<Self> {
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<str> for NonEmptyString {
fn as_ref(&self) -> &str {
&self.0
}
}
// This allows NonEmptyString to be converted to String with .into()
impl From<NonEmptyString> for String {
fn from(s: NonEmptyString) -> Self {
s.0
}
}
impl TryFrom<&str> for NonEmptyString {
type Error = &'static str;
fn try_from(s: &str) -> Result<Self, Self::Error> {
if s.is_empty() {
Err("String cannot be empty")
} else {
Ok(NonEmptyString(s.to_string()))
}
}
}
impl TryFrom<String> for NonEmptyString {
type Error = &'static str;
fn try_from(s: String) -> Result<Self, Self::Error> {
if s.is_empty() {
Err("String cannot be empty")
} else {
Ok(NonEmptyString(s))
}
}
}
#[cfg(test)] #[cfg(test)]
#[macro_export] #[macro_export]
macro_rules! testdb { macro_rules! testdb {

View File

@ -42,12 +42,20 @@ impl User {
db: &SqlitePool, db: &SqlitePool,
updated_by: &ManageUserUser, updated_by: &ManageUserUser,
new_phone: &str, new_phone: &str,
) -> Result<(), String> { ) {
let new_phone = new_phone.trim(); let new_phone = new_phone.trim();
let query = if new_phone.is_empty() { 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) sqlx::query!("UPDATE user SET phone = NULL where id = ?", self.id)
} else { } 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) 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 query.execute(db).await.unwrap(); //Okay, because we can only create a User of a valid id
@ -58,8 +66,6 @@ impl User {
None => format!("{updated_by} has added a phone number for {self}: {new_phone}") None => format!("{updated_by} has added a phone number for {self}: {new_phone}")
}; };
Log::create(db, msg).await; Log::create(db, msg).await;
Ok(())
} }
pub(crate) async fn update_address( pub(crate) async fn update_address(
@ -67,12 +73,20 @@ impl User {
db: &SqlitePool, db: &SqlitePool,
updated_by: &ManageUserUser, updated_by: &ManageUserUser,
new_address: &str, new_address: &str,
) -> Result<(), String> { ) {
let new_address = new_address.trim(); let new_address = new_address.trim();
let query = if new_address.is_empty() { 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) sqlx::query!("UPDATE user SET address = NULL where id = ?", self.id)
} else { } else {
if let Some(old_address) = &self.address {
if old_address == new_address {
return; //nothing to do
}
}
sqlx::query!( sqlx::query!(
"UPDATE user SET address = ? where id = ?", "UPDATE user SET address = ? where id = ?",
new_address, new_address,
@ -87,8 +101,6 @@ impl User {
None => format!("{updated_by} has added an address for {self}: {new_address}") None => format!("{updated_by} has added an address for {self}: {new_address}")
}; };
Log::create(db, msg).await; Log::create(db, msg).await;
Ok(())
} }
pub(crate) async fn update_nickname( pub(crate) async fn update_nickname(
@ -313,6 +325,9 @@ impl User {
if self.has_membership_pdf(db).await { if self.has_membership_pdf(db).await {
return Err(format!("User {self} hat bereits eine Beitrittserklärung.")); return Err(format!("User {self} hat bereits eine Beitrittserklärung."));
} }
if membership_pdf.len() == 0 {
return Err(format!("Keine Beitrittserklärung mitgeschickt."));
}
let mut stream = membership_pdf.open().await.unwrap(); let mut stream = membership_pdf.open().await.unwrap();
let mut buffer = Vec::new(); let mut buffer = Vec::new();

View File

@ -1,7 +1,6 @@
use super::ScheckbuchUser; use super::ScheckbuchUser;
use crate::model::{ use crate::model::{
logbook::{Logbook, LogbookWithBoatAndRowers}, logbook::{Logbook, LogbookWithBoatAndRowers},
role::Role,
user::User, user::User,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View File

@ -1,15 +1,12 @@
use std::{ use std::{fmt::Display, ops::DerefMut};
fmt::Display,
ops::{Deref, DerefMut},
};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use chrono::{Datelike, Local, NaiveDate}; use chrono::{Datelike, Local, NaiveDate};
use log::info; use log::info;
use rocket::async_trait;
use rocket::{ use rocket::{
async_trait,
http::{Cookie, Status}, http::{Cookie, Status},
request::{self, FromRequest, Outcome}, request::{FromRequest, Outcome},
time::{Duration, OffsetDateTime}, time::{Duration, OffsetDateTime},
tokio::io::AsyncReadExt, tokio::io::AsyncReadExt,
Request, Request,
@ -35,6 +32,7 @@ use scheckbuch::ScheckbuchUser;
mod basic; mod basic;
mod fee; mod fee;
pub(crate) mod member; pub(crate) mod member;
pub(crate) mod regular;
pub(crate) mod scheckbuch; pub(crate) mod scheckbuch;
#[derive(FromRow, Serialize, Deserialize, Clone, Debug, Eq, Hash, PartialEq)] #[derive(FromRow, Serialize, Deserialize, Clone, Debug, Eq, Hash, PartialEq)]
@ -119,10 +117,7 @@ impl User {
)); ));
}; };
if self.has_role(db, "Donau Linz").await { if self.has_role(db, "schnupperant").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?; self.send_welcome_mail_schnupper(db, mail, smtp_pw).await?;
} else if let Some(scheckbuch) = ScheckbuchUser::new(db, self).await { } else if let Some(scheckbuch) = ScheckbuchUser::new(db, self).await {
scheckbuch.notify(db, mail, smtp_pw).await?; scheckbuch.notify(db, mail, smtp_pw).await?;
@ -182,57 +177,6 @@ ASKÖ Ruderverein Donau Linz", self.name),
Ok(()) 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 { pub async fn amount_boats(&self, db: &SqlitePool) -> i64 {
sqlx::query!( sqlx::query!(
"SELECT COUNT(*) as count FROM boat WHERE owner = ?", "SELECT COUNT(*) as count FROM boat WHERE owner = ?",
@ -904,7 +848,7 @@ ASKÖ Ruderverein Donau Linz", self.name),
impl<'r> FromRequest<'r> for User { impl<'r> FromRequest<'r> for User {
type Error = LoginError; type Error = LoginError;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> { async fn from_request(req: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
match req.cookies().get_private("loggedin_user") { match req.cookies().get_private("loggedin_user") {
Some(user_id) => match user_id.value().parse::<i32>() { Some(user_id) => match user_id.value().parse::<i32>() {
Ok(user_id) => { Ok(user_id) => {
@ -939,7 +883,7 @@ macro_rules! special_user {
pub(crate) user: User, pub(crate) user: User,
} }
impl Deref for $name { impl std::ops::Deref for $name {
type Target = User; type Target = User;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.user &self.user
@ -953,20 +897,20 @@ macro_rules! special_user {
} }
#[async_trait] #[async_trait]
impl<'r> FromRequest<'r> for $name { impl<'r> rocket::request::FromRequest<'r> for $name {
type Error = LoginError; type Error = crate::model::user::LoginError;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> { async fn from_request(req: &'r rocket::request::Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
let db = req.rocket().state::<SqlitePool>().unwrap(); let db = req.rocket().state::<SqlitePool>().unwrap();
match User::from_request(req).await { match User::from_request(req).await {
Outcome::Success(user) => { rocket::request::Outcome::Success(user) => {
if special_user!(@check_roles user, db, $($role)*) { if special_user!(@check_roles user, db, $($role)*) {
Outcome::Success($name { user }) rocket::request::Outcome::Success($name { user })
} else { } else {
Outcome::Forward(Status::Forbidden) rocket::request::Outcome::Forward(rocket::http::Status::Forbidden)
} }
} }
Outcome::Error(f) => Outcome::Error(f), rocket::request::Outcome::Error(f) => rocket::request::Outcome::Error(f),
Outcome::Forward(f) => Outcome::Forward(f), rocket::request::Outcome::Forward(f) => rocket::request::Outcome::Forward(f),
} }
} }
} }
@ -1007,8 +951,10 @@ special_user!(TechUser, +"tech");
special_user!(ErgoUser, +"ergo"); special_user!(ErgoUser, +"ergo");
special_user!(SteeringUser, +"cox", +"Bootsführer"); special_user!(SteeringUser, +"cox", +"Bootsführer");
special_user!(AdminUser, +"admin"); special_user!(AdminUser, +"admin");
special_user!(AllowedForPlannedTripsUser, +"Donau Linz", +"scheckbuch"); special_user!(AllowedForPlannedTripsUser, +"Donau Linz", +"scheckbuch", +"Förderndes Mitglied");
special_user!(DonauLinzUser, +"Donau Linz", -"Unterstützend", -"Förderndes Mitglied"); special_user!(DonauLinzUser, +"Donau Linz", -"Unterstützend", -"Förderndes Mitglied"); // TODO:
// remove ->
// RegularUser
special_user!(SchnupperBetreuerUser, +"schnupper-betreuer"); special_user!(SchnupperBetreuerUser, +"schnupper-betreuer");
special_user!(VorstandUser, +"admin", +"Vorstand"); special_user!(VorstandUser, +"admin", +"Vorstand");
special_user!(EventUser, +"manage_events"); special_user!(EventUser, +"manage_events");

73
src/model/user/regular.rs Normal file
View File

@ -0,0 +1,73 @@
use super::User;
use crate::{
model::{mail::Mail, notification::Notification},
special_user,
};
use rocket::async_trait;
use sqlx::SqlitePool;
special_user!(RegularUser, +"Donau Linz", -"Unterstützend", -"Förderndes Mitglied");
impl RegularUser {
pub(crate) async fn notify(&self, db: &SqlitePool, smtp_pw: &str) -> Result<(), String> {
self.notify_coxes_about_new_regular(db).await;
self.send_welcome_mail_to_user(db, smtp_pw).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 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?;
Ok(())
}
async fn notify_coxes_about_new_regular(&self, db: &SqlitePool) {
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;
}
}

View File

@ -1,8 +1,7 @@
use super::member::Member; use super::regular::RegularUser;
use super::{ManageUserUser, User}; use super::{ManageUserUser, User};
use crate::model::role::Role; use crate::model::role::Role;
use crate::model::user::LoginError; use crate::NonEmptyString;
use crate::tera::admin::user::ScheckToRegularForm;
use crate::{ use crate::{
model::{mail::Mail, notification::Notification}, model::{mail::Mail, notification::Notification},
special_user, SCHECKBUCH, special_user, SCHECKBUCH,
@ -10,13 +9,7 @@ use crate::{
use chrono::NaiveDate; use chrono::NaiveDate;
use rocket::async_trait; use rocket::async_trait;
use rocket::fs::TempFile; use rocket::fs::TempFile;
use rocket::http::Status;
use rocket::request;
use rocket::request::FromRequest;
use rocket::request::Outcome;
use rocket::Request;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::ops::Deref;
special_user!(ScheckbuchUser, +"scheckbuch"); special_user!(ScheckbuchUser, +"scheckbuch");
@ -24,11 +17,12 @@ impl ScheckbuchUser {
pub(crate) async fn convert_to_regular_user( pub(crate) async fn convert_to_regular_user(
self, self,
db: &SqlitePool, db: &SqlitePool,
smtp_pw: &str,
changed_by: &ManageUserUser, changed_by: &ManageUserUser,
member_since: &NaiveDate, member_since: &NaiveDate,
birthdate: &NaiveDate, birthdate: &NaiveDate,
phone: &str, phone: NonEmptyString,
address: &str, address: NonEmptyString,
membership_pdf: &TempFile<'_>, membership_pdf: &TempFile<'_>,
) -> Result<(), String> { ) -> Result<(), String> {
// Set data // Set data
@ -36,9 +30,9 @@ impl ScheckbuchUser {
self.user self.user
.update_member_since(db, changed_by, member_since) .update_member_since(db, changed_by, member_since)
.await; .await;
self.user.update_phone(db, changed_by, phone).await?;
self.user.update_address(db, changed_by, address).await?; self.user.update_phone(db, changed_by, &phone).await;
self.user.update_address(db, changed_by, address).await?; self.user.update_address(db, changed_by, &address).await;
self.user self.user
.add_membership_pdf(db, changed_by, membership_pdf) .add_membership_pdf(db, changed_by, membership_pdf)
.await?; .await?;
@ -50,25 +44,13 @@ impl ScheckbuchUser {
self.user.add_role(db, changed_by, &regular).await?; self.user.add_role(db, changed_by, &regular).await?;
// Notify // Notify
todo!() // Continue here let regular = RegularUser::new(db, &self.user).await.unwrap();
regular.notify(db, smtp_pw).await?;
Ok(())
} }
//async fn from(user: User, db: &SqlitePool, mail: &str, smtp_pw: &str) -> Result<(), String> { // TODO: make private
// 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( pub(crate) async fn notify(
&self, &self,
db: &SqlitePool, db: &SqlitePool,

View File

@ -390,13 +390,11 @@ async fn update_phone(
); );
}; };
match user.update_phone(db, &admin, &data.phone).await { user.update_phone(db, &admin, &data.phone).await;
Ok(_) => Flash::success( Flash::success(
Redirect::to(format!("/admin/user/{}", user.id)), Redirect::to(format!("/admin/user/{}", user.id)),
"Telefonnummer erfolgreich geändert", "Telefonnummer erfolgreich geändert",
), )
Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", user.id)), e),
}
} }
#[derive(FromForm, Debug)] #[derive(FromForm, Debug)]
@ -418,13 +416,12 @@ async fn update_address(
); );
}; };
match user.update_address(db, &admin, &data.address).await { user.update_address(db, &admin, &data.address).await;
Ok(_) => Flash::success(
Flash::success(
Redirect::to(format!("/admin/user/{}", user.id)), Redirect::to(format!("/admin/user/{}", user.id)),
"Adresse erfolgreich geändert", "Adresse erfolgreich geändert",
), )
Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", user.id)), e),
}
} }
#[derive(FromForm, Debug)] #[derive(FromForm, Debug)]
@ -831,6 +828,7 @@ async fn scheckbook_to_regular(
db: &State<SqlitePool>, db: &State<SqlitePool>,
data: Form<ScheckToRegularForm<'_>>, data: Form<ScheckToRegularForm<'_>>,
admin: ManageUserUser, admin: ManageUserUser,
config: &State<Config>,
id: i32, id: i32,
) -> Flash<Redirect> { ) -> Flash<Redirect> {
let Some(user) = User::find_by_id(db, id).await else { let Some(user) = User::find_by_id(db, id).await else {
@ -842,13 +840,19 @@ async fn scheckbook_to_regular(
let Ok(birthdate) = NaiveDate::parse_from_str(&data.birthdate, "%Y-%m-%d") else { let Ok(birthdate) = NaiveDate::parse_from_str(&data.birthdate, "%Y-%m-%d") else {
return Flash::error( return Flash::error(
Redirect::to(format!("/admin/user/{id}")), Redirect::to(format!("/admin/user/{id}")),
format!("Datum {} ist nicht im YYYY-MM-DD Format", &data.birthdate), 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 { let Ok(member_since) = NaiveDate::parse_from_str(&data.member_since, "%Y-%m-%d") else {
return Flash::error( return Flash::error(
Redirect::to(format!("/admin/user/{id}")), Redirect::to(format!("/admin/user/{id}")),
format!("Datum {} ist nicht im YYYY-MM-DD Format", &data.birthdate), format!(
"Beitrittsdatum {} ist nicht im YYYY-MM-DD Format",
&data.birthdate
),
); );
}; };
@ -859,14 +863,28 @@ async fn scheckbook_to_regular(
); );
}; };
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",
);
};
match user match user
.convert_to_regular_user( .convert_to_regular_user(
db, db,
&config.smtp_pw,
&admin, &admin,
&member_since, &member_since,
&birthdate, &birthdate,
&data.phone, phone,
&data.address, address,
&data.membership_pdf, &data.membership_pdf,
) )
.await .await

View File

@ -4,50 +4,171 @@
{% block content %} {% block content %}
<div class="max-w-screen-lg w-full"> <div class="max-w-screen-lg w-full">
<h1 class="h1">{{ user.name }}</h1> <h1 class="h1">{{ user.name }}</h1>
<div class="grid sm:grid-cols-2 gap-3">
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5" <div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert"> role="alert">
<h2 class="h2">Grunddaten</h2> <h2 class="h2">
<div class="mx-2 divide-y divide-gray-200 dark:divide-primary-600"> Grunddaten
<div class="py-3"> <br />
<small class="inline-block text-xs text-gray-500 dark:text-gray-100 ">
{% if user.last_access %} {% if user.last_access %}
Zuletzt eingeloggt am {{ user.last_access | date(format="%d. %m. %Y") }} Zuletzt eingeloggt am {{ user.last_access | date(format="%d. %m. %Y") }}
{% else %} {% else %}
{{ user.name }} hat sich noch nie eingeloggt. {{ user.name }} hat sich noch nie eingeloggt.
{% endif %} {% endif %}
</div> </small>
<div class="py-3"> </h2>
<ul class="grid gap-3"> <div class="mx-2 divide-y divide-gray-200 dark:divide-primary-600">
<li> <div class="py-3 grid gap-3">
<form action="/admin/user/{{ user.id }}/change-mail" method="post"> <form action="/admin/user/{{ user.id }}/change-mail" method="post">
{{ macros::inputgroup(label='Mailadresse', name='mail', type="text", value=user.mail, readonly=not allowed_to_edit) }} {{ macros::inputgroup(label='Mailadresse', name='mail', type="text", value=user.mail, readonly=not allowed_to_edit) }}
</form> </form>
</li>
<li>
<form action="/admin/user/{{ user.id }}/change-phone" method="post"> <form action="/admin/user/{{ user.id }}/change-phone" method="post">
{{ macros::inputgroup(label='Telefonnummer', name='phone', type="text", value=user.phone, readonly=not allowed_to_edit) }} {{ macros::inputgroup(label='Telefonnummer', name='phone', type="text", value=user.phone, readonly=not allowed_to_edit) }}
</form> </form>
</li>
<li>
<form action="/admin/user/{{ user.id }}/change-nickname" method="post"> <form action="/admin/user/{{ user.id }}/change-nickname" method="post">
{{ macros::inputgroup(label='Spitzname', name='nickname', type="text", value=user.nickname, readonly=not allowed_to_edit) }} {{ macros::inputgroup(label='Spitzname', name='nickname', type="text", value=user.nickname, readonly=not allowed_to_edit) }}
</form> </form>
</li> <span>Notizen: to be replaced with activity :-)</span>
<li>Notizen: to be replaced with activity :-)</li> </div>
</ul> </div>
</div>
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">
Mitgliedschaft
<br />
<small class="inline-block text-xs text-gray-500 dark:text-gray-100 ">
{% 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 %}
</small>
</h2>
<div class="mx-2 divide-y divide-gray-200 dark:divide-primary-600">
{% if is_clubmember %}
<div class="py-3 grid gap-3">
<form action="/admin/user/{{ user.id }}/change-member-since" method="post">
{{ macros::inputgroup(label='Mitglied seit', name='member_since', type="date", value=user.member_since_date, readonly=not allowed_to_edit) }}
</form>
<form action="/admin/user/{{ user.id }}/change-birthdate" method="post">
{{ macros::inputgroup(label='Geburtsdatum', name='birthdate', type="date", value=user.birthdate, readonly=not allowed_to_edit) }}
</form>
<form action="/admin/user/{{ user.id }}/change-address" method="post">
{{ macros::inputgroup(label='Adresse', name='address', type="text", value=user.address, readonly=not allowed_to_edit) }}
</form>
<form action="/admin/user/{{ user.id }}/change-family" method="post">
{{ 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) }}
</form>
</div> </div>
<div class="py-3"> <div class="py-3">
Rollen: {% if user.membership_pdf %}
<ul class="list-disc ms-4"> <a href="/admin/user/{{ user.id }}/membership" class="link link-primary">Beitrittserklärung herunterladen</a>
{% else %}
⚠️ Aktuell gibt's keine Beitrittserklärung 😢
{% if allowed_to_edit %}
Das kannst du hier ändern ⤵️
<form action="/admin/user/{{ user.id }}/add-membership-pdf"
method="post"
enctype="multipart/form-data"
class="grid gap-3">
<fieldset>
{{ macros::input(label='Neue Beitrittserklärung hochladen', name='membership_pdf', type="file", accept='application/pdf') }}
</fieldset>
<input value="Hochladen" type="submit" class="btn btn-primary" />
</form>
{% endif %}
{% endif %}
</div>
{% if allowed_to_edit %}
<div class="py-3">
<div class="mt-3 text-right">
<a href="/admin/user/{{ user.id }}/delete"
class="btn btn-alert"
onclick="return confirm('Ist {{ user.name }} wirklich aus dem Verein ausgetreten?');">
{% include "includes/delete-icon" %}
Mitglied ist ausgetreten
</a>
</div>
</div>
{% endif %}
{% elif "Scheckbuch" in member %}
<div class="grid gap-3 pb-3">
{% for log in logbook %}
{{ log::show_old(log=log, state="completed", only_ones=false, index=loop.index, allowed_to_edit=false) }}
{% endfor %}
<button type="button"
onclick="document.getElementById('call-for-action').showModal()"
class="btn btn-primary">Zu Vereinsmitglied umwandeln</button>
</div>
<dialog id="call-for-action"
class="max-w-screen-sm w-full dark:bg-primary-600 dark:text-white rounded-md"
onclick="document.getElementById('call-for-action').close()">
<div onclick="event.stopPropagation();" class="p-3">
<button type="button"
onclick="document.getElementById('call-for-action').close()"
title="Schließen"
class="sidebar-close border-0 bg-primary-100 focus:bg-primary-50 text-black flex items-center justify-center transform rotate-45 absolute right-0 mr-3">
<svg class="inline h-5 w-5"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"></path>
</svg>
</button>
<div class="mt-8">
<form action="/admin/user/{{ user.id }}/scheckbook-to-regular"
method="post"
enctype="multipart/form-data"
class="grid gap-3">
Type: Select -> normales Mitglied, förderndes Mitglied, unterstützendes Mitglied
{{ 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) }}
<input value="Als neues, reguläres Mitglied anlegen"
type="submit"
class="btn btn-primary" />
</form>
</div>
</div>
</dialog>
{% endif %}
</div>
</div>
{% if is_clubmember %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Rollen</h2>
<div class="mx-2 divide-y divide-gray-200 dark:divide-primary-600">
<div class="py-3">
<ul>
{% for role in user.proper_roles -%} {% for role in user.proper_roles -%}
{% if not role.cluster and not role.hide_in_lists %} {% if not role.cluster and not role.hide_in_lists %}
<li> <li class="flex my-2 w-full justify-between items-center hover:bg-gray-100">
<span>
<strong> <strong>
{% if role.formatted_name %} {% if role.formatted_name %}
{{ role.formatted_name }} {{ role.formatted_name }}
{% else %} {% else %}
{{ role.name }} {{ role.name }}
{% endif %} {% endif %}
</strong> {{ role.desc }} </strong>
<br />
<small>{{ role.desc }}</small>
</span>
{% if allowed_to_edit %} {% if allowed_to_edit %}
<a href="/admin/user/{{ user.id }}/remove-role/{{ role.id }}" <a href="/admin/user/{{ user.id }}/remove-role/{{ role.id }}"
onclick="return confirm('Willst du die Rolle \'{{ role.name }}\' von {{ user.name }} wirklich entfernen?');">🗑️</a> onclick="return confirm('Willst du die Rolle \'{{ role.name }}\' von {{ user.name }} wirklich entfernen?');">🗑️</a>
@ -83,10 +204,11 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
{% if supposed_to_pay %} {% if supposed_to_pay %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5" <div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert"> role="alert">
<h2 class="h2">💸</h2> <h2 class="h2">💸-Beitrag</h2>
<div class="mx-2 divide-y divide-gray-200 dark:divide-primary-600"> <div class="mx-2 divide-y divide-gray-200 dark:divide-primary-600">
<div class="py-3"> <div class="py-3">
{% if fee %} {% if fee %}
@ -120,123 +242,6 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if is_clubmember %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Vereinsmitglied</h2>
<div class="mx-2 divide-y divide-gray-200 dark:divide-primary-600">
<div class="py-3">
<ul class="grid gap-3">
<li>
<form action="/admin/user/{{ user.id }}/change-member-since" method="post">
{{ macros::inputgroup(label='Mitglied seit', name='member_since', type="date", value=user.member_since_date, readonly=not allowed_to_edit) }}
</form>
</li>
<li>
<form action="/admin/user/{{ user.id }}/change-birthdate" method="post">
{{ macros::inputgroup(label='Geburtsdatum', name='birthdate', type="date", value=user.birthdate, readonly=not allowed_to_edit) }}
</form>
</li>
<li>
<form action="/admin/user/{{ user.id }}/change-address" method="post">
{{ macros::inputgroup(label='Adresse', name='address', type="text", value=user.address, readonly=not allowed_to_edit) }}
</form>
</li>
<li>
Familie:
{% for family in families %}
{% if user.family_id == family.id %}{{ family.names }}{% endif %}
{% endfor %}
{% if allowed_to_edit %}
<details>
<summary>✏️</summary>
<form action="/admin/user/{{ user.id }}/change-family" method="post">
{{ 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') }}
<input value="Ändern" type="submit" class="btn btn-primary ml-1" />
</form>
</details>
{% endif %}
</li>
</ul>
</div>
<div class="py-3">
{% if user.membership_pdf %}
<a href="/admin/user/{{ user.id }}/membership"
class="text-black dark:text-white">Beitrittserklärung</a>
{% else %}
⚠️ Aktuell gibt's keine Beitrittserklärung 😢
{% if allowed_to_edit %}
Das kannst du hier ändern ⤵️
<form action="/admin/user/{{ user.id }}/add-membership-pdf"
method="post"
enctype="multipart/form-data">
<fieldset>
{{ macros::input(label='Neue Beitrittserklärung hochladen', name='membership_pdf', type="file", accept='application/pdf') }}
<input value="Hochladen" type="submit" class="btn btn-primary ml-1" />
</fieldset>
</form>
{% endif %}
{% endif %}
</div>
{% if allowed_to_edit %}
<div class="py-3">
<div class="mt-3 text-right">
<a href="/admin/user/{{ user.id }}/delete"
class="btn btn-alert"
onclick="return confirm('Ist {{ user.name }} wirklich aus dem Verein ausgetreten?');">
{% include "includes/delete-icon" %}
Mitglied ist ausgetreten
</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Mitgliedstyp</h2>
<div class="mx-2 divide-y divide-gray-200 dark:divide-primary-600">
<div class="py-3">
{{ user.name }}
{% if "SchnupperInterest" in member %}
ist interessiert am Schnupperkurs.
{% elif "Schnupperant" in member %}
ist beim nächsten Schnupperkurs angemeldet.
{% elif "Scheckbuch" in member %}
{% set logbook = member["Scheckbuch"] %}
hat ein Scheckbuch und {{ logbook | length }} Ausfahrten absolviert.
<details>
<summary>Ausfahrten</summary>
{% for log in logbook %}
{{ log::show_old(log=log, state="completed", only_ones=false, index=loop.index, allowed_to_edit=false) }}
{% endfor %}
</details>
<details>
<summary>Zu reguläres Vereinsmitglied umwandeln</summary>
<form action="/admin/user/{{ user.id }}/scheckbook-to-regular"
method="post"
enctype="multipart/form-data">
{{ macros::input(label='Mitglied seit', name='member_since', type="date", value=now() | date()) }}
{{ macros::input(label='Geburtsdatum', name='birthdate', type="date", value=user.birthdate) }}
{{ macros::input(label='Telefonnummer', name='phone', type="text", value=user.phone) }}
{{ macros::input(label='Adresse', name='address', type="text", value=user.address) }}
{{ macros::input(label='Beitrittserklärung', name='membership_pdf', type="file", accept='application/pdf') }}
<input value="Als neues, reguläres Mitglied anlegen"
type="submit"
class="btn btn-primary ml-1" />
</form>
</details>
{% elif "Regular" in member %}
ist ein reguläres Vereinsmitglied.
{% elif "Foerdernd" in member %}
ist ein förderndes Vereinsmitglied.
{% elif "Unterstuetzend" in member %}
ist ein unterstützendes Vereinsmitglied.
{% endif %}
</div>
</div>
</div>
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5" <div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert"> role="alert">
<h2 class="h2">Aktivität von und mit {{ user.name }}</h2> <h2 class="h2">Aktivität von und mit {{ user.name }}</h2>
@ -325,4 +330,5 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{% endblock content %} {% endblock content %}

View File

@ -174,10 +174,9 @@ function setChoiceByLabel(choicesInstance, label) {
{% if autofocus %}autofocus{% endif %} {% if autofocus %}autofocus{% endif %}
{% if accept %}accept="{{ accept }}"{% endif %} {% if accept %}accept="{{ accept }}"{% endif %}
{% if pattern %}pattern="{{ pattern }}"{% endif %} {% if pattern %}pattern="{{ pattern }}"{% endif %}
{% if readonly %}readonly{% endif %}/> {% if readonly %}readonly{% endif %} />
</div> </div>
{% endmacro input %} {% endmacro input %}
{% macro inputgroup(label, name, type, required=false, class='', value='', min='', hide_label=false, id='', autofocus=false, wrapper_class='', pattern='', readonly=false, accept='') %} {% macro inputgroup(label, name, type, required=false, class='', value='', min='', hide_label=false, id='', autofocus=false, wrapper_class='', pattern='', readonly=false, accept='') %}
<div class="{{ wrapper_class }}"> <div class="{{ wrapper_class }}">
<label for="{{ name }}" <label for="{{ name }}"
@ -197,17 +196,65 @@ function setChoiceByLabel(choicesInstance, label) {
{% if autofocus %}autofocus{% endif %} {% if autofocus %}autofocus{% endif %}
{% if accept %}accept="{{ accept }}"{% endif %} {% if accept %}accept="{{ accept }}"{% endif %}
{% if pattern %}pattern="{{ pattern }}"{% endif %} {% if pattern %}pattern="{{ pattern }}"{% endif %}
readonly/> readonly />
{% if allowed_to_edit %} {% if allowed_to_edit %}
<button type="button" class="btn btn-primary rounded-l-none-important edit-js">Ändern</button> <button type="button" class="btn btn-dark rounded-l-none-important edit-js">{% include "includes/pencil" %}</button>
<input value="x" type="reset" class="edit-js btn btn-alert btn-hidden rounded-none-important"/> <input value="x"
<input value="💾" type="submit" class="btn btn-primary btn-hidden rounded-l-none-important" /> type="reset"
class="edit-js btn btn-alert btn-hidden rounded-none-important" />
<input value="💾"
type="submit"
class="btn btn-primary btn-hidden rounded-l-none-important" />
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endmacro inputgroup %} {% endmacro inputgroup %}
{% macro selectgroup(label, data, name='trip_type', default='', id='', selected_id='', display='', extras='', class='', wrapper_class='', required=false, show_seats=false, new_last_entry='', nonSelectableDefault=false, only_ergo=false, readonly=false) %}
<div class="{{ wrapper_class }}">
<label for="{{ name }}" class="text-sm text-gray-600 dark:text-gray-100">{{ label }}</label>
{% if display == '' %}
{% set display = ["name"] %}
{% endif %}
<div class="input-group">
<select name="{{ name }}"
{% if id %} id="{{ id }}" {% else %} id="{{ name }}" {% endif %}
class="input {% if readonly %}rounded-md{% else %}rounded-l-md{% endif %} {{ class }}"
{% if required %}required="required"{% endif %}
disabled>
{% if default %}<option selected value>{{ default }}</option>{% endif %}
{% if nonSelectableDefault %}<option disabled selected value>{{ nonSelectableDefault }}</option>{% endif %}
{% for d in data %}
<option value="{{ d.id }}"
{% if only_ergo and d.id!=4 %}disabled{% endif %}
{% if d.id == selected_id %}selected{% endif %}
{% if extras != '' %} {% for extra in extras %} {% if extra != 'on_water' and d[extra] %} data- {{ extra }}={{ d[extra] }} {% else %} {% if d[extra] %}disabled{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% if show_seats %} data-custom-properties='{"amount_seats": {{ d["amount_seats"] }}, "owner": "{{ d["owner"] }}", "default_destination": "{{ d["default_destination"] }}", "boat_in_ottensheim": {{ d["location_id"] == 2 }}, "boat_reserved_today": {{ d["reserved_today"] }}, "convert_handoperated_possible": {{ d["convert_handoperated_possible"] }}, "default_handoperated": {{ d["default_shipmaster_only_steering"] }}}' {% endif %}>
{% for displa in display -%}
{%- if d[displa] -%}
{{- d[displa] -}}
{%- else -%}
{{- displa -}}
{%- endif -%}
{%- endfor %}
</option>
{% endfor %}
{% if new_last_entry %}<option value="-1">{{ new_last_entry }}</option>{% endif %}
</select>
{% if allowed_to_edit %}
<button type="button" class="btn btn-dark rounded-l-none-important edit-js">{% include "includes/pencil" %}</button>
<input value="x"
type="reset"
class="edit-js btn btn-alert btn-hidden rounded-none-important" />
<input value="💾"
type="submit"
class="btn btn-primary btn-hidden rounded-l-none-important" />
{% endif %}
</div>
</div>
{% endmacro selectgroup %}
{% macro checkbox(label, name, id='', checked=false, class='', disabled=false, readonly=false) %} {% macro checkbox(label, name, id='', checked=false, class='', disabled=false, readonly=false) %}
<label for="{{ name }}{{ id }}" <label for="{{ name }}{{ id }}"
class="flex items-center cursor-pointer text-black dark:text-white hover:text-gray-900 dark:hover:text-gray-100 {{ class }}"> class="flex items-center cursor-pointer text-black dark:text-white hover:text-gray-900 dark:hover:text-gray-100 {{ class }}">