Merge remote-tracking branch 'upstream/main' into upd

This commit is contained in:
Philipp Hofer 2025-04-03 16:58:43 +02:00
commit ab7ec637ab
18 changed files with 1165 additions and 623 deletions

1481
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
[package] [package]
name = "rot" name = "rot"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[features] [features]
default = ["rest", "rowing-tera" ] default = ["rest", "rowing-tera" ]
@ -13,20 +13,20 @@ rocket = { version = "0.5.0", features = ["secrets"]}
rocket_dyn_templates = {version = "0.2", features = [ "tera" ], optional = true } rocket_dyn_templates = {version = "0.2", features = [ "tera" ], optional = true }
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "macros", "chrono", "time"] } sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "macros", "chrono"] }
argon2 = "0.5" argon2 = "0.5"
serde = { version = "1.0", features = [ "derive" ]} serde = { version = "1.0", features = [ "derive" ]}
serde_json = "1.0" serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"]} chrono = { version = "0.4", features = ["serde"]}
chrono-tz = "0.9" chrono-tz = "0.10"
tera = { version = "1.18", features = ["date-locale"], optional = true} tera = { version = "1.18", features = ["date-locale"], optional = true}
ics = "0.5" ics = "0.5"
futures = "0.3" futures = "0.3"
lettre = "0.11" lettre = "0.11"
csv = "1.3" csv = "1.3"
itertools = "0.13" itertools = "0.14"
job_scheduler_ng = "2.0" job_scheduler_ng = "2.0"
ureq = { version = "2.9", features = ["json"] } ureq = { version = "3.0", features = ["json"] }
regex = "1.10" regex = "1.10"
urlencoding = "2.1" urlencoding = "2.1"

View File

@ -23,6 +23,7 @@ document.addEventListener("DOMContentLoaded", function () {
addRelationMagic(<HTMLElement>document.querySelector("body")); addRelationMagic(<HTMLElement>document.querySelector("body"));
reloadPage(); reloadPage();
setCurrentdate(<HTMLInputElement>document.querySelector("#departure")); setCurrentdate(<HTMLInputElement>document.querySelector("#departure"));
initDropdown();
}); });
function changeTheme() { function changeTheme() {
@ -795,3 +796,21 @@ function replaceStrings() {
weekday.innerHTML = weekday.innerHTML.replace("Freitag", "Markttag"); weekday.innerHTML = weekday.innerHTML.replace("Freitag", "Markttag");
}); });
} }
function initDropdown() {
const popoverTriggerList = document.querySelectorAll('[data-dropdown]');
popoverTriggerList.forEach((popoverTriggerEl: Element) => {
const id = popoverTriggerEl.getAttribute('data-dropdown');
if (id) {
const element = document.getElementById(id);
if (element) {
// Toggle visibility of the dropdown when clicked
popoverTriggerEl.addEventListener('click', () => {
element.classList.toggle('hidden');
});
}
}
});
}

View File

@ -53,6 +53,7 @@ INSERT INTO "planned_event" (name, planned_amount_cox, trip_details_id) VALUES('
INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('11:00', 1, date('now', '+1 day'), 'trip_details for trip from cox'); INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('11:00', 1, date('now', '+1 day'), 'trip_details for trip from cox');
INSERT INTO "trip" (cox_id, trip_details_id) VALUES(4, 2); INSERT INTO "trip" (cox_id, trip_details_id) VALUES(4, 2);
INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('10:00', 2, date('now'), 'same trip_details as id=1');
INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Regatta', 'Regatta!', 'Kein normales Event. Das ist eine Regatta! Willst du wirklich teilnehmen?', '&#127941;'); INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Regatta', 'Regatta!', 'Kein normales Event. Das ist eine Regatta! Willst du wirklich teilnehmen?', '&#127941;');
INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Lange Ausfahrt', 'Lange Ausfahrt!', 'Das ist eine lange Ausfahrt! Willst du wirklich teilnehmen?', '&#128170;'); INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Lange Ausfahrt', 'Lange Ausfahrt!', 'Das ist eine lange Ausfahrt! Willst du wirklich teilnehmen?', '&#128170;');
INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Wanderfahrt', 'Wanderfahrt!', 'Kein normales Event. Das ist eine Wanderfahrt! Bitte überprüfe ob du alle Anforderungen erfüllst. Willst du wirklich teilnehmen?', '&#9969;'); INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Wanderfahrt', 'Wanderfahrt!', 'Kein normales Event. Das ist eine Wanderfahrt! Bitte überprüfe ob du alle Anforderungen erfüllst. Willst du wirklich teilnehmen?', '&#9969;');

View File

@ -96,8 +96,8 @@ FROM trip WHERE planned_event_id = ?
.unwrap() .unwrap()
.into_iter() .into_iter()
.map(|r| Registration { .map(|r| Registration {
name: r.name, name: r.name.unwrap(),
registered_at: r.registered_at, registered_at: r.registered_at.unwrap(),
is_guest: false, is_guest: false,
is_real_guest: false, is_real_guest: false,
}) })

View File

@ -11,6 +11,8 @@ use self::{
waterlevel::Waterlevel, waterlevel::Waterlevel,
weather::Weather, weather::Weather,
}; };
use boatreservation::{BoatReservation, BoatReservationWithDetails};
use std::collections::HashMap;
pub mod event; pub mod event;
pub mod log; pub mod log;
@ -34,6 +36,7 @@ pub struct Day {
regular_sees_this_day: bool, regular_sees_this_day: bool,
max_waterlevel: Option<WaterlevelDay>, max_waterlevel: Option<WaterlevelDay>,
weather: Option<Weather>, weather: Option<Weather>,
boat_reservations: HashMap<String, Vec<BoatReservationWithDetails>>,
} }
impl Day { impl Day {
@ -50,6 +53,9 @@ impl Day {
regular_sees_this_day, regular_sees_this_day,
max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await, max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await,
weather: Weather::find_by_day(db, day).await, weather: Weather::find_by_day(db, day).await,
boat_reservations: BoatReservation::with_groups(
BoatReservation::for_day(db, day).await,
),
} }
} else { } else {
Self { Self {
@ -60,6 +66,9 @@ impl Day {
regular_sees_this_day, regular_sees_this_day,
max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await, max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await,
weather: Weather::find_by_day(db, day).await, weather: Weather::find_by_day(db, day).await,
boat_reservations: BoatReservation::with_groups(
BoatReservation::for_day(db, day).await,
),
} }
} }
} }

View File

@ -208,6 +208,15 @@ ORDER BY read_at DESC, created_at DESC;
} }
} }
} }
pub(crate) async fn mark_all_read(db: &SqlitePool, user: &User) {
let notifications = Self::for_user(db, user).await;
for notification in notifications {
notification.mark_read(db).await;
}
}
pub(crate) async fn delete_by_action(db: &sqlx::Pool<Sqlite>, action: &str) { pub(crate) async fn delete_by_action(db: &sqlx::Pool<Sqlite>, action: &str) {
sqlx::query!( sqlx::query!(
"DELETE FROM notification WHERE action_after_reading=? and read_at is null", "DELETE FROM notification WHERE action_after_reading=? and read_at is null",
@ -289,7 +298,7 @@ mod test {
assert_eq!(rower_notification.category, "Absage Ausfahrt"); assert_eq!(rower_notification.category, "Absage Ausfahrt");
assert_eq!( assert_eq!(
rower_notification.action_after_reading.as_deref(), rower_notification.action_after_reading.as_deref(),
Some("remove_user_trip_with_trip_details_id:3") Some("remove_user_trip_with_trip_details_id:4")
); );
// Cox received notification // Cox received notification

View File

@ -81,33 +81,31 @@ impl Trip {
trip_details.planned_starting_time, trip_details.planned_starting_time,
) )
.await; .await;
if same_starting_datetime.len() > 1 { for notify in same_starting_datetime {
for notify in same_starting_datetime { // don't notify oneself
// don't notify oneself if notify.id == trip_details.id {
if notify.id == trip_details.id { continue;
continue; }
}
// don't notify people who have cancelled their trip // don't notify people who have cancelled their trip
if notify.cancelled() { if notify.cancelled() {
continue; continue;
} }
if let Some(trip) = Trip::find_by_trip_details(db, notify.id).await { if let Some(trip) = Trip::find_by_trip_details(db, notify.id).await {
let user = User::find_by_id(db, trip.cox_id as i32).await.unwrap(); let user_earlier_trip = User::find_by_id(db, trip.cox_id as i32).await.unwrap();
Notification::create( Notification::create(
db, db,
&user, &user_earlier_trip,
&format!( &format!(
"{} hat eine Ausfahrt zur selben Zeit ({} um {}) wie du erstellt", "{} hat eine Ausfahrt zur selben Zeit ({} um {}) wie du erstellt",
user.name, trip.day, trip.planned_starting_time user.name, trip.day, trip.planned_starting_time
), ),
"Neue Ausfahrt zur selben Zeit", "Neue Ausfahrt zur selben Zeit",
None, None,
None, None,
) )
.await; .await;
}
} }
} }
} }
@ -277,10 +275,8 @@ WHERE day=?
return Err(TripUpdateError::NotYourTrip); return Err(TripUpdateError::NotYourTrip);
} }
if update.trip_type != Some(4) { if update.trip_type != Some(4) && !update.cox.allowed_to_steer(db).await {
if !update.cox.allowed_to_steer(db).await { return Err(TripUpdateError::TripTypeNotAllowed);
return Err(TripUpdateError::TripTypeNotAllowed);
}
} }
let Some(trip_details_id) = update.trip.trip_details_id else { let Some(trip_details_id) = update.trip.trip_details_id else {
@ -478,6 +474,7 @@ mod test {
use crate::{ use crate::{
model::{ model::{
event::Event, event::Event,
notification::Notification,
trip::{self, TripDeleteError}, trip::{self, TripDeleteError},
tripdetails::TripDetails, tripdetails::TripDetails,
user::{SteeringUser, User}, user::{SteeringUser, User},
@ -509,6 +506,34 @@ mod test {
assert!(Trip::find_by_id(&pool, 1).await.is_some()); assert!(Trip::find_by_id(&pool, 1).await.is_some());
} }
#[sqlx::test]
fn test_notification_cox_if_same_datetime() {
let pool = testdb!();
let cox = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox".into()).await.unwrap(),
)
.await
.unwrap();
let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap();
Trip::new_own(&pool, &cox, trip_details).await;
let cox2 = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox2".into()).await.unwrap(),
)
.await
.unwrap();
let trip_details = TripDetails::find_by_id(&pool, 3).await.unwrap();
Trip::new_own(&pool, &cox2, trip_details).await;
let last_notification = &Notification::for_user(&pool, &cox).await[0];
assert!(last_notification
.message
.starts_with("cox2 hat eine Ausfahrt zur selben Zeit"));
}
#[sqlx::test] #[sqlx::test]
fn test_get_day_cox_trip() { fn test_get_day_cox_trip() {
let pool = testdb!(); let pool = testdb!();

View File

@ -339,7 +339,7 @@ mod test {
} }
) )
.await, .await,
3, 4,
); );
assert_eq!( assert_eq!(
TripDetails::create( TripDetails::create(
@ -354,7 +354,7 @@ mod test {
} }
) )
.await, .await,
4, 5,
); );
} }

58
src/model/user/fee.rs Normal file
View File

@ -0,0 +1,58 @@
use super::User;
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct Fee {
pub sum_in_cents: i64,
pub parts: Vec<(String, i64)>,
pub name: String,
pub user_ids: String,
pub paid: bool,
pub users: Vec<User>,
}
impl Default for Fee {
fn default() -> Self {
Self::new()
}
}
impl Fee {
pub fn new() -> Self {
Self {
sum_in_cents: 0,
name: "".into(),
parts: Vec::new(),
user_ids: "".into(),
users: Vec::new(),
paid: false,
}
}
pub fn add(&mut self, desc: String, price_in_cents: i64) {
self.sum_in_cents += price_in_cents;
self.parts.push((desc, price_in_cents));
}
pub fn add_person(&mut self, user: &User) {
if !self.name.is_empty() {
self.name.push_str(" + ");
self.user_ids.push('&');
}
self.name.push_str(&user.name);
self.user_ids.push_str(&format!("user_ids[]={}", user.id));
self.users.push(user.clone());
}
pub fn paid(&mut self) {
self.paid = true;
}
pub fn merge(&mut self, fee: Fee) {
for (desc, price_in_cents) in fee.parts {
self.add(desc, price_in_cents);
}
}
}

View File

@ -31,7 +31,7 @@ pub struct User {
pub struct UserWithDetails { pub struct UserWithDetails {
#[serde(flatten)] #[serde(flatten)]
pub user: User, pub user: User,
pub amount_unread_notifications: i32, pub amount_unread_notifications: i64,
pub allowed_to_steer: bool, pub allowed_to_steer: bool,
pub roles: Vec<String>, pub roles: Vec<String>,
} }
@ -72,7 +72,7 @@ impl User {
self.has_role_tx(db, "cox").await || self.has_role_tx(db, "Bootsführer").await self.has_role_tx(db, "cox").await || self.has_role_tx(db, "Bootsführer").await
} }
pub async fn amount_unread_notifications(&self, db: &SqlitePool) -> i32 { pub async fn amount_unread_notifications(&self, db: &SqlitePool) -> i64 {
sqlx::query!( sqlx::query!(
"SELECT COUNT(*) as count FROM notification WHERE user_id = ? AND read_at IS NULL", "SELECT COUNT(*) as count FROM notification WHERE user_id = ? AND read_at IS NULL",
self.id self.id
@ -197,18 +197,27 @@ WHERE lower(name)=?
} }
pub async fn all(db: &SqlitePool) -> Vec<Self> { pub async fn all(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as!( Self::all_with_order(db, "last_access", false).await
Self, }
pub async fn all_with_order(db: &SqlitePool, sort: &str, asc: bool) -> Vec<Self> {
let mut query = format!(
" "
SELECT id, name, pw, deleted, last_access, user_token SELECT id, name, pw, deleted, last_access, user_token
FROM user FROM user
WHERE deleted = 0 WHERE deleted = 0
ORDER BY last_access DESC ORDER BY {}
" ",
) sort
.fetch_all(db) );
.await if !asc {
.unwrap() query.push_str(" DESC");
}
sqlx::query_as::<_, User>(&query)
.fetch_all(db)
.await
.unwrap()
} }
pub async fn all_with_role(db: &SqlitePool, role: &Role) -> Vec<Self> { pub async fn all_with_role(db: &SqlitePool, role: &Role) -> Vec<Self> {

View File

@ -80,8 +80,8 @@ fn fetch() -> Result<Station, String> {
let url = "https://hydro.ooe.gv.at/daten/internet/stations/OG/207068/S/forecast.json"; let url = "https://hydro.ooe.gv.at/daten/internet/stations/OG/207068/S/forecast.json";
match ureq::get(url).call() { match ureq::get(url).call() {
Ok(response) => { Ok(mut response) => {
let forecast: Result<Vec<Station>, _> = response.into_json(); let forecast: Result<Vec<Station>, _> = response.body_mut().read_json();
if let Ok(data) = forecast { if let Ok(data) = forecast {
if data.len() == 1 { if data.len() == 1 {

View File

@ -99,8 +99,8 @@ fn fetch(api_key: &str) -> Result<Data, String> {
let url = format!("https://api.openweathermap.org/data/3.0/onecall?lat=47.766249&lon=13.367683&units=metric&exclude=current,minutely,hourly,alert&appid={api_key}"); let url = format!("https://api.openweathermap.org/data/3.0/onecall?lat=47.766249&lon=13.367683&units=metric&exclude=current,minutely,hourly,alert&appid={api_key}");
match ureq::get(&url).call() { match ureq::get(&url).call() {
Ok(response) => { Ok(mut response) => {
let data: Result<Data, _> = response.into_json(); let data: Result<Data, _> = response.body_mut().read_json();
if let Ok(data) = data { if let Ok(data) = data {
Ok(data) Ok(data)

View File

@ -33,13 +33,17 @@ impl<'r> FromRequest<'r> for Referer {
} }
} }
#[get("/user")] #[get("/user?<sort>&<asc>")]
async fn index( async fn index(
db: &State<SqlitePool>, db: &State<SqlitePool>,
user: ManageUserUser, user: ManageUserUser,
flash: Option<FlashMessage<'_>>, flash: Option<FlashMessage<'_>>,
sort: Option<String>,
asc: bool,
) -> Template { ) -> Template {
let user_futures: Vec<_> = User::all(db) let sort_column = sort.unwrap_or_else(|| "last_access".to_string());
let user_futures: Vec<_> = User::all_with_order(db, &sort_column, asc)
.await .await
.into_iter() .into_iter()
.map(|u| async move { UserWithDetails::from_user(u, db).await }) .map(|u| async move { UserWithDetails::from_user(u, db).await })

View File

@ -38,7 +38,7 @@ async fn cal_registered(
return Err("Invalid".into()); return Err("Invalid".into());
}; };
if &user.user_token != uuid { if user.user_token != uuid {
return Err("Invalid".into()); return Err("Invalid".into());
} }

View File

@ -30,6 +30,12 @@ async fn mark_read(db: &State<SqlitePool>, user: User, notification_id: i64) ->
} }
} }
pub fn routes() -> Vec<Route> { #[get("/read/all")]
routes![mark_read] async fn mark_all_read(db: &State<SqlitePool>, user: User) -> Flash<Redirect> {
Notification::mark_all_read(db, &user).await;
Flash::success(Redirect::to("/"), "Alle Nachrichten als gelesen markiert")
}
pub fn routes() -> Vec<Route> {
routes![mark_read, mark_all_read]
} }

View File

@ -4,9 +4,12 @@
<div class="max-w-screen-lg w-full"> <div class="max-w-screen-lg w-full">
<h1 class="h1">Mitglieder</h1> <h1 class="h1">Mitglieder</h1>
{% if allowed_to_edit %} {% if allowed_to_edit %}
<form action="/admin/user/new" <details class="mt-5 bg-gray-200 dark:bg-primary-600 p-3 rounded-md">
<summary class="px-3 cursor-pointer text-md font-bold text-primary-950 dark:text-white">Neue Person hinzufügen</summary>
<form action="/admin/user/new"
onsubmit="return confirm('Willst du wirklich einen neuen Benutzer anlegen?');"
method="post" method="post"
class="mt-4 bg-primary-900 rounded-md text-white px-3 pb-3 pt-2 sm:flex items-end justify-between"> class="flex mt-4 rounded-md sm:flex items-end justify-between">
<div class="w-full"> <div class="w-full">
<h2 class="text-md font-bold mb-2 uppercase tracking-wide">Neues Mitglied hinzufügen</h2> <h2 class="text-md font-bold mb-2 uppercase tracking-wide">Neues Mitglied hinzufügen</h2>
<div class="grid md:grid-cols-3"> <div class="grid md:grid-cols-3">
@ -19,21 +22,44 @@
</div> </div>
</div> </div>
</div> </div>
<div class="text-right"> <div class="text-right ml-3">
<input value="Hinzufügen" <input value="Hinzufügen"
type="submit" type="submit"
class="w-28 mt-2 sm:mt-0 rounded-md bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 cursor-pointer" /> class="w-28 mt-2 sm:mt-0 rounded-md bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 cursor-pointer" />
</div> </div>
</form> </form>
</details>
{% endif %} {% endif %}
<!-- START filterBar --> <!-- START filterBar -->
<div class="search-wrapper"> <div class="search-wrapper flex">
<label for="name" class="sr-only">Suche</label> <label for="name" class="sr-only">Suche</label>
<input type="search" <input type="search"
name="name" name="name"
id="filter-js" id="filter-js"
class="search-bar" class="search-bar"
placeholder="Suchen nach (Name, [yes|no]-role:<name>" /> placeholder="Suchen nach (Name, [yes|no]-role:<name>, has-[no-]membership-pdf)" />
<div class="relative">
<button id="dropdownbtn" data-dropdown="dropdown" class="btn btn-dark ml-3" type="button">
Sortieren
</button>
<!-- Dropdown menu -->
<div id="dropdown" class="z-10 hidden bg-white divide-y divide-gray-100 text-secondary-900 rounded-lg shadow-sm w-44 absolute right-0">
<ul class="py-2 text-sm" aria-labelledby="dropdownbtn">
<li>
<a href="./user" class="block px-4 py-2 hover:bg-gray-100 hover:text-secondary-950">Zuletzt eingeloggt</a>
</li>
<li>
<a href="?sort=name&asc" class="block px-4 py-2 hover:bg-gray-100 hover:text-secondary-950">Name A-Z</a>
</li>
<li>
<a href="?sort=name" class="block px-4 py-2 hover:bg-gray-100 hover:text-secondary-950">Name Z-A</a>
</li>
</ul>
</div>
</div>
</div> </div>
<!-- END filterBar --> <!-- END filterBar -->
<div id="filter-result-js" class="search-result"></div> <div id="filter-result-js" class="search-result"></div>
@ -46,7 +72,7 @@
<span class="text-black dark:text-white cursor-pointer"> <span class="text-black dark:text-white cursor-pointer">
<span class="font-bold"> <span class="font-bold">
{{ user.name }} {{ user.name }}
{% if not user.last_access and "admin" in loggedin_user.roles %} {% if not user.last_access and allowed_to_edit and user.mail %}
<form action="/admin/user" <form action="/admin/user"
method="post" method="post"
enctype="multipart/form-data" enctype="multipart/form-data"

View File

@ -4,13 +4,12 @@
{% extends "base" %} {% extends "base" %}
{% macro show_place(aisle_name, side_name, level) %} {% macro show_place(aisle_name, side_name, level) %}
<li class="truncate p-2 flex relative w-full"> <li class="truncate p-2 flex relative w-full">
{% set aisle = aisle_name ~ "-aisle" %} {% set place = boathouse[aisle_name][side_name].boats %}
{% set place = boathouse[aisle][side_name] %}
{% if place[level] %} {% if place[level] %}
{{ place[level].1.name }} {{ place[level].boat.name }}
{% if "admin" in loggedin_user.roles %} {% if "admin" in loggedin_user.roles %}
<a class="btn btn-primary absolute end-0" <a class="btn btn-primary absolute end-0"
href="/board/boathouse/{{ place[level].0 }}/delete">X</a> href="/board/boathouse/{{ place[level].boathouse_id }}/delete">X</a>
{% endif %} {% endif %}
{% elif boats | length > 0 %} {% elif boats | length > 0 %}
{% if "admin" in loggedin_user.roles %} {% if "admin" in loggedin_user.roles %}