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]
name = "rot"
version = "0.1.0"
edition = "2021"
edition = "2024"
[features]
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 }
log = "0.4"
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"
serde = { version = "1.0", features = [ "derive" ]}
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"]}
chrono-tz = "0.9"
chrono-tz = "0.10"
tera = { version = "1.18", features = ["date-locale"], optional = true}
ics = "0.5"
futures = "0.3"
lettre = "0.11"
csv = "1.3"
itertools = "0.13"
itertools = "0.14"
job_scheduler_ng = "2.0"
ureq = { version = "2.9", features = ["json"] }
ureq = { version = "3.0", features = ["json"] }
regex = "1.10"
urlencoding = "2.1"

View File

@ -23,6 +23,7 @@ document.addEventListener("DOMContentLoaded", function () {
addRelationMagic(<HTMLElement>document.querySelector("body"));
reloadPage();
setCurrentdate(<HTMLInputElement>document.querySelector("#departure"));
initDropdown();
});
function changeTheme() {
@ -795,3 +796,21 @@ function replaceStrings() {
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" (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 ('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;');

View File

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

View File

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

View File

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

View File

@ -339,7 +339,7 @@ mod test {
}
)
.await,
3,
4,
);
assert_eq!(
TripDetails::create(
@ -354,7 +354,7 @@ mod test {
}
)
.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 {
#[serde(flatten)]
pub user: User,
pub amount_unread_notifications: i32,
pub amount_unread_notifications: i64,
pub allowed_to_steer: bool,
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
}
pub async fn amount_unread_notifications(&self, db: &SqlitePool) -> i32 {
pub async fn amount_unread_notifications(&self, db: &SqlitePool) -> i64 {
sqlx::query!(
"SELECT COUNT(*) as count FROM notification WHERE user_id = ? AND read_at IS NULL",
self.id
@ -197,18 +197,27 @@ WHERE lower(name)=?
}
pub async fn all(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as!(
Self,
Self::all_with_order(db, "last_access", false).await
}
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
FROM user
WHERE deleted = 0
ORDER BY last_access DESC
"
)
.fetch_all(db)
.await
.unwrap()
SELECT id, name, pw, deleted, last_access, user_token
FROM user
WHERE deleted = 0
ORDER BY {}
",
sort
);
if !asc {
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> {

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";
match ureq::get(url).call() {
Ok(response) => {
let forecast: Result<Vec<Station>, _> = response.into_json();
Ok(mut response) => {
let forecast: Result<Vec<Station>, _> = response.body_mut().read_json();
if let Ok(data) = forecast {
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}");
match ureq::get(&url).call() {
Ok(response) => {
let data: Result<Data, _> = response.into_json();
Ok(mut response) => {
let data: Result<Data, _> = response.body_mut().read_json();
if let Ok(data) = data {
Ok(data)

View File

@ -33,13 +33,17 @@ impl<'r> FromRequest<'r> for Referer {
}
}
#[get("/user")]
#[get("/user?<sort>&<asc>")]
async fn index(
db: &State<SqlitePool>,
user: ManageUserUser,
flash: Option<FlashMessage<'_>>,
sort: Option<String>,
asc: bool,
) -> 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
.into_iter()
.map(|u| async move { UserWithDetails::from_user(u, db).await })

View File

@ -38,7 +38,7 @@ async fn cal_registered(
return Err("Invalid".into());
};
if &user.user_token != uuid {
if user.user_token != uuid {
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> {
routes![mark_read]
#[get("/read/all")]
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">
<h1 class="h1">Mitglieder</h1>
{% 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"
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">
<h2 class="text-md font-bold mb-2 uppercase tracking-wide">Neues Mitglied hinzufügen</h2>
<div class="grid md:grid-cols-3">
@ -19,21 +22,44 @@
</div>
</div>
</div>
<div class="text-right">
<div class="text-right ml-3">
<input value="Hinzufügen"
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" />
</div>
</form>
</details>
{% endif %}
<!-- START filterBar -->
<div class="search-wrapper">
<div class="search-wrapper flex">
<label for="name" class="sr-only">Suche</label>
<input type="search"
name="name"
id="filter-js"
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>
<!-- END filterBar -->
<div id="filter-result-js" class="search-result"></div>
@ -46,7 +72,7 @@
<span class="text-black dark:text-white cursor-pointer">
<span class="font-bold">
{{ 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"
method="post"
enctype="multipart/form-data"

View File

@ -4,13 +4,12 @@
{% extends "base" %}
{% macro show_place(aisle_name, side_name, level) %}
<li class="truncate p-2 flex relative w-full">
{% set aisle = aisle_name ~ "-aisle" %}
{% set place = boathouse[aisle][side_name] %}
{% set place = boathouse[aisle_name][side_name].boats %}
{% if place[level] %}
{{ place[level].1.name }}
{{ place[level].boat.name }}
{% if "admin" in loggedin_user.roles %}
<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 %}
{% elif boats | length > 0 %}
{% if "admin" in loggedin_user.roles %}