Be able to update financial and skill; Fixes #974
Some checks failed
CI/CD Pipeline / test (push) Has started running
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled

This commit is contained in:
Philipp Hofer 2025-05-04 10:31:15 +02:00
parent 905178e60d
commit 6362fed909
6 changed files with 286 additions and 75 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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';

View File

@ -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,14 @@
<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 +413,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">
&bullet; <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">