single-user-edit-page #975
| @@ -1,4 +1,4 @@ | ||||
| use std::{fmt::Display, ops::DerefMut}; | ||||
| use std::{cmp::Ordering, fmt::Display, ops::DerefMut}; | ||||
|  | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; | ||||
| @@ -13,6 +13,30 @@ pub struct Role { | ||||
|     pub(crate) cluster: Option<String>, | ||||
| } | ||||
|  | ||||
| // 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<Ordering> { | ||||
|         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) | ||||
| @@ -30,6 +54,27 @@ impl Role { | ||||
|         .unwrap() | ||||
|     } | ||||
|  | ||||
|     pub async fn all_cluster(db: &SqlitePool, cluster: &str) -> Vec<Role> { | ||||
|         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<Self> { | ||||
|         sqlx::query_as!( | ||||
|             Self, | ||||
| @@ -59,21 +104,6 @@ WHERE id like ? | ||||
|         .ok() | ||||
|     } | ||||
|  | ||||
|     pub async fn find_by_cluster_tx(db: &mut Transaction<'_, Sqlite>, name: i32) -> Option<Self> { | ||||
|         sqlx::query_as!( | ||||
|             Self, | ||||
|             " | ||||
| SELECT id, name, formatted_name, desc, hide_in_lists, cluster | ||||
| FROM role  | ||||
| WHERE cluster = ? | ||||
|         ", | ||||
|             name | ||||
|         ) | ||||
|         .fetch_one(db.deref_mut()) | ||||
|         .await | ||||
|         .ok() | ||||
|     } | ||||
|  | ||||
|     pub async fn find_by_name(db: &SqlitePool, name: &str) -> Option<Self> { | ||||
|         sqlx::query_as!( | ||||
|             Self, | ||||
|   | ||||
| @@ -1,7 +1,10 @@ | ||||
| // TODO: put back in `src/model/user/mod.rs` once that is cleaned up | ||||
|  | ||||
| use super::{AllowedToEditPaymentStatusUser, ManageUserUser, User}; | ||||
| use crate::model::{activity::Activity, family::Family, log::Log, mail::valid_mails, role::Role}; | ||||
| use crate::model::{ | ||||
|     activity::Activity, family::Family, log::Log, mail::valid_mails, notification::Notification, | ||||
|     role::Role, | ||||
| }; | ||||
| use chrono::NaiveDate; | ||||
| use rocket::{fs::TempFile, tokio::io::AsyncReadExt}; | ||||
| use sqlx::SqlitePool; | ||||
| @@ -228,6 +231,103 @@ impl User { | ||||
|         .await; | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn change_skill( | ||||
|         &self, | ||||
|         db: &SqlitePool, | ||||
|         updated_by: &ManageUserUser, | ||||
|         skill: Option<Role>, | ||||
|     ) -> 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) { | ||||
|             (old, new) if old == None && 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; | ||||
|             } | ||||
|             (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; | ||||
|             } | ||||
|             (old, new) if new == 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; | ||||
|                 } | ||||
|             } | ||||
|             (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<Role>, | ||||
|     ) -> 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"); | ||||
|         } | ||||
|  | ||||
|         Activity::create( | ||||
|             db, | ||||
|             &format!("({updated_by}) Ermäßigung von {self} von {old} auf {new} geändert"), | ||||
|             &format!("user-{};", self.id), | ||||
|             None, | ||||
|         ) | ||||
|         .await; | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn remove_role( | ||||
|         &self, | ||||
|         db: &SqlitePool, | ||||
|   | ||||
| @@ -259,6 +259,39 @@ ASKÖ Ruderverein Donau Linz", self.name), | ||||
|         .into_iter().map(|r| r.name).collect() | ||||
|     } | ||||
|  | ||||
|     pub async fn financial(&self, db: &SqlitePool) -> Option<Role> { | ||||
|         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<Role> { | ||||
|         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<Role> { | ||||
|         sqlx::query_as!( | ||||
|             Role, | ||||
|   | ||||
| @@ -130,6 +130,10 @@ async fn view( | ||||
|     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; | ||||
|  | ||||
| @@ -148,6 +152,10 @@ async fn view( | ||||
|     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); | ||||
| @@ -456,6 +464,86 @@ async fn update_family( | ||||
|     ) | ||||
| } | ||||
|  | ||||
| #[derive(FromForm, Debug)] | ||||
| pub struct ChangeSkillForm { | ||||
|     skill_id: String, | ||||
| } | ||||
|  | ||||
| #[post("/user/<id>/change-skill", data = "<data>")] | ||||
| async fn change_skill( | ||||
|     db: &State<SqlitePool>, | ||||
|     data: Form<ChangeSkillForm>, | ||||
|     admin: ManageUserUser, | ||||
|     id: i32, | ||||
| ) -> Flash<Redirect> { | ||||
|     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 == "" { | ||||
|         None | ||||
|     } else { | ||||
|         let Ok(skill_id) = data.skill_id.parse() else { | ||||
|             return Flash::error( | ||||
|                 Redirect::to(format!("/admin/user/{id}")), | ||||
|                 format!("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/<id>/change-financial", data = "<data>")] | ||||
| async fn change_financial( | ||||
|     db: &State<SqlitePool>, | ||||
|     data: Form<ChangeFinancialForm>, | ||||
|     admin: ManageUserUser, | ||||
|     id: i32, | ||||
| ) -> Flash<Redirect> { | ||||
|     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 == "" { | ||||
|         None | ||||
|     } else { | ||||
|         let Ok(financial_id) = data.financial_id.parse() else { | ||||
|             return Flash::error( | ||||
|                 Redirect::to(format!("/admin/user/{id}")), | ||||
|                 format!("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>, | ||||
| @@ -1176,6 +1264,8 @@ pub fn routes() -> Vec<Route> { | ||||
|         update_birthdate, | ||||
|         update_address, | ||||
|         update_family, | ||||
|         change_skill, | ||||
|         change_financial, | ||||
|         add_membership_pdf, | ||||
|         add_role, | ||||
|         add_note, | ||||
|   | ||||
| @@ -33,3 +33,4 @@ CREATE TABLE activity ( | ||||
|     relevant_for TEXT NOT NULL,  -- e.g. user_id=123;trip_id=456 | ||||
|     keep_until DATETIME          -- OPTIONAL field | ||||
| ); | ||||
| delete from role where name='Anwärter'; | ||||
|   | ||||
| @@ -31,6 +31,13 @@ | ||||
|                         <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) }} | ||||
|                         </form> | ||||
|                         <form action="/admin/user/{{ user.id }}/change-financial" method="post"> | ||||
|                             {% 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 %} | ||||
|                         </form> | ||||
|                         {% if allowed_to_edit %} | ||||
|                             <form action="/admin/user/{{ user.id }}/new-note" method="post"> | ||||
|                                 {{ macros::inputgroup(label='Neue Notiz', name='note', type="text") }} | ||||
| @@ -79,6 +86,13 @@ | ||||
|                             <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-skill" method="post"> | ||||
|                                 {% 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 %} | ||||
|                             </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> | ||||
| @@ -398,64 +412,6 @@ | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"> | ||||
|                 <h2 class="h2">TODO</h2> | ||||
|                 <div class="border-t bg-white dark:bg-primary-900 py-3 px-4 relative"> | ||||
|                     <span class="text-black dark:text-white cursor-pointer"> | ||||
|                         <span class="font-bold"> | ||||
|                             {{ user.name }} | ||||
|                             {% if not user.last_access and allowed_to_edit and user.mail %} | ||||
|                                 <form action="/admin/user" | ||||
|                                       method="post" | ||||
|                                       enctype="multipart/form-data" | ||||
|                                       class="inline"> | ||||
|                                     • <a class="font-normal text-primary-600 dark:text-primary-200 hover:text-primary-900 dark:hover:text-primary-300 underline" | ||||
|     href="/admin/user/{{ user.id }}/send-welcome-mail" | ||||
|     onclick="return confirm('Willst du wirklich das Willkommensmail an {{ user.name }} ausschicken?');">Willkommensmail verschicken</a> | ||||
|                                 </form> | ||||
|                             {% endif %} | ||||
|                         </span> | ||||
|                     </span> | ||||
|                     <form action="/admin/user" | ||||
|                           method="post" | ||||
|                           enctype="multipart/form-data" | ||||
|                           class="w-full mt-2"> | ||||
|                         <div class="w-full grid gap-3 mt-3"> | ||||
|                             <input type="hidden" name="id" value="{{ user.id }}" /> | ||||
|                             <div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-3"> | ||||
|                                 {% for cluster, cluster_roles in roles | group_by(attribute="cluster") %} | ||||
|                                     <label for="cluster_{{ loop.index }}">{{ cluster }}</label> | ||||
|                                     {# 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 #} | ||||
|                                     <select id="cluster_{{ loop.index }}" | ||||
|                                             {% if selected_role_id == 'none' %} {% else %} name="roles[{{ selected_role_id }}]" {% endif %} | ||||
|                                             {% if allowed_to_edit == false %}disabled{% endif %} | ||||
|                                             onchange=" if (this.value === '') { this.removeAttribute('name'); } else { this.name = 'roles[' + this.options[this.selectedIndex].getAttribute('data-role-id') + ']'; }"> | ||||
|                                         <option value="" | ||||
|                                                 data-role-id="none" | ||||
|                                                 {% if selected_role_id == 'none' %}selected="selected"{% endif %}> | ||||
|                                             None | ||||
|                                         </option> | ||||
|                                         {% for role in cluster_roles %} | ||||
|                                             <option value="on" | ||||
|                                                     data-role-id="{{ role.id }}" | ||||
|                                                     {% if role.id == selected_role_id %}selected="selected"{% endif %}> | ||||
|                                                 {{ role.name }} | ||||
|                                             </option> | ||||
|                                         {% endfor %} | ||||
|                                     </select> | ||||
|                                 {% endfor %} | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </form> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"> | ||||
|                 <h2 class="h2">Ergo-Challenge</h2> | ||||
|                 <div class="mx-3 divide-y divide-gray-200 dark:divide-primary-600"> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user