more-activities #1035

Merged
philipp merged 5 commits from more-activities into staging 2025-05-17 09:50:04 +02:00
14 changed files with 141 additions and 103 deletions

View File

@ -17,10 +17,31 @@ pub struct Activity {
pub keep_until: Option<NaiveDateTime>, pub keep_until: Option<NaiveDateTime>,
} }
#[derive(Serialize, Deserialize, Debug)]
pub struct ActivityWithDetails {
#[serde(flatten)]
pub(crate) activity: Activity,
keep_until_days: Option<i64>,
}
impl From<Activity> for ActivityWithDetails {
fn from(activity: Activity) -> Self {
let keep_until_days = activity.keep_until.map(|keep_until| {
let now = Utc::now().naive_utc();
let duration = keep_until.signed_duration_since(now);
duration.num_days()
});
Self {
keep_until_days,
activity,
}
}
}
// TODO: add `reason` as additional db field, to be able to query and show this to the users // TODO: add `reason` as additional db field, to be able to query and show this to the users
pub enum Reason<'a> { pub enum Reason<'a> {
// `User` tried to login with `String` as UserAgent Auth(ReasonAuth<'a>),
SuccLogin(&'a User, String),
// `User` changed the data of `User`, explanation in `String` // `User` changed the data of `User`, explanation in `String`
UserDataChange(&'a ManageUserUser, &'a User, String), UserDataChange(&'a ManageUserUser, &'a User, String),
// New Note for User // New Note for User
@ -30,11 +51,7 @@ pub enum Reason<'a> {
impl From<Reason<'_>> for ActivityBuilder { impl From<Reason<'_>> for ActivityBuilder {
fn from(value: Reason<'_>) -> Self { fn from(value: Reason<'_>) -> Self {
match value { match value {
Reason::SuccLogin(user, agent) => { Reason::Auth(auth) => auth.into(),
Self::new(&format!("{user} hat sich eingeloggt (User-Agent: {agent})"))
.relevant_for_user(user)
.keep_until_days(7)
}
Reason::UserDataChange(changed_by, changed_user, explanation) => Self::new(&format!( Reason::UserDataChange(changed_by, changed_user, explanation) => Self::new(&format!(
"{changed_by} hat die Daten von {changed_user} aktualisiert: {explanation}" "{changed_by} hat die Daten von {changed_user} aktualisiert: {explanation}"
)) ))
@ -46,6 +63,43 @@ impl From<Reason<'_>> for ActivityBuilder {
} }
} }
pub enum ReasonAuth<'a> {
// `User` tried to login with `String` as UserAgent
SuccLogin(&'a User, String),
// `User` tried to login which was already deleted
DeletedUserLogin(&'a User),
// `User` tried to login, supplied wrong PW
WrongPw(&'a User),
}
impl<'a> From<ReasonAuth<'a>> for Reason<'a> {
fn from(auth_reason: ReasonAuth<'a>) -> Self {
Reason::Auth(auth_reason)
}
}
impl From<ReasonAuth<'_>> for ActivityBuilder {
fn from(value: ReasonAuth<'_>) -> Self {
match value {
ReasonAuth::SuccLogin(user, agent) => {
Self::new(&format!("{user} hat sich eingeloggt (User-Agent: {agent})"))
.relevant_for_user(user)
.keep_until_days(7)
}
ReasonAuth::DeletedUserLogin(user) => Self::new(&format!(
"User {user} wollte sich einloggen, klappte jedoch nicht weil er gelöscht wurde."
))
.relevant_for_user(user)
.keep_until_days(30),
ReasonAuth::WrongPw(user) => Self::new(&format!(
"User {user} wollte sich einloggen, hat jedoch das falsche Passwort angegeben."
))
.relevant_for_user(user)
.keep_until_days(7),
}
}
}
pub struct ActivityBuilder { pub struct ActivityBuilder {
text: String, text: String,
relevant_for: String, relevant_for: String,

View File

@ -1,8 +1,8 @@
use std::ops::DerefMut; use std::ops::DerefMut;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use rocket::serde::{Deserialize, Serialize};
use rocket::FromForm; use rocket::FromForm;
use rocket::serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
use crate::model::boathouse::Boathouse; use crate::model::boathouse::Boathouse;

View File

@ -1,7 +1,7 @@
use std::ops::DerefMut; use std::ops::DerefMut;
use serde::Serialize; use serde::Serialize;
use sqlx::{sqlite::SqliteQueryResult, FromRow, Sqlite, SqlitePool, Transaction}; use sqlx::{FromRow, Sqlite, SqlitePool, Transaction, sqlite::SqliteQueryResult};
use super::user::User; use super::user::User;

View File

@ -1,9 +1,9 @@
use std::{error::Error, fs}; use std::{error::Error, fs};
use lettre::{ use lettre::{
message::{header::ContentType, Attachment, MultiPart, SinglePart},
transport::smtp::authentication::Credentials,
Address, Message, SmtpTransport, Transport, Address, Message, SmtpTransport, Transport,
message::{Attachment, MultiPart, SinglePart, header::ContentType},
transport::smtp::authentication::Credentials,
}; };
use sqlx::{Sqlite, SqlitePool, Transaction}; use sqlx::{Sqlite, SqlitePool, Transaction};

View File

@ -1,8 +1,8 @@
use super::User; use super::User;
use crate::{ use crate::{
model::family::Family, BOAT_STORAGE, DUAL_MEMBERSHIP, EINSCHREIBGEBUEHR, FAMILY_THREE_OR_MORE, BOAT_STORAGE, DUAL_MEMBERSHIP, EINSCHREIBGEBUEHR, FAMILY_THREE_OR_MORE, FAMILY_TWO, FOERDERND,
FAMILY_TWO, FOERDERND, REGULAR, RENNRUDERBEITRAG, STUDENT_OR_PUPIL, TRIAL_ROWING, REGULAR, RENNRUDERBEITRAG, STUDENT_OR_PUPIL, TRIAL_ROWING, TRIAL_ROWING_REDUCED,
TRIAL_ROWING_REDUCED, UNTERSTUETZEND, UNTERSTUETZEND, model::family::Family,
}; };
use chrono::{Datelike, Local, NaiveDate}; use chrono::{Datelike, Local, NaiveDate};
use serde::Serialize; use serde::Serialize;

View File

@ -1,20 +1,21 @@
use std::{fmt::Display, ops::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 chrono::{Datelike, Local, NaiveDate};
use log::info; use log::info;
use rocket::async_trait; use rocket::async_trait;
use rocket::{ use rocket::{
Request,
http::{Cookie, Status}, http::{Cookie, Status},
request::{FromRequest, Outcome}, request::{FromRequest, Outcome},
time::{Duration, OffsetDateTime}, time::{Duration, OffsetDateTime},
Request,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
use super::activity::ActivityBuilder; use super::activity::{ActivityBuilder, ReasonAuth};
use super::{ use super::{
Day,
log::Log, log::Log,
logbook::Logbook, logbook::Logbook,
mail::Mail, mail::Mail,
@ -23,7 +24,6 @@ use super::{
role::Role, role::Role,
stat::Stat, stat::Stat,
tripdetails::TripDetails, tripdetails::TripDetails,
Day,
}; };
use crate::AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD; use crate::AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD;
use scheckbuch::ScheckbuchUser; use scheckbuch::ScheckbuchUser;
@ -465,49 +465,25 @@ ASKÖ Ruderverein Donau Linz", self.name),
pub async fn login(db: &SqlitePool, name: &str, pw: &str) -> Result<Self, LoginError> { pub async fn login(db: &SqlitePool, name: &str, pw: &str) -> Result<Self, LoginError> {
let name = name.trim().to_lowercase(); // just to make sure... let name = name.trim().to_lowercase(); // just to make sure...
let Some(user) = User::find_by_name(db, &name).await else { let Some(user) = User::find_by_name(db, &name).await else {
if ![
"n-sageder",
"p-hofer",
"marie-birner",
"daniel-kortschak",
"rudernlinz",
"m-birner",
"s-sollberger",
"d-kortschak",
"wwwadmin",
"wadminw",
"admin",
"m sageder",
"d kortschak",
"a almousa",
"p hofer",
"s sollberger",
"n sageder",
"wp-system",
"s.sollberger",
"m.birner",
"m-sageder",
"a-almousa",
"m.sageder",
"n.sageder",
"a.almousa",
"p.hofer",
"philipp-hofer",
"d.kortschak",
"[login]",
]
.contains(&name.as_str())
{
Log::create(db, format!("Username ({name}) not found (tried to login)")).await; Log::create(db, format!("Username ({name}) not found (tried to login)")).await;
}
return Err(LoginError::InvalidAuthenticationCombo); // Username not found return Err(LoginError::InvalidAuthenticationCombo); // Username not found
}; };
if user.deleted { if user.deleted {
ActivityBuilder::new(&format!( if let Some(board) = Role::find_by_name(db, "Vorstand").await {
Notification::create_for_role(
db,
&board,
&format!(
"User {user} wollte sich einloggen, klappte jedoch nicht weil er gelöscht wurde." "User {user} wollte sich einloggen, klappte jedoch nicht weil er gelöscht wurde."
)) ),
.relevant_for_user(&user) "Fehlgeschlagener Login",
None,
None,
)
.await;
}
ActivityBuilder::from(ReasonAuth::DeletedUserLogin(&user))
.save(db) .save(db)
.await; .await;
return Err(LoginError::InvalidAuthenticationCombo); //User existed sometime ago; has return Err(LoginError::InvalidAuthenticationCombo); //User existed sometime ago; has
@ -519,10 +495,7 @@ ASKÖ Ruderverein Donau Linz", self.name),
if password_hash == user_pw { if password_hash == user_pw {
return Ok(user); return Ok(user);
} }
ActivityBuilder::new(&format!( ActivityBuilder::from(ReasonAuth::WrongPw(&user))
"User {user} wollte sich einloggen, hat jedoch das falsche Passwort angegeben."
))
.relevant_for_user(&user)
.save(db) .save(db)
.await; .await;
Err(LoginError::InvalidAuthenticationCombo) Err(LoginError::InvalidAuthenticationCombo)
@ -862,8 +835,8 @@ special_user!(SteeringUser, +"cox", +"Bootsführer");
special_user!(AdminUser, +"admin"); special_user!(AdminUser, +"admin");
special_user!(AllowedForPlannedTripsUser, +"Donau Linz", +"scheckbuch", +"Förderndes Mitglied"); special_user!(AllowedForPlannedTripsUser, +"Donau Linz", +"scheckbuch", +"Förderndes Mitglied");
special_user!(DonauLinzUser, +"Donau Linz", -"Unterstützend", -"Förderndes Mitglied"); // TODO: special_user!(DonauLinzUser, +"Donau Linz", -"Unterstützend", -"Förderndes Mitglied"); // TODO:
// remove -> // remove ->
// RegularUser // 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");
@ -977,17 +950,21 @@ mod test {
#[sqlx::test] #[sqlx::test]
fn wrong_pw() { fn wrong_pw() {
let pool = testdb!(); let pool = testdb!();
assert!(User::login(&pool, "admin".into(), "admi".into()) assert!(
User::login(&pool, "admin".into(), "admi".into())
.await .await
.is_err()); .is_err()
);
} }
#[sqlx::test] #[sqlx::test]
fn wrong_username() { fn wrong_username() {
let pool = testdb!(); let pool = testdb!();
assert!(User::login(&pool, "admi".into(), "admin".into()) assert!(
User::login(&pool, "admi".into(), "admin".into())
.await .await
.is_err()); .is_err()
);
} }
#[sqlx::test] #[sqlx::test]
@ -1007,9 +984,11 @@ mod test {
let pool = testdb!(); let pool = testdb!();
let user = User::find_by_id(&pool, 1).await.unwrap(); let user = User::find_by_id(&pool, 1).await.unwrap();
assert!(User::login(&pool, "admin".into(), "abc".into()) assert!(
User::login(&pool, "admin".into(), "abc".into())
.await .await
.is_err()); .is_err()
);
user.update_pw(&pool, "abc".into()).await; user.update_pw(&pool, "abc".into()).await;

View File

@ -1,7 +1,8 @@
use super::{ManageUserUser, User}; use super::{ManageUserUser, User};
use crate::{ use crate::{
NonEmptyString,
model::{activity::ActivityBuilder, mail::Mail, notification::Notification, role::Role}, model::{activity::ActivityBuilder, mail::Mail, notification::Notification, role::Role},
special_user, NonEmptyString, special_user,
}; };
use chrono::NaiveDate; use chrono::NaiveDate;
use rocket::{async_trait, fs::TempFile, tokio::io::AsyncReadExt}; use rocket::{async_trait, fs::TempFile, tokio::io::AsyncReadExt};

View File

@ -2,12 +2,13 @@ use super::foerdernd::FoerderndUser;
use super::regular::RegularUser; use super::regular::RegularUser;
use super::unterstuetzend::UnterstuetzendUser; use super::unterstuetzend::UnterstuetzendUser;
use super::{ManageUserUser, User}; use super::{ManageUserUser, User};
use crate::NonEmptyString;
use crate::model::activity::ActivityBuilder; use crate::model::activity::ActivityBuilder;
use crate::model::role::Role; use crate::model::role::Role;
use crate::NonEmptyString;
use crate::{ use crate::{
SCHECKBUCH,
model::{mail::Mail, notification::Notification}, model::{mail::Mail, notification::Notification},
special_user, SCHECKBUCH, special_user,
}; };
use chrono::NaiveDate; use chrono::NaiveDate;
use rocket::async_trait; use rocket::async_trait;

View File

@ -4,9 +4,9 @@ use super::scheckbuch::ScheckbuchUser;
use super::schnupperinterest::SchnupperInterestUser; use super::schnupperinterest::SchnupperInterestUser;
use super::unterstuetzend::UnterstuetzendUser; use super::unterstuetzend::UnterstuetzendUser;
use super::{ManageUserUser, User}; use super::{ManageUserUser, User};
use crate::NonEmptyString;
use crate::model::activity::ActivityBuilder; use crate::model::activity::ActivityBuilder;
use crate::model::role::Role; use crate::model::role::Role;
use crate::NonEmptyString;
use crate::{ use crate::{
model::{mail::Mail, notification::Notification}, model::{mail::Mail, notification::Notification},
special_user, special_user,

View File

@ -1,6 +1,6 @@
use csv::ReaderBuilder; use csv::ReaderBuilder;
use rocket::{form::Form, get, post, routes, FromForm, Route, State}; use rocket::{FromForm, Route, State, form::Form, get, post, routes};
use rocket_dyn_templates::{context, Template}; use rocket_dyn_templates::{Template, context};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use crate::model::{activity::Activity, role::Role, user::AdminUser}; use crate::model::{activity::Activity, role::Role, user::AdminUser};

View File

@ -1,17 +1,17 @@
use crate::{ use crate::{
model::{ model::{
activity::Activity, activity::{Activity, ActivityWithDetails},
family::Family, family::Family,
log::Log, log::Log,
logbook::Logbook, logbook::Logbook,
mail::valid_mails, mail::valid_mails,
role::Role, role::Role,
user::{ user::{
AdminUser, AllowedToEditPaymentStatusUser, ManageUserUser, User, UserWithDetails,
UserWithMembershipPdf, UserWithRolesAndMembershipPdf, VorstandUser,
clubmember::ClubMemberUser, foerdernd::FoerderndUser, member::Member, clubmember::ClubMemberUser, foerdernd::FoerderndUser, member::Member,
regular::RegularUser, scheckbuch::ScheckbuchUser, schnupperant::SchnupperantUser, regular::RegularUser, scheckbuch::ScheckbuchUser, schnupperant::SchnupperantUser,
schnupperinterest::SchnupperInterestUser, unterstuetzend::UnterstuetzendUser, schnupperinterest::SchnupperInterestUser, unterstuetzend::UnterstuetzendUser,
AdminUser, AllowedToEditPaymentStatusUser, ManageUserUser, User, UserWithDetails,
UserWithMembershipPdf, UserWithRolesAndMembershipPdf, VorstandUser,
}, },
}, },
tera::Config, tera::Config,
@ -19,6 +19,7 @@ use crate::{
use chrono::NaiveDate; use chrono::NaiveDate;
use futures::future::join_all; use futures::future::join_all;
use rocket::{ use rocket::{
FromForm, Request, Route, State,
form::Form, form::Form,
fs::TempFile, fs::TempFile,
get, get,
@ -26,9 +27,9 @@ use rocket::{
post, post,
request::{FlashMessage, FromRequest, Outcome}, request::{FlashMessage, FromRequest, Outcome},
response::{Flash, Redirect}, 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; use sqlx::SqlitePool;
// Custom request guard to extract the Referer header // Custom request guard to extract the Referer header
@ -141,7 +142,11 @@ async fn view(
let member = Member::from(db, user.clone()).await; let member = Member::from(db, user.clone()).await;
let fee = user.fee(db).await; let fee = user.fee(db).await;
let activities = Activity::for_user(db, &user).await; let activities: Vec<ActivityWithDetails> = Activity::for_user(db, &user)
.await
.into_iter()
.map(Into::into)
.collect();
let financial = Role::all_cluster(db, "financial").await; let financial = Role::all_cluster(db, "financial").await;
let user_financial = user.financial(db).await; let user_financial = user.financial(db).await;
let skill = Role::all_cluster(db, "skill").await; let skill = Role::all_cluster(db, "skill").await;

View File

@ -1,4 +1,5 @@
use rocket::{ use rocket::{
FromForm, Request, Route, State,
form::Form, form::Form,
get, get,
http::{Cookie, CookieJar}, http::{Cookie, CookieJar},
@ -8,13 +9,12 @@ use rocket::{
response::{Flash, Redirect}, response::{Flash, Redirect},
routes, routes,
time::{Duration, OffsetDateTime}, 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 sqlx::SqlitePool;
use crate::model::{ use crate::model::{
activity::{self, ActivityBuilder}, activity::{self, ActivityBuilder, ReasonAuth},
log::Log, log::Log,
user::{LoginError, User}, user::{LoginError, User},
}; };
@ -83,7 +83,7 @@ async fn login(
cookies.add_private(Cookie::new("loggedin_user", format!("{}", user.id))); cookies.add_private(Cookie::new("loggedin_user", format!("{}", user.id)));
ActivityBuilder::from(activity::Reason::SuccLogin(&user, agent.0)) ActivityBuilder::from(ReasonAuth::SuccLogin(&user, agent.0))
.save(db) .save(db)
.await; .await;

View File

@ -3,7 +3,3 @@ INSERT INTO user(name) VALUES('Marie');
INSERT INTO "user_role" (user_id, role_id) VALUES((SELECT id from user where name = 'Marie'),(SELECT id FROM role where name = 'Donau Linz')); INSERT INTO "user_role" (user_id, role_id) VALUES((SELECT id from user where name = 'Marie'),(SELECT id FROM role where name = 'Donau Linz'));
INSERT INTO user(name) VALUES('Philipp'); INSERT INTO user(name) VALUES('Philipp');
INSERT INTO "user_role" (user_id, role_id) VALUES((SELECT id from user where name = 'Philipp'),(SELECT id FROM role where name = 'Donau Linz')); INSERT INTO "user_role" (user_id, role_id) VALUES((SELECT id from user where name = 'Philipp'),(SELECT id FROM role where name = 'Donau Linz'));
insert into role(name, cluster, formatted_name) values('dual_membership', 'financial', 'Doppelmitgliedschaft mit anderem österr. Ruderverein');
insert into role(name, hide_in_lists) values('participated_schnupperkurs', true);

View File

@ -411,7 +411,9 @@
<ul class="list-disc ms-4"> <ul class="list-disc ms-4">
{% for activity in activities %} {% for activity in activities %}
<li> <li>
<strong>{{ activity.created_at | date(format="%d. %m. %Y") }}:</strong> <small>{{ activity.text }}</small> <strong>{{ activity.created_at | date(format="%d. %m. %Y") }}:</strong> <small>{{ activity.text }}
{% if activity.keep_until_days %}(⏳ {{ activity.keep_until_days }} Tage){% endif %}
</small>
</li> </li>
{% else %} {% else %}
<li>Noch keine Aktivität... Stay tuned 😆</li> <li>Noch keine Aktivität... Stay tuned 😆</li>