Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Philipp Hofer 2025-04-03 16:29:05 +02:00
commit 702b8d3ff4
36 changed files with 6973 additions and 613 deletions

1481
Cargo.lock generated

File diff suppressed because it is too large Load Diff

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

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

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

144
src/model/boathouse.rs Normal file

@ -0,0 +1,144 @@
use rocket::serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
use crate::tera::board::boathouse::FormBoathouseToAdd;
use super::boat::Boat;
#[derive(Debug, Serialize, Deserialize)]
pub struct BoathousePlace {
boat: Boat,
boathouse_id: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BoathouseRack {
boats: [Option<BoathousePlace>; 12],
}
impl BoathouseRack {
fn new() -> Self {
let boats = [
None, None, None, None, None, None, None, None, None, None, None, None,
];
Self { boats }
}
async fn add(&mut self, db: &SqlitePool, boathouse: Boathouse) {
self.boats[boathouse.level as usize] = Some(BoathousePlace {
boat: Boat::find_by_id(db, boathouse.boat_id as i32)
.await
.unwrap(),
boathouse_id: boathouse.id,
});
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BoathouseSide {
mountain: BoathouseRack,
water: BoathouseRack,
}
impl BoathouseSide {
fn new() -> Self {
Self {
mountain: BoathouseRack::new(),
water: BoathouseRack::new(),
}
}
async fn add(&mut self, db: &SqlitePool, boathouse: Boathouse) {
match boathouse.side.as_str() {
"mountain" => self.mountain.add(db, boathouse).await,
"water" => self.water.add(db, boathouse).await,
_ => panic!("db constraint failed"),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BoathouseAisles {
mountain: BoathouseSide,
middle: BoathouseSide,
water: BoathouseSide,
}
impl BoathouseAisles {
fn new() -> Self {
Self {
mountain: BoathouseSide::new(),
middle: BoathouseSide::new(),
water: BoathouseSide::new(),
}
}
async fn add(&mut self, db: &SqlitePool, boathouse: Boathouse) {
match boathouse.aisle.as_str() {
"water" => self.water.add(db, boathouse).await,
"middle" => self.middle.add(db, boathouse).await,
"mountain" => self.mountain.add(db, boathouse).await,
_ => panic!("db constraint failed"),
};
}
pub async fn from(db: &SqlitePool, boathouses: Vec<Boathouse>) -> Self {
let mut ret = BoathouseAisles::new();
for boathouse in boathouses {
ret.add(db, boathouse).await;
}
ret
}
}
#[derive(FromRow, Debug, Serialize, Deserialize)]
pub struct Boathouse {
pub id: i64,
pub boat_id: i64,
pub aisle: String,
pub side: String,
pub level: i64,
}
impl Boathouse {
pub async fn get(db: &SqlitePool) -> BoathouseAisles {
let boathouses = sqlx::query_as!(
Boathouse,
"SELECT id, boat_id, aisle, side, level FROM boathouse"
)
.fetch_all(db)
.await
.unwrap(); //TODO: fixme
BoathouseAisles::from(db, boathouses).await
}
pub async fn create(db: &SqlitePool, data: FormBoathouseToAdd) -> Result<(), String> {
sqlx::query!(
"INSERT INTO boathouse(boat_id, aisle, side, level) VALUES (?,?,?,?)",
data.boat_id,
data.aisle,
data.side,
data.level
)
.execute(db)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> {
sqlx::query_as!(Self, "SELECT * FROM boathouse WHERE id like ?", id)
.fetch_one(db)
.await
.ok()
}
pub async fn delete(&self, db: &SqlitePool) {
sqlx::query!("DELETE FROM boathouse WHERE id=?", self.id)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a Boat of a valid id
}
}

@ -0,0 +1,272 @@
use std::collections::HashMap;
use crate::model::{boat::Boat, user::User};
use crate::tera::boatreservation::ReservationEditForm;
use chrono::NaiveDate;
use chrono::NaiveDateTime;
use rocket::serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
use super::log::Log;
use super::notification::Notification;
use super::role::Role;
#[derive(FromRow, Debug, Serialize, Deserialize)]
pub struct BoatReservation {
pub id: i64,
pub boat_id: i64,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub time_desc: String,
pub usage: String,
pub user_id_applicant: i64,
pub user_id_confirmation: Option<i64>,
pub created_at: NaiveDateTime,
}
#[derive(FromRow, Debug, Serialize, Deserialize)]
pub struct BoatReservationWithDetails {
#[serde(flatten)]
reservation: BoatReservation,
boat: Boat,
user_applicant: User,
user_confirmation: Option<User>,
}
#[derive(Debug)]
pub struct BoatReservationToAdd<'r> {
pub boat: &'r Boat,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
pub time_desc: &'r str,
pub usage: &'r str,
pub user_applicant: &'r User,
}
impl BoatReservation {
pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> {
sqlx::query_as!(
Self,
"SELECT id, boat_id, start_date, end_date, time_desc, usage, user_id_applicant, user_id_confirmation, created_at
FROM boat_reservation
WHERE id like ?",
id
)
.fetch_one(db)
.await
.ok()
}
pub async fn for_day(db: &SqlitePool, day: NaiveDate) -> Vec<BoatReservationWithDetails> {
let boatreservations = sqlx::query_as!(
Self,
"
SELECT id, boat_id, start_date, end_date, time_desc, usage, user_id_applicant, user_id_confirmation, created_at
FROM boat_reservation
WHERE end_date >= ? AND start_date <= ?
", day, day
)
.fetch_all(db)
.await
.unwrap(); //TODO: fixme
let mut res = Vec::new();
for reservation in boatreservations {
let user_confirmation = match reservation.user_id_confirmation {
Some(id) => {
let user = User::find_by_id(db, id as i32).await;
Some(user.unwrap())
}
None => None,
};
let user_applicant = User::find_by_id(db, reservation.user_id_applicant as i32)
.await
.unwrap();
let boat = Boat::find_by_id(db, reservation.boat_id as i32)
.await
.unwrap();
res.push(BoatReservationWithDetails {
reservation,
boat,
user_applicant,
user_confirmation,
});
}
res
}
pub async fn all_future(db: &SqlitePool) -> Vec<BoatReservationWithDetails> {
let boatreservations = sqlx::query_as!(
Self,
"
SELECT id, boat_id, start_date, end_date, time_desc, usage, user_id_applicant, user_id_confirmation, created_at
FROM boat_reservation
WHERE end_date >= CURRENT_DATE ORDER BY end_date
"
)
.fetch_all(db)
.await
.unwrap(); //TODO: fixme
let mut res = Vec::new();
for reservation in boatreservations {
let user_confirmation = match reservation.user_id_confirmation {
Some(id) => {
let user = User::find_by_id(db, id as i32).await;
Some(user.unwrap())
}
None => None,
};
let user_applicant = User::find_by_id(db, reservation.user_id_applicant as i32)
.await
.unwrap();
let boat = Boat::find_by_id(db, reservation.boat_id as i32)
.await
.unwrap();
res.push(BoatReservationWithDetails {
reservation,
boat,
user_applicant,
user_confirmation,
});
}
res
}
pub fn with_groups(
reservations: Vec<BoatReservationWithDetails>,
) -> HashMap<String, Vec<BoatReservationWithDetails>> {
let mut grouped_reservations: HashMap<String, Vec<BoatReservationWithDetails>> =
HashMap::new();
for reservation in reservations {
let key = format!(
"{}-{}-{}-{}-{}",
reservation.reservation.start_date,
reservation.reservation.end_date,
reservation.reservation.time_desc,
reservation.reservation.usage,
reservation.user_applicant.name
);
grouped_reservations
.entry(key)
.or_default()
.push(reservation);
}
grouped_reservations
}
pub async fn all_future_with_groups(
db: &SqlitePool,
) -> HashMap<String, Vec<BoatReservationWithDetails>> {
let reservations = Self::all_future(db).await;
Self::with_groups(reservations)
}
pub async fn create(
db: &SqlitePool,
boatreservation: BoatReservationToAdd<'_>,
) -> Result<(), String> {
if Self::boat_reserved_between_dates(
db,
boatreservation.boat,
&boatreservation.start_date,
&boatreservation.end_date,
)
.await
{
return Err("Boot in diesem Zeitraum bereits reserviert.".into());
}
Log::create(db, format!("New boat reservation: {boatreservation:?}")).await;
sqlx::query!(
"INSERT INTO boat_reservation(boat_id, start_date, end_date, time_desc, usage, user_id_applicant) VALUES (?,?,?,?,?,?)",
boatreservation.boat.id,
boatreservation.start_date,
boatreservation.end_date,
boatreservation.time_desc,
boatreservation.usage,
boatreservation.user_applicant.id,
)
.execute(db)
.await
.map_err(|e| e.to_string())?;
let board =
User::all_with_role(db, &Role::find_by_name(db, "Vorstand").await.unwrap()).await;
for user in board {
let date = if boatreservation.start_date == boatreservation.end_date {
format!("am {}", boatreservation.start_date)
} else {
format!(
"von {} bis {}",
boatreservation.start_date, boatreservation.end_date
)
};
Notification::create(
db,
&user,
&format!(
"{} hat eine neue Bootsreservierung für Boot '{}' {} angelegt. Zeit: {}; Zweck: {}",
boatreservation.user_applicant.name,
boatreservation.boat.name,
date,
boatreservation.time_desc,
boatreservation.usage
),
"Neue Bootsreservierung",
None,None
)
.await;
}
Ok(())
}
pub async fn boat_reserved_between_dates(
db: &SqlitePool,
boat: &Boat,
start_date: &NaiveDate,
end_date: &NaiveDate,
) -> bool {
sqlx::query!(
"SELECT COUNT(*) AS reservation_count
FROM boat_reservation
WHERE boat_id = ?
AND start_date <= ? AND end_date >= ?;",
boat.id,
end_date,
start_date
)
.fetch_one(db)
.await
.unwrap()
.reservation_count
> 0
}
pub async fn update(&self, db: &SqlitePool, data: ReservationEditForm) {
let time_desc = data.time_desc.trim();
let usage = data.usage.trim();
sqlx::query!(
"UPDATE boat_reservation SET time_desc = ?, usage = ? where id = ?",
time_desc,
usage,
self.id
)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a User of a valid id
}
pub async fn delete(&self, db: &SqlitePool) {
sqlx::query!("DELETE FROM boat_reservation WHERE id=?", self.id)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a Boat of a valid id
}
}

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

94
src/model/family.rs Normal file

@ -0,0 +1,94 @@
use std::ops::DerefMut;
use serde::Serialize;
use sqlx::{sqlite::SqliteQueryResult, FromRow, Sqlite, SqlitePool, Transaction};
use super::user::User;
#[derive(FromRow, Serialize, Clone)]
pub struct Family {
id: i64,
}
#[derive(Serialize, Clone)]
pub struct FamilyWithMembers {
id: i64,
names: Option<String>,
}
impl Family {
pub async fn all(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as!(Self, "SELECT id FROM role")
.fetch_all(db)
.await
.unwrap()
}
pub async fn insert_tx(db: &mut Transaction<'_, Sqlite>) -> i64 {
let result: SqliteQueryResult = sqlx::query("INSERT INTO family DEFAULT VALUES")
.execute(db.deref_mut())
.await
.unwrap();
result.last_insert_rowid()
}
pub async fn insert(db: &SqlitePool) -> i64 {
let result: SqliteQueryResult = sqlx::query("INSERT INTO family DEFAULT VALUES")
.execute(db)
.await
.unwrap();
result.last_insert_rowid()
}
pub async fn all_with_members(db: &SqlitePool) -> Vec<FamilyWithMembers> {
sqlx::query_as!(
FamilyWithMembers,
"
SELECT
family.id as id,
GROUP_CONCAT(user.name, ', ') as names
FROM family
LEFT JOIN
user ON family.id = user.family_id
GROUP BY family.id;"
)
.fetch_all(db)
.await
.unwrap()
}
pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> {
sqlx::query_as!(Self, "SELECT id FROM family WHERE id like ?", id)
.fetch_one(db)
.await
.ok()
}
pub async fn find_by_opt_id(db: &SqlitePool, id: Option<i64>) -> Option<Self> {
if let Some(id) = id {
Self::find_by_id(db, id).await
} else {
None
}
}
pub async fn amount_family_members(&self, db: &SqlitePool) -> i64 {
sqlx::query!(
"SELECT COUNT(*) as count FROM user WHERE family_id = ?",
self.id
)
.fetch_one(db)
.await
.unwrap()
.count
}
pub async fn members(&self, db: &SqlitePool) -> Vec<User> {
sqlx::query_as!(User, "SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, user_token FROM user WHERE family_id = ?", self.id)
.fetch_all(db)
.await
.unwrap()
}
}

1288
src/model/logbook.rs Normal file

File diff suppressed because it is too large Load Diff

367
src/model/mail.rs Normal file

@ -0,0 +1,367 @@
use std::{error::Error, fs};
use lettre::{
message::{header::ContentType, Attachment, MultiPart, SinglePart},
transport::smtp::authentication::Credentials,
Message, SmtpTransport, Transport,
};
use sqlx::{Sqlite, SqlitePool, Transaction};
use crate::tera::admin::mail::MailToSend;
use super::{family::Family, log::Log, role::Role, user::User};
pub struct Mail {}
impl Mail {
pub async fn send_single(
db: &SqlitePool,
to: &str,
subject: &str,
body: String,
smtp_pw: &str,
) -> Result<(), String> {
let mut tx = db.begin().await.unwrap();
let ret = Self::send_single_tx(&mut tx, to, subject, body, smtp_pw).await;
tx.commit().await.unwrap();
ret
}
pub async fn send_single_tx(
db: &mut Transaction<'_, Sqlite>,
to: &str,
subject: &str,
body: String,
smtp_pw: &str,
) -> Result<(), String> {
let mut email = Message::builder()
.from(
"ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>"
.parse()
.unwrap(),
)
.reply_to(
"ASKÖ Ruderverein Donau Linz <info@rudernlinz.at>"
.parse()
.unwrap(),
)
.to("ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>"
.parse()
.unwrap());
let splitted = to.split(',');
for single_rec in splitted {
match single_rec.parse() {
Ok(new_bcc_mail) => email = email.bcc(new_bcc_mail),
Err(_) => {
Log::create_with_tx(
db,
format!("Mail not sent to {single_rec}, because it could not be parsed"),
)
.await;
return Err(format!(
"Mail nicht versandt, da '{single_rec}' keine gültige Mailadresse ist."
));
}
}
}
let email = email
.subject(subject)
.header(ContentType::TEXT_PLAIN)
.body(body)
.unwrap();
let creds = Credentials::new("no-reply@rudernlinz.at".to_owned(), smtp_pw.into());
let mailer = SmtpTransport::relay("mail.your-server.de")
.unwrap()
.credentials(creds)
.build();
// Send the email
mailer.send(&email).unwrap();
Ok(())
}
pub async fn send(db: &SqlitePool, data: MailToSend<'_>, smtp_pw: String) -> bool {
let mut email = Message::builder()
.from(
"ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>"
.parse()
.unwrap(),
)
.reply_to(
"ASKÖ Ruderverein Donau Linz <info@rudernlinz.at>"
.parse()
.unwrap(),
)
.to("ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>"
.parse()
.unwrap());
let role = Role::find_by_id(db, data.role_id).await.unwrap();
for rec in role.mails_from_role(db).await {
let splitted = rec.split(',');
for single_rec in splitted {
match single_rec.parse() {
Ok(new_bcc_mail) => email = email.bcc(new_bcc_mail),
Err(_) => {
Log::create(
db,
format!("Mail not sent to {rec}, because it could not be parsed"),
)
.await;
}
}
}
}
let mut multipart = MultiPart::mixed().singlepart(SinglePart::plain(data.body));
for temp_file in &data.files {
let content = fs::read(temp_file.path().unwrap()).unwrap();
let media_type = format!("{}", temp_file.content_type().unwrap().media_type());
let content_type = ContentType::parse(&media_type).unwrap();
if let Some(name) = temp_file.name() {
let attachment = Attachment::new(format!(
"{}.{}",
name,
temp_file.content_type().unwrap().extension().unwrap()
))
.body(content, content_type);
multipart = multipart.singlepart(attachment);
}
}
let email = email.subject(data.subject).multipart(multipart).unwrap();
let creds = Credentials::new("no-reply@rudernlinz.at".to_owned(), smtp_pw);
let mailer = SmtpTransport::relay("mail.your-server.de")
.unwrap()
.credentials(creds)
.build();
// Send the email
match mailer.send(&email) {
Ok(_) => return true,
Err(e) => println!("{:?}", e.source()),
};
false
}
pub async fn fees(db: &SqlitePool, smtp_pw: String) {
let users = User::all_payer_groups(db).await;
for user in users {
if !user.has_role(db, "paid").await {
let mut is_family = false;
let mut send_to = String::new();
match Family::find_by_opt_id(db, user.family_id).await {
Some(family) => {
is_family = true;
for member in family.members(db).await {
if let Some(mail) = member.mail {
send_to.push_str(&format!("{mail},"))
}
}
}
None => {
if let Some(mail) = &user.mail {
send_to.push_str(mail)
}
}
}
let fees = user.fee(db).await;
if let Some(fees) = fees {
let mut content = format!(
"Liebes Vereinsmitglied, \n\n\
dein Vereinsbeitrag für das aktuelle Jahr beträgt {}",
fees.sum_in_cents / 100,
);
if fees.parts.len() == 1 {
content.push_str(&format!(" ({}).\n", fees.parts[0].0))
} else {
content.push_str(". Dieser setzt sich aus folgenden Teilen zusammen: \n");
for (desc, fee_in_cents) in fees.parts {
content.push_str(&format!("- {}: {}\n", desc, fee_in_cents / 100))
}
}
if is_family {
content.push_str(&format!(
"Dieser gilt für die gesamte Familie ({}).\n",
fees.name
))
}
content.push_str("\nBitte überweise diesen auf folgendes Konto: IBAN: AT58 2032 0321 0072 9256. Auf https://app.rudernlinz.at/planned findest du einen QR Code, den du mit deiner Bankapp scannen kannst um alle Eingaben bereits ausgefüllt zu haben.\n\n\
Falls die Berechnung nicht stimmt (korrekte Preise findest du unter https://rudernlinz.at/unser-verein/gebuhren/) melde dich bitte bei it@rudernlinz.at. @Studenten: Bitte die aktuelle Studienbestätigung an it@rudernlinz.at schicken.\n\n\
Wenn du die Vereinsgebühren schon bezahlt hast, kannst du diese Mail einfach ignorieren.\n\n
Beste Grüße\n\
Der Vorstand
");
let mut email = Message::builder()
.from(
"ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>"
.parse()
.unwrap(),
)
.reply_to(
"ASKÖ Ruderverein Donau Linz <it@rudernlinz.at>"
.parse()
.unwrap(),
)
.to("ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>"
.parse()
.unwrap());
let splitted = send_to.split(',');
let mut send_mail = false;
for single_rec in splitted {
let single_rec = single_rec.trim();
match single_rec.parse() {
Ok(val) => {
email = email.bcc(val);
send_mail = true;
}
Err(_) => {
println!("Error in mail: {single_rec}");
}
}
}
if send_mail {
let email = email
.subject("ASKÖ Ruderverein Donau Linz | Vereinsgebühren")
.header(ContentType::TEXT_PLAIN)
.body(content)
.unwrap();
let creds =
Credentials::new("no-reply@rudernlinz.at".to_owned(), smtp_pw.clone());
let mailer = SmtpTransport::relay("mail.your-server.de")
.unwrap()
.credentials(creds)
.build();
// Send the email
mailer.send(&email).unwrap();
}
}
}
}
}
pub async fn fees_final(db: &SqlitePool, smtp_pw: String) {
let users = User::all_payer_groups(db).await;
for user in users {
if let Some(fee) = user.fee(db).await {
if !fee.paid {
let mut is_family = false;
let mut send_to = String::new();
match Family::find_by_opt_id(db, user.family_id).await {
Some(family) => {
is_family = true;
for member in family.members(db).await {
if let Some(mail) = member.mail {
send_to.push_str(&format!("{mail},"))
}
}
}
None => {
if let Some(mail) = &user.mail {
send_to.push_str(mail)
}
}
}
let fees = user.fee(db).await;
if let Some(fees) = fees {
let mut content = format!(
"Liebes Vereinsmitglied, \n\n\
wir möchten darauf hinweisen, dass wir deinen Mitgliedsbeitrag für das laufende Jahr bislang nicht verbuchen konnten. Es besteht die Möglichkeit, dass es sich hierbei um ein Versehen unsererseits handelt. Solltest du den Betrag bereits überwiesen haben, bitte kurz auf diese E-Mail antworten, damit wir es richtigstellen können.
Falls die Zahlung noch nicht erfolgt ist, bitten wir um umgehende Überweisung des ausstehenden Betrags, spätestens jedoch bis zum 31. März, auf unser Bankkonto.\n\n\
Dein Vereinsbeitrag für das aktuelle Jahr beträgt {}",
fees.sum_in_cents / 100,
);
if fees.parts.len() == 1 {
content.push_str(&format!(" ({}).\n", fees.parts[0].0))
} else {
content
.push_str(". Dieser setzt sich aus folgenden Teilen zusammen: \n");
for (desc, fee_in_cents) in fees.parts {
content.push_str(&format!("- {}: {}\n", desc, fee_in_cents / 100))
}
}
if is_family {
content.push_str(&format!(
"Dieser gilt für die gesamte Familie ({}). Diese Mail wird an alle Familienmitglieder verschickt, bezahlen müsst ihr natürlich nur 1x.\n",
fees.name
))
}
content.push_str("\n\
Gemäß § 7 Abs. 3 lit. c unseres Status behalten wir uns vor, bei ausbleibender Zahlung die Mitgliedschaft zu beenden. Dies möchten wir vermeiden und hoffen auf deine Unterstützung.\n\n\
Bei Fragen oder Problemen stehen wir gerne zur Verfügung.
Bankverbindung: IBAN: AT58 2032 0321 0072 9256 (Unter https://app.rudernlinz.at/planned findest du einen QR Code, den du mit deiner Bankapp scannen kannst um alle Eingaben bereits ausgefüllt zu haben.)
Mit freundlichen Grüßen,\n\
Der Vorstand");
let mut email = Message::builder()
.from(
"ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>"
.parse()
.unwrap(),
)
.reply_to(
"ASKÖ Ruderverein Donau Linz <it@rudernlinz.at>"
.parse()
.unwrap(),
)
.to("ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>"
.parse()
.unwrap());
let splitted = send_to.split(',');
let mut send_mail = false;
for single_rec in splitted {
let single_rec = single_rec.trim();
match single_rec.parse() {
Ok(val) => {
email = email.bcc(val);
send_mail = true;
}
Err(_) => {
println!("Error in mail: {single_rec}");
}
}
}
if send_mail {
let email = email
.subject("Mahnung Vereinsgebühren | ASKÖ Ruderverein Donau Linz")
.header(ContentType::TEXT_PLAIN)
.body(content)
.unwrap();
let creds = Credentials::new(
"no-reply@rudernlinz.at".to_owned(),
smtp_pw.clone(),
);
let mailer = SmtpTransport::relay("mail.your-server.de")
.unwrap()
.credentials(creds)
.build();
// Send the email
mailer.send(&email).unwrap();
}
}
}
}
}
}
}

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

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

@ -0,0 +1,104 @@
use crate::model::{logbook::Logbook, stat::Stat, user::User};
use serde::Serialize;
#[derive(Serialize, PartialEq, Debug)]
pub(crate) enum Level {
None,
Bronze,
Silver,
Gold,
Diamond,
Done,
}
impl Level {
fn required_km(&self) -> i32 {
match self {
Level::Bronze => 40_000,
Level::Silver => 80_000,
Level::Gold => 100_000,
Level::Diamond => 200_000,
Level::Done => 0,
Level::None => 0,
}
}
fn next_level(km: i32) -> Self {
if km < Level::Bronze.required_km() {
Level::Bronze
} else if km < Level::Silver.required_km() {
Level::Silver
} else if km < Level::Gold.required_km() {
Level::Gold
} else if km < Level::Diamond.required_km() {
Level::Diamond
} else {
Level::Done
}
}
pub(crate) fn curr_level(km: i32) -> Self {
if km < Level::Bronze.required_km() {
Level::None
} else if km < Level::Silver.required_km() {
Level::Bronze
} else if km < Level::Gold.required_km() {
Level::Silver
} else if km < Level::Diamond.required_km() {
Level::Gold
} else {
Level::Diamond
}
}
pub(crate) fn desc(&self) -> &str {
match self {
Level::Bronze => "Bronze",
Level::Silver => "Silber",
Level::Gold => "Gold",
Level::Diamond => "Diamant",
Level::Done => "",
Level::None => "-",
}
}
}
#[derive(Serialize)]
pub(crate) struct Next {
level: Level,
desc: String,
missing_km: i32,
required_km: i32,
rowed_km: i32,
}
impl Next {
pub(crate) fn new(rowed_km: i32) -> Self {
let level = Level::next_level(rowed_km);
let required_km = level.required_km();
let missing_km = required_km - rowed_km;
Self {
desc: level.desc().to_string(),
level,
missing_km,
required_km,
rowed_km,
}
}
}
pub(crate) async fn new_level_with_last_log(
db: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
user: &User,
) -> Option<String> {
let rowed_km = Stat::total_km_tx(db, user).await.rowed_km;
if let Some(last_logbookentry) = Logbook::completed_with_user_tx(db, user).await.last() {
let last_trip_km = last_logbookentry.logbook.distance_in_km.unwrap();
if Level::curr_level(rowed_km) != Level::curr_level(rowed_km - last_trip_km as i32) {
return Some(Level::curr_level(rowed_km).desc().to_string());
}
}
None
}

@ -0,0 +1,221 @@
use std::cmp;
use chrono::{Datelike, Local, NaiveDate};
use serde::Serialize;
use sqlx::{Sqlite, SqlitePool, Transaction};
use crate::model::{
logbook::{Filter, Logbook, LogbookWithBoatAndRowers},
stat::Stat,
user::User,
};
enum AgeBracket {
Till14,
From14Till18,
From19Till30,
From31Till60,
From61Till75,
From76,
}
impl AgeBracket {
fn cat(&self) -> &str {
match self {
AgeBracket::Till14 => "Schülerinnen und Schüler bis 14 Jahre",
AgeBracket::From14Till18 => "Juniorinnen und Junioren, Para-Ruderer bis 18 Jahre",
AgeBracket::From19Till30 => "Frauen und Männer, Para-Ruderer bis 30 Jahre",
AgeBracket::From31Till60 => "Frauen und Männer, Para-Ruderer von 31 bis 60 Jahre",
AgeBracket::From61Till75 => "Frauen und Männer, Para-Ruderer von 61 bis 75 Jahre",
AgeBracket::From76 => "Frauen und Männer, Para-Ruderer ab 76 Jahre",
}
}
fn dist_in_km(&self) -> i32 {
match self {
AgeBracket::Till14 => 500,
AgeBracket::From14Till18 => 1000,
AgeBracket::From19Till30 => 1200,
AgeBracket::From31Till60 => 1000,
AgeBracket::From61Till75 => 800,
AgeBracket::From76 => 600,
}
}
fn required_dist_multi_day_in_km(&self) -> i32 {
match self {
AgeBracket::Till14 => 60,
AgeBracket::From14Till18 => 60,
AgeBracket::From19Till30 => 80,
AgeBracket::From31Till60 => 80,
AgeBracket::From61Till75 => 80,
AgeBracket::From76 => 80,
}
}
fn required_dist_single_day_in_km(&self) -> i32 {
match self {
AgeBracket::Till14 => 30,
AgeBracket::From14Till18 => 30,
AgeBracket::From19Till30 => 40,
AgeBracket::From31Till60 => 40,
AgeBracket::From61Till75 => 40,
AgeBracket::From76 => 40,
}
}
}
impl TryFrom<&User> for AgeBracket {
type Error = String;
fn try_from(value: &User) -> Result<Self, Self::Error> {
let Some(birthdate) = value.birthdate.clone() else {
return Err("User has no birthdate".to_string());
};
let Ok(birthdate) = NaiveDate::parse_from_str(&birthdate, "%Y-%m-%d") else {
return Err("Birthdate in wrong format...".to_string());
};
let today = Local::now().date_naive();
let age = today.year() - birthdate.year();
if age <= 14 {
Ok(AgeBracket::Till14)
} else if age <= 18 {
Ok(AgeBracket::From14Till18)
} else if age <= 30 {
Ok(AgeBracket::From19Till30)
} else if age <= 60 {
Ok(AgeBracket::From31Till60)
} else if age <= 75 {
Ok(AgeBracket::From61Till75)
} else {
Ok(AgeBracket::From76)
}
}
}
#[derive(Serialize)]
pub(crate) struct Status {
pub(crate) year: i32,
rowed_km: i32,
category: String,
required_km: i32,
missing_km: i32,
multi_day_trips_over_required_distance: Vec<LogbookWithBoatAndRowers>,
multi_day_trips_required_distance: i32,
single_day_trips_over_required_distance: Vec<LogbookWithBoatAndRowers>,
single_day_trips_required_distance: i32,
achieved: bool,
}
impl Status {
fn calc(
agebracket: &AgeBracket,
rowed_km: i32,
single_day_trips_over_required_distance: usize,
multi_day_trips_over_required_distance: usize,
year: i32,
) -> Self {
let category = agebracket.cat().to_string();
let required_km = agebracket.dist_in_km();
let missing_km = cmp::max(required_km - rowed_km, 0);
let achieved = missing_km == 0
&& (multi_day_trips_over_required_distance >= 1
|| single_day_trips_over_required_distance >= 2);
Self {
year,
rowed_km,
category,
required_km,
missing_km,
multi_day_trips_over_required_distance: vec![],
single_day_trips_over_required_distance: vec![],
multi_day_trips_required_distance: agebracket.required_dist_multi_day_in_km(),
single_day_trips_required_distance: agebracket.required_dist_single_day_in_km(),
achieved,
}
}
pub(crate) async fn for_user_tx(
db: &mut Transaction<'_, Sqlite>,
user: &User,
exclude_last_log: bool,
) -> Option<Self> {
let Ok(agebracket) = AgeBracket::try_from(user) else {
return None;
};
let year = if Local::now().month() == 1 {
Local::now().year() - 1
} else {
Local::now().year()
};
let rowed_km = Stat::person_tx(db, Some(year), user).await.rowed_km;
let single_day_trips_over_required_distance =
Logbook::completed_wanderfahrten_with_user_over_km_in_year_tx(
db,
user,
agebracket.required_dist_single_day_in_km(),
year,
Filter::SingleDayOnly,
exclude_last_log,
)
.await;
let multi_day_trips_over_required_distance =
Logbook::completed_wanderfahrten_with_user_over_km_in_year_tx(
db,
user,
agebracket.required_dist_multi_day_in_km(),
year,
Filter::MultiDayOnly,
exclude_last_log,
)
.await;
let ret = Self::calc(
&agebracket,
rowed_km,
single_day_trips_over_required_distance.len(),
multi_day_trips_over_required_distance.len(),
year,
);
Some(Self {
multi_day_trips_over_required_distance,
single_day_trips_over_required_distance,
..ret
})
}
pub(crate) async fn for_user(db: &SqlitePool, user: &User) -> Option<Self> {
let mut tx = db.begin().await.unwrap();
let ret = Self::for_user_tx(&mut tx, user, false).await;
tx.commit().await.unwrap();
ret
}
pub(crate) async fn completed_with_last_log(
db: &mut Transaction<'_, Sqlite>,
user: &User,
) -> bool {
if let Some(status) = Self::for_user_tx(db, user, false).await {
// if user has agebracket...
if status.achieved {
// ... and has achieved the 'Fahrtenabzeichen'
let without_last_entry = Self::for_user_tx(db, user, true).await.unwrap();
if !without_last_entry.achieved {
// ... and this wasn't the case before the last logentry
return true;
}
}
}
false
}
}

335
src/model/stat.rs Normal file

@ -0,0 +1,335 @@
use std::{collections::HashMap, ops::DerefMut};
use crate::model::user::User;
use chrono::Datelike;
use serde::Serialize;
use sqlx::{FromRow, Row, Sqlite, SqlitePool, Transaction};
use super::boat::Boat;
#[derive(Serialize, Clone)]
pub struct BoatStat {
pot_years: Vec<i32>,
boats: Vec<SingleBoatStat>,
}
#[derive(Serialize, Clone)]
pub struct SingleBoatStat {
name: String,
cat: String,
location: String,
owner: String,
years: HashMap<String, i32>,
}
impl BoatStat {
pub async fn get(db: &SqlitePool) -> BoatStat {
let mut years = Vec::new();
let mut boat_stats_map: HashMap<String, SingleBoatStat> = HashMap::new();
let rows = sqlx::query(
"
SELECT
boat.id,
location.name AS location,
CAST(strftime('%Y', COALESCE(arrival, 'now')) AS INTEGER) AS year,
CAST(SUM(COALESCE(distance_in_km, 0)) AS INTEGER) AS rowed_km
FROM
boat
LEFT JOIN
logbook ON boat.id = logbook.boat_id AND logbook.arrival IS NOT NULL
LEFT JOIN
location ON boat.location_id = location.id
WHERE
not boat.external
GROUP BY
boat.id, year
ORDER BY
boat.name, year DESC;
",
)
.fetch_all(db)
.await
.unwrap();
for row in rows {
let id: i32 = row.get("id");
let boat = Boat::find_by_id(db, id).await.unwrap();
let owner = if let Some(owner) = boat.owner(db).await {
owner.name
} else {
String::from("Verein")
};
let name = boat.name.clone();
let location: String = row.get("location");
let year: i32 = row.get("year");
if year == 0 {
continue; // Boat still on water
}
if !years.contains(&year) {
years.push(year);
}
let year: String = format!("{year}");
let cat = boat.cat();
let rowed_km: i32 = row.get("rowed_km");
let boat_stat = boat_stats_map
.entry(name.clone())
.or_insert(SingleBoatStat {
name,
location,
owner,
cat,
years: HashMap::new(),
});
boat_stat.years.insert(year, rowed_km);
}
BoatStat {
pot_years: years,
boats: boat_stats_map.into_values().collect(),
}
}
}
#[derive(FromRow, Serialize, Clone)]
pub struct Stat {
name: String,
pub(crate) amount_trips: i32,
pub(crate) rowed_km: i32,
}
impl Stat {
pub async fn guest(db: &SqlitePool, year: Option<i32>) -> Stat {
let year = match year {
Some(year) => year,
None => chrono::Local::now().year(),
};
//TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server)
// proper guests
let guests = sqlx::query(&format!(
"
SELECT SUM((b.amount_seats - COALESCE(m.member_count, 0)) * l.distance_in_km) as total_guest_km,
SUM(b.amount_seats - COALESCE(m.member_count, 0)) AS amount_trips
FROM logbook l
JOIN boat b ON l.boat_id = b.id
LEFT JOIN (
SELECT logbook_id, COUNT(*) as member_count
FROM rower
GROUP BY logbook_id
) m ON l.id = m.logbook_id
WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%' AND not b.external;
"
))
.fetch_one(db)
.await
.unwrap();
let guest_km: i32 = guests.get(0);
let guest_amount_trips: i32 = guests.get(1);
// e.g. scheckbücher
let guest_user = sqlx::query(&format!(
"
SELECT CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km, COUNT(*) AS amount_trips
FROM user u
INNER JOIN rower r ON u.id = r.rower_id
INNER JOIN logbook l ON r.logbook_id = l.id
WHERE u.id NOT IN (
SELECT ur.user_id
FROM user_role ur
INNER JOIN role ro ON ur.role_id = ro.id
WHERE ro.name = 'Donau Linz'
)
AND l.distance_in_km IS NOT NULL
AND l.arrival LIKE '{year}-%'
AND u.name != 'Externe Steuerperson';
"
))
.fetch_one(db)
.await
.unwrap();
let guest_user_km: i32 = guest_user.get(0);
let guest_user_amount_trips: i32 = guest_user.get(1);
Stat {
name: "Gäste".into(),
amount_trips: guest_amount_trips + guest_user_amount_trips,
rowed_km: guest_km + guest_user_km,
}
}
pub async fn trips_people(db: &SqlitePool, year: Option<i32>) -> i32 {
let stats = Self::people(db, year).await;
let mut sum = 0;
for stat in stats {
sum += stat.amount_trips;
}
sum
}
pub async fn sum_people(db: &SqlitePool, year: Option<i32>) -> i32 {
let stats = Self::people(db, year).await;
let mut sum = 0;
for stat in stats {
sum += stat.rowed_km;
}
sum
}
pub async fn people(db: &SqlitePool, year: Option<i32>) -> Vec<Stat> {
let year = match year {
Some(year) => year,
None => chrono::Local::now().year(),
};
//TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server)
sqlx::query(&format!(
"
SELECT u.name, CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km, COUNT(*) AS amount_trips
FROM (
SELECT * FROM user
WHERE id IN (
SELECT user_id FROM user_role
JOIN role ON user_role.role_id = role.id
WHERE role.name = 'Donau Linz'
)
) u
INNER JOIN rower r ON u.id = r.rower_id
INNER JOIN logbook l ON r.logbook_id = l.id
WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%' AND u.name != 'Externe Steuerperson'
GROUP BY u.name
ORDER BY rowed_km DESC, u.name;
"
))
.fetch_all(db)
.await
.unwrap()
.into_iter()
.map(|row| Stat {
name: row.get("name"),
amount_trips: row.get("amount_trips"),
rowed_km: row.get("rowed_km"),
})
.collect()
}
pub async fn total_km_tx(db: &mut Transaction<'_, Sqlite>, user: &User) -> Stat {
//TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server)
let row = sqlx::query(&format!(
"
SELECT u.name, CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km, COUNT(*) AS amount_trips
FROM (
SELECT * FROM user
WHERE id={}
) u
INNER JOIN rower r ON u.id = r.rower_id
INNER JOIN logbook l ON r.logbook_id = l.id
WHERE l.distance_in_km IS NOT NULL;
",
user.id
))
.fetch_one(db.deref_mut())
.await
.unwrap();
Stat {
name: row.get("name"),
amount_trips: row.get("amount_trips"),
rowed_km: row.get("rowed_km"),
}
}
pub async fn total_km(db: &SqlitePool, user: &User) -> Stat {
let mut tx = db.begin().await.unwrap();
let ret = Self::total_km_tx(&mut tx, user).await;
tx.commit().await.unwrap();
ret
}
pub async fn person_tx(
db: &mut Transaction<'_, Sqlite>,
year: Option<i32>,
user: &User,
) -> Stat {
let year = match year {
Some(year) => year,
None => chrono::Local::now().year(),
};
//TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server)
let row = sqlx::query(&format!(
"
SELECT u.name, CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km, COUNT(*) AS amount_trips
FROM (
SELECT * FROM user
WHERE id={}
) u
INNER JOIN rower r ON u.id = r.rower_id
INNER JOIN logbook l ON r.logbook_id = l.id
WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%';
",
user.id
))
.fetch_one(db.deref_mut())
.await
.unwrap();
Stat {
name: row.get("name"),
amount_trips: row.get("amount_trips"),
rowed_km: row.get("rowed_km"),
}
}
pub async fn person(db: &SqlitePool, year: Option<i32>, user: &User) -> Stat {
let mut tx = db.begin().await.unwrap();
let ret = Self::person_tx(&mut tx, year, user).await;
tx.commit().await.unwrap();
ret
}
}
#[derive(Debug, Serialize)]
pub struct PersonalStat {
date: String,
km: i32,
}
pub async fn get_personal(db: &SqlitePool, user: &User) -> Vec<PersonalStat> {
sqlx::query(&format!(
"
SELECT
departure_date as date,
SUM(total_distance) OVER (ORDER BY departure_date) as km
FROM (
SELECT
date(l.departure) as departure_date,
COALESCE(SUM(l.distance_in_km),0) as total_distance
FROM
logbook l
LEFT JOIN
rower r ON l.id = r.logbook_id
WHERE
r.rower_id = {}
GROUP BY
departure_date
) as subquery
ORDER BY
departure_date;
",
user.id
))
.fetch_all(db)
.await
.unwrap()
.into_iter()
.map(|row| PersonalStat {
date: row.get("date"),
km: row.get("km"),
})
.collect()
}

@ -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!();

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

@ -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);
}
}
}

@ -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>,
}
@ -205,10 +205,21 @@ FROM user
WHERE deleted = 0
ORDER BY last_access DESC
"
)
.fetch_all(db)
.await
.unwrap()
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, 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> {

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

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

319
src/tera/admin/boat.rs Normal file

@ -0,0 +1,319 @@
use crate::model::{
boat::{Boat, BoatToAdd, BoatToUpdate},
location::Location,
log::Log,
user::{User, UserWithDetails, VorstandUser},
};
use rocket::{
form::Form,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
routes, Route, State,
};
use rocket_dyn_templates::{tera::Context, Template};
use sqlx::SqlitePool;
#[get("/boat")]
async fn index(
db: &State<SqlitePool>,
admin: VorstandUser,
flash: Option<FlashMessage<'_>>,
) -> Template {
let boats = Boat::all(db).await;
let locations = Location::all(db).await;
let users = User::all(db).await;
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("boats", &boats);
context.insert("locations", &locations);
context.insert("users", &users);
context.insert(
"loggedin_user",
&UserWithDetails::from_user(admin.user, db).await,
);
Template::render("admin/boat/index", context.into_json())
}
#[get("/boat/<boat>/delete")]
async fn delete(db: &State<SqlitePool>, admin: VorstandUser, boat: i32) -> Flash<Redirect> {
let boat = Boat::find_by_id(db, boat).await;
Log::create(db, format!("{} deleted boat: {boat:?}", admin.user.name)).await;
match boat {
Some(boat) => {
boat.delete(db).await;
Flash::success(
Redirect::to("/admin/boat"),
format!("Boot {} gelöscht", boat.name),
)
}
None => Flash::error(Redirect::to("/admin/boat"), "Boat does not exist"),
}
}
#[post("/boat/<boat_id>", data = "<data>")]
async fn update(
db: &State<SqlitePool>,
data: Form<BoatToUpdate<'_>>,
boat_id: i32,
_admin: VorstandUser,
) -> Flash<Redirect> {
let boat = Boat::find_by_id(db, boat_id).await;
let Some(boat) = boat else {
return Flash::error(Redirect::to("/admin/boat"), "Boat does not exist!");
};
match boat.update(db, data.into_inner()).await {
Ok(_) => Flash::success(Redirect::to("/admin/boat"), "Boot bearbeitet"),
Err(e) => Flash::error(Redirect::to("/admin/boat"), e),
}
}
#[post("/boat/new", data = "<data>")]
async fn create(
db: &State<SqlitePool>,
data: Form<BoatToAdd<'_>>,
_admin: VorstandUser,
) -> Flash<Redirect> {
match Boat::create(db, data.into_inner()).await {
Ok(_) => Flash::success(Redirect::to("/admin/boat"), "Boot hinzugefügt"),
Err(e) => Flash::error(Redirect::to("/admin/boat"), e),
}
}
pub fn routes() -> Vec<Route> {
routes![index, create, delete, update]
}
#[cfg(test)]
mod test {
use rocket::{
http::{ContentType, Status},
local::asynchronous::Client,
};
use sqlx::SqlitePool;
use crate::tera::admin::boat::Boat;
use crate::testdb;
#[sqlx::test]
fn test_boat_index() {
let db = testdb!();
let rocket = rocket::build().manage(db.clone());
let rocket = crate::tera::config(rocket);
let client = Client::tracked(rocket).await.unwrap();
let login = client
.post("/auth")
.header(ContentType::Form) // Set the content type to form
.body("name=admin&password=admin"); // Add the form data to the request body;
login.dispatch().await;
let req = client.get("/admin/boat");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::Ok);
let text = response.into_string().await.unwrap();
assert!(&text.contains("Neues Boot"));
assert!(&text.contains("Kaputtes Boot :-("));
assert!(&text.contains("Haichenbach"));
}
#[sqlx::test]
fn test_succ_update() {
let db = testdb!();
let boat = Boat::find_by_id(&db, 1).await.unwrap();
assert_eq!(boat.name, "Haichenbach");
let rocket = rocket::build().manage(db.clone());
let rocket = crate::tera::config(rocket);
let client = Client::tracked(rocket).await.unwrap();
let login = client
.post("/auth")
.header(ContentType::Form) // Set the content type to form
.body("name=admin&password=admin"); // Add the form data to the request body;
login.dispatch().await;
let req = client
.post("/admin/boat/1")
.header(ContentType::Form)
.body("name=Haichiii&amount_seats=1&location_id=1");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(
response.headers().get("Location").next(),
Some("/admin/boat")
);
let flash_cookie = response
.cookies()
.get("_flash")
.expect("Expected flash cookie");
assert_eq!(flash_cookie.value(), "7:successBoot bearbeitet");
let boat = Boat::find_by_id(&db, 1).await.unwrap();
assert_eq!(boat.name, "Haichiii");
}
#[sqlx::test]
fn test_update_wrong_boat() {
let db = testdb!();
let rocket = rocket::build().manage(db.clone());
let rocket = crate::tera::config(rocket);
let client = Client::tracked(rocket).await.unwrap();
let login = client
.post("/auth")
.header(ContentType::Form) // Set the content type to form
.body("name=admin&password=admin"); // Add the form data to the request body;
login.dispatch().await;
let req = client
.post("/admin/boat/1337")
.header(ContentType::Form)
.body("name=Haichiii&amount_seats=1&location_id=1");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(
response.headers().get("Location").next(),
Some("/admin/boat")
);
let flash_cookie = response
.cookies()
.get("_flash")
.expect("Expected flash cookie");
assert_eq!(flash_cookie.value(), "5:errorBoat does not exist!");
let boat = Boat::find_by_id(&db, 1).await.unwrap();
assert_eq!(boat.name, "Haichenbach");
}
#[sqlx::test]
fn test_update_wrong_foreign() {
let db = testdb!();
let rocket = rocket::build().manage(db.clone());
let rocket = crate::tera::config(rocket);
let client = Client::tracked(rocket).await.unwrap();
let login = client
.post("/auth")
.header(ContentType::Form) // Set the content type to form
.body("name=admin&password=admin"); // Add the form data to the request body;
login.dispatch().await;
let req = client
.post("/admin/boat/1")
.header(ContentType::Form)
.body("name=Haichiii&amount_seats=1&location_id=999");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(
response.headers().get("Location").next(),
Some("/admin/boat")
);
let flash_cookie = response
.cookies()
.get("_flash")
.expect("Expected flash cookie");
assert_eq!(
flash_cookie.value(),
"5:errorerror returned from database: (code: 787) FOREIGN KEY constraint failed"
);
}
#[sqlx::test]
fn test_succ_create() {
let db = testdb!();
let rocket = rocket::build().manage(db.clone());
let rocket = crate::tera::config(rocket);
assert!(Boat::find_by_name(&db, "completely-new-boat".into())
.await
.is_none());
let client = Client::tracked(rocket).await.unwrap();
let login = client
.post("/auth")
.header(ContentType::Form) // Set the content type to form
.body("name=admin&password=admin"); // Add the form data to the request body;
login.dispatch().await;
let req = client
.post("/admin/boat/new")
.header(ContentType::Form)
.body("name=completely-new-boat&amount_seats=1&location_id=1");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(
response.headers().get("Location").next(),
Some("/admin/boat")
);
let flash_cookie = response
.cookies()
.get("_flash")
.expect("Expected flash cookie");
assert_eq!(flash_cookie.value(), "7:successBoot hinzugefügt");
Boat::find_by_name(&db, "completely-new-boat".into())
.await
.unwrap();
}
#[sqlx::test]
fn test_create_db_error() {
let db = testdb!();
let rocket = rocket::build().manage(db.clone());
let rocket = crate::tera::config(rocket);
let client = Client::tracked(rocket).await.unwrap();
let login = client
.post("/auth")
.header(ContentType::Form) // Set the content type to form
.body("name=admin&password=admin"); // Add the form data to the request body;
login.dispatch().await;
let req = client
.post("/admin/boat/new")
.header(ContentType::Form)
.body("name=Haichenbach&amount_seats=1&location_id=1");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(
response.headers().get("Location").next(),
Some("/admin/boat")
);
let flash_cookie = response
.cookies()
.get("_flash")
.expect("Expected flash cookie");
assert_eq!(
flash_cookie.value(),
"5:errorerror returned from database: (code: 2067) UNIQUE constraint failed: boat.name"
);
}
}

@ -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 })

@ -0,0 +1,46 @@
use crate::model::{
personal::Achievements,
role::Role,
user::{User, UserWithDetails, VorstandUser},
};
use rocket::{get, request::FlashMessage, routes, Route, State};
use rocket_dyn_templates::{tera::Context, Template};
use sqlx::SqlitePool;
#[get("/achievement")]
async fn index(
db: &State<SqlitePool>,
admin: VorstandUser,
flash: Option<FlashMessage<'_>>,
) -> Template {
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
let role = Role::find_by_name(db, "Donau Linz").await.unwrap();
let users = User::all_with_role(db, &role).await;
let mut people = Vec::new();
let mut rowingbadge_year = None;
for user in users {
let achievement = Achievements::for_user(db, &user).await;
if let Some(badge) = &achievement.rowingbadge {
rowingbadge_year = Some(badge.year);
}
people.push((user, achievement));
}
context.insert("people", &people);
context.insert("rowingbadge_year", &rowingbadge_year.unwrap());
context.insert(
"loggedin_user",
&UserWithDetails::from_user(admin.into_inner(), db).await,
);
Template::render("achievement", context.into_json())
}
pub fn routes() -> Vec<Route> {
routes![index]
}

181
src/tera/boatdamage.rs Normal file

@ -0,0 +1,181 @@
use rocket::{
form::Form,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
routes, FromForm, Route, State,
};
use rocket_dyn_templates::Template;
use sqlx::SqlitePool;
use tera::Context;
use crate::{
model::{
boat::Boat,
boatdamage::{BoatDamage, BoatDamageFixed, BoatDamageToAdd, BoatDamageVerified},
user::{DonauLinzUser, SteeringUser, TechUser, User, UserWithDetails},
},
tera::log::KioskCookie,
};
#[get("/")]
async fn index_kiosk(
db: &State<SqlitePool>,
flash: Option<FlashMessage<'_>>,
_kiosk: KioskCookie,
) -> Template {
let boatdamages = BoatDamage::all(db).await;
let boats = Boat::all(db).await;
let user = User::all(db).await;
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("boatdamages", &boatdamages);
context.insert("boats", &boats);
context.insert("user", &user);
context.insert("show_kiosk_header", &true);
Template::render("boatdamages", context.into_json())
}
#[get("/", rank = 2)]
async fn index(
db: &State<SqlitePool>,
flash: Option<FlashMessage<'_>>,
user: DonauLinzUser,
) -> Template {
let boatdamages = BoatDamage::all(db).await;
let boats = Boat::all(db).await;
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("boatdamages", &boatdamages);
context.insert("boats", &boats);
context.insert(
"loggedin_user",
&UserWithDetails::from_user(user.into_inner(), db).await,
);
Template::render("boatdamages", context.into_json())
}
#[derive(FromForm)]
pub struct FormBoatDamageToAdd<'r> {
pub boat_id: i64,
pub desc: &'r str,
pub lock_boat: bool,
}
#[post("/", data = "<data>", rank = 2)]
async fn create<'r>(
db: &State<SqlitePool>,
data: Form<FormBoatDamageToAdd<'r>>,
user: DonauLinzUser,
) -> Flash<Redirect> {
let user: User = user.into_inner();
let boatdamage_to_add = BoatDamageToAdd {
boat_id: data.boat_id,
desc: data.desc,
lock_boat: data.lock_boat,
user_id_created: user.id as i32,
};
match BoatDamage::create(db, boatdamage_to_add).await {
Ok(_) => Flash::success(
Redirect::to("/boatdamage"),
"Bootsschaden erfolgreich hinzugefügt",
),
Err(e) => Flash::error(Redirect::to("/boatdamage"), format!("Fehler: {e}")),
}
}
#[derive(FromForm)]
pub struct FormBoatDamageToAddKiosk<'r> {
pub boat_id: i64,
pub desc: &'r str,
pub lock_boat: bool,
pub user_id: i32,
}
#[post("/", data = "<data>")]
async fn create_from_kiosk<'r>(
db: &State<SqlitePool>,
data: Form<FormBoatDamageToAddKiosk<'r>>,
_kiosk: KioskCookie,
) -> Flash<Redirect> {
let boatdamage_to_add = BoatDamageToAdd {
boat_id: data.boat_id,
desc: data.desc,
lock_boat: data.lock_boat,
user_id_created: data.user_id,
};
match BoatDamage::create(db, boatdamage_to_add).await {
Ok(_) => Flash::success(
Redirect::to("/boatdamage"),
"Bootsschaden erfolgreich hinzugefügt",
),
Err(e) => Flash::error(Redirect::to("/boatdamage"), format!("Fehler: {e}")),
}
}
#[derive(FromForm)]
pub struct FormBoatDamageFixed<'r> {
pub desc: &'r str,
}
#[post("/<boatdamage_id>/fixed", data = "<data>")]
async fn fixed<'r>(
db: &State<SqlitePool>,
data: Form<FormBoatDamageFixed<'r>>,
boatdamage_id: i32,
coxuser: SteeringUser,
) -> Flash<Redirect> {
let boatdamage = BoatDamage::find_by_id(db, boatdamage_id).await.unwrap(); //TODO: Fix
let boatdamage_fixed = BoatDamageFixed {
desc: data.desc,
user_id_fixed: coxuser.id as i32,
};
match boatdamage.fixed(db, boatdamage_fixed).await {
Ok(_) => Flash::success(Redirect::to("/boatdamage"), "Bootsschaden behoben."),
Err(e) => Flash::error(Redirect::to("/boatdamage"), format!("Error: {e}")),
}
}
#[derive(FromForm)]
pub struct FormBoatDamageVerified<'r> {
desc: &'r str,
}
#[post("/<boatdamage_id>/verified", data = "<data>")]
async fn verified<'r>(
db: &State<SqlitePool>,
data: Form<FormBoatDamageVerified<'r>>,
boatdamage_id: i32,
techuser: TechUser,
) -> Flash<Redirect> {
let boatdamage = BoatDamage::find_by_id(db, boatdamage_id).await.unwrap(); //TODO: Fix
let boatdamage_verified = BoatDamageVerified {
desc: data.desc,
user_id_verified: techuser.id as i32,
};
match boatdamage.verified(db, boatdamage_verified).await {
Ok(_) => Flash::success(Redirect::to("/boatdamage"), "Bootsschaden verifiziert"),
Err(e) => Flash::error(Redirect::to("/boatdamage"), format!("Error: {e}")),
}
}
pub fn routes() -> Vec<Route> {
routes![
index,
index_kiosk,
create,
fixed,
verified,
create_from_kiosk
]
}

365
src/tera/ergo.rs Normal file

@ -0,0 +1,365 @@
use std::env;
use chrono::{Datelike, Utc};
use rocket::{
form::Form,
fs::TempFile,
get,
http::ContentType,
post,
request::FlashMessage,
response::{Flash, Redirect},
routes, FromForm, Route, State,
};
use rocket_dyn_templates::{context, Template};
use serde::Serialize;
use sqlx::SqlitePool;
use tera::Context;
use crate::model::{
log::Log,
notification::Notification,
role::Role,
user::{AdminUser, User, UserWithDetails},
};
#[derive(Serialize)]
struct ErgoStat {
id: i64,
name: String,
dob: Option<String>,
weight: Option<String>,
sex: Option<String>,
result: Option<String>,
}
#[get("/final")]
async fn send(db: &State<SqlitePool>, _user: AdminUser) -> Template {
let thirty = sqlx::query_as!(
ErgoStat,
"SELECT id, name, dirty_thirty as result, dob, weight, sex FROM user WHERE deleted = 0 AND dirty_thirty is not null ORDER BY result DESC"
)
.fetch_all(db.inner())
.await
.unwrap();
let dozen= sqlx::query_as!(
ErgoStat,
"SELECT id, name, dirty_dozen as result, dob, weight, sex FROM user WHERE deleted = 0 AND dirty_dozen is not null ORDER BY result DESC"
)
.fetch_all(db.inner())
.await
.unwrap();
Template::render(
"ergo/final",
context!(loggedin_user: &UserWithDetails::from_user(_user.user, db).await, thirty, dozen),
)
}
#[get("/reset")]
async fn reset(db: &State<SqlitePool>, _user: AdminUser) -> Flash<Redirect> {
sqlx::query!("UPDATE user SET dirty_thirty = NULL, dirty_dozen = NULL;")
.execute(db.inner())
.await
.unwrap();
Flash::success(
Redirect::to("/ergo"),
"Erfolgreich zurückgesetzt (Bilder müssen manuell gelöscht werden!)",
)
}
#[get("/<challenge>/user/<user_id>/new?<new>")]
async fn update(
db: &State<SqlitePool>,
_admin: AdminUser,
challenge: &str,
user_id: i64,
new: &str,
) -> Flash<Redirect> {
if challenge == "thirty" {
sqlx::query!("UPDATE user SET dirty_thirty = ? WHERE id=?", new, user_id)
.execute(db.inner())
.await
.unwrap();
Flash::success(Redirect::to("/ergo"), "Succ")
} else if challenge == "dozen" {
sqlx::query!("UPDATE user SET dirty_dozen = ? WHERE id=?", new, user_id)
.execute(db.inner())
.await
.unwrap();
Flash::success(Redirect::to("/ergo"), "Succ")
} else {
Flash::error(
Redirect::to("/ergo"),
"Challenge not found (should be thirty or dozen)",
)
}
}
#[get("/")]
async fn index(db: &State<SqlitePool>, user: User, flash: Option<FlashMessage<'_>>) -> Template {
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert(
"loggedin_user",
&UserWithDetails::from_user(user.clone(), db).await,
);
if !user.has_role(db, "ergo").await {
return Template::render("ergo/missing-data", context.into_json());
}
let users = User::ergo(db).await;
let thirty = sqlx::query_as!(
ErgoStat,
"SELECT id, name, dirty_thirty as result, dob, weight, sex FROM user WHERE deleted = 0 AND dirty_thirty is not null ORDER BY result DESC"
)
.fetch_all(db.inner())
.await
.unwrap();
let dozen= sqlx::query_as!(
ErgoStat,
"SELECT id, name, dirty_dozen as result, dob, weight, sex FROM user WHERE deleted = 0 AND dirty_dozen is not null ORDER BY result DESC"
)
.fetch_all(db.inner())
.await
.unwrap();
context.insert("users", &users);
context.insert("thirty", &thirty);
context.insert("dozen", &dozen);
Template::render("ergo/index", context.into_json())
}
#[derive(FromForm, Debug)]
pub struct UserAdd {
birthyear: i32,
weight: i64,
sex: String,
}
#[post("/set-data", data = "<data>")]
async fn new_user(db: &State<SqlitePool>, data: Form<UserAdd>, user: User) -> Flash<Redirect> {
if user.has_role(db, "ergo").await {
return Flash::error(Redirect::to("/ergo"), "Du hast deine Daten schon eingegeben. Wenn du sie updaten willst, melde dich bitte bei it@rudernlinz.at");
}
// check data
if data.birthyear < 1900 || data.birthyear > chrono::Utc::now().year() - 5 {
return Flash::error(Redirect::to("/ergo"), "Bitte überprüfe dein Geburtsjahr...");
}
if data.weight < 20 || data.weight > 200 {
return Flash::error(Redirect::to("/ergo"), "Bitte überprüfe dein Gewicht...");
}
if &data.sex != "f" && &data.sex != "m" {
return Flash::error(Redirect::to("/ergo"), "Bitte überprüfe dein Geschlecht...");
}
// set data
user.update_ergo(db, data.birthyear, data.weight, &data.sex)
.await;
// inform all other `ergo` users
let ergo = Role::find_by_name(db, "ergo").await.unwrap();
Notification::create_for_role(
db,
&ergo,
&format!("{} nimmt heuer an der Ergochallenge teil 💪", user.name),
"Ergo Challenge",
None,
None,
)
.await;
// add to `ergo` group
user.add_role(db, &ergo).await.unwrap();
Flash::success(
Redirect::to("/ergo"),
"Du hast deine Daten erfolgreich eingegeben. Viel Spaß beim Schwitzen :-)",
)
}
#[derive(FromForm, Debug)]
pub struct ErgoToAdd<'a> {
user: i64,
result: String,
proof: TempFile<'a>,
}
#[post("/thirty", data = "<data>", format = "multipart/form-data")]
async fn new_thirty(
db: &State<SqlitePool>,
mut data: Form<ErgoToAdd<'_>>,
created_by: User,
) -> Flash<Redirect> {
let user = User::find_by_id(db, data.user as i32).await.unwrap();
let extension = if data.proof.content_type() == Some(&ContentType::JPEG) {
"jpg"
} else {
return Flash::error(Redirect::to("/ergo"), "Es werden nur JPG Bilder akzeptiert");
};
let base_dir = env::current_dir().unwrap();
let file_path = base_dir.join(format!(
"data-ergo/thirty/{}_{}.{extension}",
user.name,
Utc::now()
));
if let Err(e) = data.proof.move_copy_to(file_path).await {
eprintln!("Failed to persist file: {:?}", e);
}
let result = data.result.trim_start_matches(['0', ' ']);
sqlx::query!(
"UPDATE user SET dirty_thirty = ? where id = ?",
result,
data.user
)
.execute(db.inner())
.await
.unwrap(); //Okay, because we can only create a User of a valid id
Log::create(
db,
format!("{} created thirty-ergo entry: {data:?}", created_by.name),
)
.await;
let ergo = Role::find_by_name(db, "ergo").await.unwrap();
Notification::create_for_role(
db,
&ergo,
&format!(
"{} ist gerade die Dirty Thirty Challenge gefahren 🥵",
user.name
),
"Ergo Challenge",
Some("/ergo"),
None,
)
.await;
Flash::success(Redirect::to("/ergo"), "Erfolgreich eingetragen")
}
fn format_time(input: &str) -> String {
let input = if input.starts_with(":") {
&format!("00{input}")
} else {
input
};
let mut parts: Vec<&str> = input.split(':').collect();
// If there's only seconds (e.g., "24.2"), treat it as "00:00:24.2"
if parts.len() == 1 {
parts.insert(0, "0"); // Add "0" for hours
parts.insert(0, "0"); // Add "0" for minutes
}
// If there are two parts (e.g., "4:24.2"), treat it as "00:04:24.2"
if parts.len() == 2 {
parts.insert(0, "0"); // Add "0" for hours
}
// Now parts should have [hours, minutes, seconds]
let hours = if parts[0].len() == 1 {
format!("0{}", parts[0])
} else {
parts[0].to_string()
};
let minutes = if parts[1].len() == 1 {
format!("0{}", parts[1])
} else {
parts[1].to_string()
};
let seconds = parts[2];
// Split seconds into whole and fractional parts
let (sec_int, sec_frac) = seconds.split_once('.').unwrap_or((seconds, "0"));
// Format the time as "hh:mm:ss.s"
format!(
"{}:{}:{}.{:1}",
hours,
minutes,
sec_int,
sec_frac.chars().next().unwrap_or('0')
)
}
#[post("/dozen", data = "<data>", format = "multipart/form-data")]
async fn new_dozen(
db: &State<SqlitePool>,
mut data: Form<ErgoToAdd<'_>>,
created_by: User,
) -> Flash<Redirect> {
let user = User::find_by_id(db, data.user as i32).await.unwrap();
let extension = if data.proof.content_type() == Some(&ContentType::JPEG) {
"jpg"
} else {
return Flash::error(Redirect::to("/ergo"), "Es werden nur JPG Bilder akzeptiert");
};
let base_dir = env::current_dir().unwrap();
let file_path = base_dir.join(format!(
"data-ergo/dozen/{}_{}.{extension}",
user.name,
Utc::now()
));
if let Err(e) = data.proof.move_copy_to(file_path).await {
eprintln!("Failed to persist file: {:?}", e);
}
let result = data.result.trim_start_matches(['0', ' ']);
let result = if result.contains(":") || result.contains(".") {
format_time(result)
} else {
result.to_string()
};
sqlx::query!(
"UPDATE user SET dirty_dozen = ? where id = ?",
result,
data.user
)
.execute(db.inner())
.await
.unwrap(); //Okay, because we can only create a User of a valid id
Log::create(
db,
format!("{} created dozen-ergo entry: {data:?}", created_by.name),
)
.await;
let ergo = Role::find_by_name(db, "ergo").await.unwrap();
Notification::create_for_role(
db,
&ergo,
&format!(
"{} ist gerade die Dirty Dozen Challenge gefahren 🥵",
user.name
),
"Ergo Challenge",
Some("/ergo"),
None,
)
.await;
Flash::success(Redirect::to("/ergo"), "Erfolgreich eingetragen")
}
pub fn routes() -> Vec<Route> {
routes![index, new_thirty, new_dozen, send, reset, update, new_user]
}
#[cfg(test)]
mod test {}

1111
src/tera/log.rs Normal file

File diff suppressed because it is too large Load Diff

@ -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());
}

@ -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]
}

91
src/tera/stat.rs Normal file

@ -0,0 +1,91 @@
use rocket::{get, routes, Route, State};
use rocket_dyn_templates::{context, Template};
use sqlx::SqlitePool;
use crate::model::{
stat::{self, BoatStat, Stat},
user::{DonauLinzUser, UserWithDetails},
};
use super::log::KioskCookie;
#[get("/boats", rank = 2)]
async fn index_boat(db: &State<SqlitePool>, user: DonauLinzUser) -> Template {
let stat = BoatStat::get(db).await;
let kiosk = false;
Template::render(
"stat.boats",
context!(loggedin_user: &UserWithDetails::from_user(user.into_inner(), db).await, stat, kiosk),
)
}
#[get("/boats")]
async fn index_boat_kiosk(db: &State<SqlitePool>, _kiosk: KioskCookie) -> Template {
let stat = BoatStat::get(db).await;
let kiosk = true;
Template::render("stat.boats", context!(stat, kiosk, show_kiosk_header: true))
}
#[get("/?<year>", rank = 2)]
async fn index(db: &State<SqlitePool>, user: DonauLinzUser, year: Option<i32>) -> Template {
let stat = Stat::people(db, year).await;
let club_km = Stat::sum_people(db, year).await;
let club_trips = Stat::trips_people(db, year).await;
let guest_km = Stat::guest(db, year).await;
let personal = stat::get_personal(db, &user).await;
let kiosk = false;
Template::render(
"stat.people",
context!(loggedin_user: &UserWithDetails::from_user(user.into_inner(), db).await, stat, personal, kiosk, guest_km, club_km, club_trips),
)
}
#[get("/?<year>")]
async fn index_kiosk(db: &State<SqlitePool>, _kiosk: KioskCookie, year: Option<i32>) -> Template {
let stat = Stat::people(db, year).await;
let club_km = Stat::sum_people(db, year).await;
let club_trips = Stat::trips_people(db, year).await;
let guest_km = Stat::guest(db, year).await;
let kiosk = true;
Template::render(
"stat.people",
context!(stat, kiosk, show_kiosk_header: true, guest_km, club_km, club_trips),
)
}
pub fn routes() -> Vec<Route> {
routes![index, index_kiosk, index_boat, index_boat_kiosk]
}
#[cfg(test)]
mod test {
use rocket::{http::Status, local::asynchronous::Client};
use sqlx::SqlitePool;
use crate::testdb;
#[sqlx::test]
fn test_kiosk_stat() {
let db = testdb!();
let rocket = rocket::build().manage(db.clone());
let rocket = crate::tera::config(rocket);
let client = Client::tracked(rocket).await.unwrap();
// "Log in"
let req = client.get("/log/kiosk/ekrv2019/Linz");
let _ = req.dispatch().await;
// `/stat` should be viewable
let req = client.get("/stat");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::Ok);
let text = response.into_string().await.unwrap();
assert!(text.contains("Statistik"));
}
}

@ -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,15 +22,17 @@
</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"

@ -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 %}

@ -0,0 +1,241 @@
{% import "includes/macros" as macros %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">Ergo Challenges</h1>
<div class="grid gap-3">
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Ergo Challenge?!</h2>
<div class="p-3">
<ul class="list-disc ms-2">
<li class="py-1">
<a href="https://rudernlinz.at/termin"
target="_blank"
class="link-primary">Überblick der Challenges</a>
</li>
<li class="py-1">
Eintragung ist jederzeit möglich, alle Daten die bis Sonntag 23:59 hier hochgeladen wurden, werden gesammelt an die Ister Ergo Challenge geschickt
<li class="py-1">
Montag &rarr; gemeinsames Training; bitte um <a href="/planned" class="link-primary">Anmeldung</a>, damit jeder einen Ergo hat
</li>
<li class="py-1">
<a href="https://data.ergochallenge.at"
target="_blank"
style="text-decoration: underline">Offizielle Ergebnisse</a>, bei Fehlern direkt mit <a href="mailto:office@ergochallenge.at"
style="text-decoration: underline">Christian (Ister)</a> Kontakt aufnehmen
</li>
</ul>
</div>
<details class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md p-2">
<summary class="cursor-pointer">
<strong>Um was geht es bei den Ergochallenges?</strong>
</summary>
<p class="py-2 ">
Der Linzer Verein Ister veranstaltet seit einigen Jahren zwei Challenges im Winter, Dirty Thirty (6x im Winter) und Dirty Dozen (12 Wochen lang).
<ul class="list-decimal ms-4">
<li class="py-1">
Bei <strong>Dirty Thirty</strong> geht es darum so viele Kilometer wie möglich in 30 Minuten zu fahren.
</li>
<li class="py-1">
Bei <strong>Dirty Dozen</strong> werden jede Woche neue Ziele ausgeschrieben, gestartet wird mit einem Halbmarathon und es geht runter bis auf 100m.
</li>
</ul>
<p class="py-2">
Ihr könnt gerne bei allen Challenges mitmachen und es ist möglich jederzeit ein- bzw. auszusteigen. Für alle komplett neuen Teilnehmer würde ich allerdings empfehlen die ersten beiden Dirty Dozen Challenges (Halbmarathon und 16 Kilometer) auszulassen und es am Anfang etwas ruhiger anzugehen. Es steht der Spaß und die Festigung der Technik im Vordergrund, nicht Rekorde.
</p>
<strong>Video Tipps 🐞</strong>
<ul class="list-disc ms-3">
<li class="py-1">
<a href="https://www.youtube.com/watch?v=TJsQPV6LNPI"
target="_blank"
style="text-decoration: underline">Intro</a>
</li>
<li class="py-1">
<a href="https://www.youtube.com/watch?v=VE663Kg0c00"
target="_blank"
style="text-decoration: underline">Grundlagen</a>
</li>
<li class="py-1">
<a href="https://www.youtube.com/watch?v=KOacKLOpWkI"
target="_blank"
style="text-decoration: underline">Schlagaufbau</a>
</li>
<li class="py-1">
<a href="https://www.youtube.com/watch?v=m6VP11EDjcM"
target="_blank"
style="text-decoration: underline">PM5 Monitor</a>
</li>
</ul>
</details>
<details class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md p-2">
<summary class="cursor-pointer">
<strong>Deine Daten</strong>
</summary>
<div class="pt-3">
<p>
Folgende Daten hat der Ruderassistent von dir. Wenn diese nicht mehr aktuell sind, bitte gewünschte Änderungen an unseren Schriftführer melden (<a href="mailto:info@rudernlinz.at"
class="text-primary-600 dark:text-primary-200 hover:text-primary-950 hover:dark:text-primary-300 underline"
target="_blank">it@rudernlinz.at</a>).
<br />
<br />
<ul>
<li>Geburtsdatum: {{ loggedin_user.dob }}</li>
<li>Gewicht: {{ loggedin_user.weight }} kg</li>
<li>Geschlecht: {{ loggedin_user.sex }}</li>
</ul>
</p>
</div>
</details>
</div>
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow grid gap-3">
<h2 class="h2">
Neuer Eintrag
</h1>
<details class="p-2">
<summary class="cursor-pointer">Dirty Thirty</summary>
<div class="mt-3">
<form action="/ergo/thirty"
class="grid gap-3"
method="post"
enctype="multipart/form-data">
<div>
<label for="user-thirty" class="text-sm text-gray-600 dark:text-gray-100">Ergo-Fahrer</label>
<select name="user" id="user-thirty" class="input rounded-md">
<option disabled="disabled">User auswählen</option>
{% for user in users %}
{% if user.id == loggedin_user.id %}
<option value="{{ user.id }}" selected="selected">{{ user.name }}</option>
{% else %}
<option value="{{ user.id }}">{{ user.name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
{{ macros::input(label="Distanz [m]", name="result", required=true, type="number", class="input rounded-md") }}
<div>
<label for="file-thirty" class="text-sm text-gray-600 dark:text-gray-100">Ergebnis-Foto vom Ergo-Display</label>
<input type="file"
id="file-thirty"
name="proof"
class="input rounded-md"
accept="image/*">
</div>
<div class="text-end">
<input type="submit" value="Speichern" class="btn btn-primary btn-fw m-auto" />
</div>
</form>
</div>
</details>
<details class="p-2">
<summary class="cursor-pointer">Dirty Dozen</summary>
<div class="mt-3">
<form action="/ergo/dozen"
class="grid gap-3"
method="post"
enctype="multipart/form-data">
<div>
<label for="user-dozen" class="text-sm text-gray-600 dark:text-gray-100">Ergo-Fahrer</label>
<select name="user" id="user-dozen" class="input rounded-md">
<option disabled="disabled">User auswählen</option>
{% for user in users %}
{% if user.id == loggedin_user.id %}
<option value="{{ user.id }}" selected="selected">{{ user.name }}</option>
{% else %}
<option value="{{ user.id }}">{{ user.name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
{{ macros::input(label="Zeit [hh:mm:ss.s] oder Distanz [m]", name="result", required=true, type="text", class="input rounded-md", pattern="(?:\d+:\d{2}:\d{2}\.\d+|\d{1,2}:\d{2}\.\d+|\d+(\.\d+)?)") }}
<div>
<label for="file-dozen" class="text-sm text-gray-600 dark:text-gray-100">Ergebnis-Foto vom Ergo-Display</label>
<input type="file"
id="file-dozen"
name="proof"
class="input rounded-md"
accept="image/*">
</div>
<div class="text-end">
<input type="submit" value="Speichern" class="btn btn-primary btn-fw m-auto" />
</div>
</form>
</div>
</details>
</div>
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow grid gap-3">
<h2 class="h2">Aktuelle Woche</h2>
<details class="p-2">
<summary class="cursor-pointer">
Dirty Thirty <small class="text-gray-600 dark:text-white">({{ thirty | length }})</small>
</summary>
<div class="mt-3">
<ol>
{% for stat in thirty %}
<li>
<strong>{{ stat.name }}:</strong> {{ stat.result }}
</li>
{% endfor %}
</ol>
</div>
</details>
<details class="p-2">
<summary class="cursor-pointer">
Dirty Dozen <small class="text-gray-600 dark:text-white">({{ dozen | length }})</small>
</summary>
<div class="mt-3">
<ol>
{% for stat in dozen %}
<li>
<strong>{{ stat.name }}:</strong> {{ stat.result }}
</li>
{% endfor %}
</ol>
</div>
</details>
</div>
{% if "admin" in loggedin_user.roles %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow grid gap-3">
<h2 class="h2">Update</h2>
<details class="p-2">
<summary class="cursor-pointer">
Dirty Thirty <small class="text-gray-600 dark:text-white">({{ thirty | length }})</small>
</summary>
<div class="mt-3">
<ol>
{% for stat in thirty %}
<li>
<form action="/ergo/thirty/user/{{ stat.id }}/new" method="get">
{{ stat.name }}:
<input type="text" value="{{ stat.result }}" name="new" style="color: black" />
<input type="submit" />
</form>
</li>
{% endfor %}
</ol>
</div>
</details>
<details class="p-2">
<summary class="cursor-pointer">
Dirty Dozen <small class="text-gray-600 dark:text-white">({{ dozen | length }})</small>
</summary>
<div class="mt-3">
<ol>
{% for stat in dozen %}
<li>
<form action="/ergo/dozen/user/{{ stat.id }}/new" method="get">
{{ stat.name }}:
<input type="text" value="{{ stat.result }}" name="new" style="color: black" />
<input type="submit" />
</form>
</li>
{% endfor %}
</ol>
</div>
</details>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock content %}

@ -0,0 +1,65 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/log" as log %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">
Logbuch
{% if loggedin_user and "Vorstand" in loggedin_user.roles %}
<select id="yearSelect"
onchange="changeYear()"
style="background: transparent;
background-image: none;
text-decoration: underline"></select>
{% endif %}
</h1>
<div class="mt-3">
<div class="search-wrapper">
<label for="name" class="sr-only">Suche</label>
<input type="search"
name="name"
id="filter-js"
class="search-bar"
placeholder="Suchen nach Bootsname oder Ruderer...">
</div>
<div id="filter-result-js" class="search-result"></div>
{% for log in logs %}
{% set_global allowed_to_edit = false %}
{% if loggedin_user %}
{% if "Vorstand" in loggedin_user.roles %}
{% set_global allowed_to_edit = true %}
{% endif %}
{% endif %}
{{ log::show_old(log=log, state="completed", only_ones=false, index=loop.index, allowed_to_edit=allowed_to_edit) }}
{% endfor %}
</div>
</div>
<script>
function getYearFromURL() {
var queryParams = new URLSearchParams(window.location.search);
return queryParams.get('year');
}
function populateYears() {
var select = document.getElementById('yearSelect');
var currentYear = new Date().getFullYear();
var selectedYear = getYearFromURL() || currentYear;
for (var year = 2019; year <= currentYear; year++) {
var option = document.createElement('option');
option.value = option.textContent = year;
if (year == selectedYear) {
option.selected = true;
}
select.appendChild(option);
}
}
function changeYear() {
var selectedYear = document.getElementById('yearSelect').value;
window.location.href = '?year=' + selectedYear;
}
// Call this function when the page loads
populateYears();
</script>
{% endblock content %}

490
templates/planned.html.tera Normal file

@ -0,0 +1,490 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/log" as log %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-xl w-full grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% if "paid" not in loggedin_user.roles and "Donau Linz" in loggedin_user.roles %}
<div class="grid gap-3 sm:col-span-2 lg:col-span-3">
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Vereinsgebühren</h2>
<div class="text-sm p-3">
{% include "includes/qrcode" %}
<div id="qrcode"
style="float: left;
padding-top: 10 pt;
padding-right: 10pt;
padding-bottom: 10pt"></div>
<script type="text/javascript">
var sepaqr = new sepaQR({
benefName: 'ASKÖ Ruderverein Donau Linz',
benefBIC: 'ASPKAT2LXXX',
benefAccNr: 'AT582032032100729256',
amountEuro: {{ fee.sum_in_cents/100 }},
remittanceInf: 'Vereinsgebühren {{ fee.name }}',
});
var code = sepaqr.makeCodeInto("qrcode");
</script>
<b>Dein Vereinsbeitrag ({{ fee.name }}): {{ fee.sum_in_cents / 100 }}€
{% if fee.parts | length == 1 %}({{ fee.parts[0].0 }}){% endif %}
</b>
<br />
{% if fee.parts | length > 1 %}
<small>
Setzt sich zusammen aus:
<ul style="list-style: circle; padding-left: 1em;">
{% for p in fee.parts %}
<li>
{{ p.0 }} ({{ p.1 / 100 }}€)
{% if not loop.last %}+{% endif %}
</li>
{% endfor %}
</ul>
</small>
{% endif %}
Bitte auf folgendes Konto überweisen: IBAN AT58 2032 0321 0072 9256. Alternativ kannst du auch mit deiner Bankapp den QR Code scannen, damit sollten alle Daten vorausgefüllt sein.
<br />
Falls die Berechnung nicht stimmt (korrekte Preise findest du <a href="https://rudernlinz.at/unser-verein/gebuhren/"
target="_blank"
rel="noopener noreferrer">hier</a>) melde dich bitte bei it@rudernlinz.at. @Studenten: Bitte die aktuelle Studienbestätigung an it@rudernlinz.at schicken.
<br />
<small><a href="https://rudernlinz.at/unser-verein/vorstand/" target="_blank">Unsere Kassiere</a> aktualisieren den Ruderassistent unregelmäßig mit unserem Bankkonto. Falls du schon bezahlt hast, kannst du diese Nachricht getrost ignorieren. Wenn du schon vor "einigen Wochen" bezahlt hast bitte bei kassier@rudernlinz.at nachfragen :^)</small>
</div>
</div>
</div>
{% endif %}
<h1 class="h1 sm:col-span-2 lg:col-span-3">Ausfahrten</h1>
{% include "includes/buttons" %}
{% for day in days %}
{% set amount_trips = day.events | length + day.trips | length %}
{% set_global day_cox_needed = false %}
{% if day.events | length > 0 %}
{% for event in day.events %}
{% if event.cox_needed %}
{% set_global day_cox_needed = true %}
{% endif %}
{% endfor %}
{% endif %}
<div id="{{ day.day| date(format="%Y-%m-%d") }}"
class="bg-white dark:bg-primary-900 rounded-md flex justify-between flex-col shadow reset-js"
style="min-height: 10rem"
data-trips="{{ amount_trips }}"
data-month="{{ day.day| date(format='%m') }}"
data-coxneeded="{{ day_cox_needed }}">
<div>
<h2 class="font-bold uppercase tracking-wide text-center rounded-t-md {% if day.is_pinned %} text-white bg-primary-950 {% else %} text-primary-950 dark:text-white bg-gray-200 dark:bg-primary-950 bg-opacity-80 {% endif %} text-lg px-3 py-3 ">
{{ day.day| date(format="%d.%m.%Y") }}
<small class="inline-block ml-1 text-xs {% if day.is_pinned %} text-gray-200 {% else %} text-gray-500 dark:text-gray-100 {% endif %}">{{ day.day | date(format="%A", locale="de_AT") }}
{% if day.max_waterlevel %}
&bullet; <a href="https://hydro.ooe.gv.at/#/overview/Wasserstand/station/16668/Linz/Wasserstand"
target="_blank"
title="Prognostizierter maximaler Wasserstand am {{ day.day | date(format="%A", locale="de_AT") }}: {{ day.max_waterlevel.avg }} ± {{ day.max_waterlevel.fluctuation }} cm (ungeprüfte Rohdaten, für Details siehe die Infos dazu im Impressum)">🌊{{ day.max_waterlevel.avg }} ± {{ day.max_waterlevel.fluctuation }} cm</a>
{% endif %}
</small>
{% if day.weather %}
<small class="inline-block text-xs {% if day.is_pinned %} text-gray-200 {% else %} text-gray-500 dark:text-gray-100 {% endif %}">
Temp:&nbsp;{{ day.weather.max_temp | round }}° &bullet; Windböe:&nbsp;{{ day.weather.wind_gust | round }}&nbsp;km/h &bullet; Regen:&nbsp;{{ day.weather.rain_mm | round }}&nbsp;mm
</small>
{% endif %}
</h2>
{% if day.events | length > 0 or day.trips | length > 0 or day.boat_reservations | length > 0 %}
<div class="grid grid-cols-1 gap-3 mb-3">
{# --- START Boatreservations--- #}
{% for _, reservations_for_event in day.boat_reservations %}
{% set reservation = reservations_for_event[0] %}
<div class="pt-2 px-3 border-gray-200">
<div class="flex justify-between items-center">
<div class="mr-1">
<span class="text-primary-900 dark:text-white">
⏳ {{ reservation.time_desc }} <small class="text-gray-600 dark:text-gray-100">({{ reservation.user_applicant.name }})</small><br/>
<strong>
{% for reservation in reservations_for_event -%}
{{ reservation.boat.name }}
{%- if not loop.last %} + {% endif -%}
{% endfor -%}
</strong>
</span>
<small class="text-gray-600 dark:text-gray-100">(Reservierung - {{ reservation.usage}})</small>
</div>
</div>
</div>
{% endfor %}
{# --- END Boatreservations--- #}
{# --- START Events --- #}
{% if day.events | length > 0 %}
{% for event in day.events | sort(attribute="planned_starting_time") %}
{% set amount_cur_cox = event.cox | length %}
{% set amount_cox_missing = event.planned_amount_cox - amount_cur_cox %}
<div class="pt-2 px-3 border-t border-gray-200"
style="order: {{ event.planned_starting_time | replace(from=":", to="") }}">
<div class="flex justify-between items-center">
<div class="mr-1">
{% if event.always_show and not day.regular_sees_this_day %}
<span title="Du siehst diese Ausfahrt schon, obwohl sie mehr als {{ amount_days_to_show_trips_ahead }} Tage in der Zukunft liegt. Du Magier!">🔮</span>
{% endif -%}
{%- if event.max_people == 0 %}
<strong class="text-[#f43f5e]">&#9888; Absage
{{ event.planned_starting_time }}
Uhr
</strong>
<small class="text-[#f43f5e]">({{ event.name }}
{%- if event.trip_type %}
- {{ event.trip_type.icon | safe }}&nbsp;{{ event.trip_type.name }}
{%- endif -%}
)</small>
{% else %}
<strong class="text-primary-900 dark:text-white">
{{ event.planned_starting_time }}
Uhr
</strong>
<small class="text-gray-600 dark:text-gray-100">({{ event.name }}
{%- if event.trip_type %}
- {{ event.trip_type.icon | safe }}&nbsp;{{ event.trip_type.name }}
{%- endif -%}
)</small>
{% endif %}
<br />
<a href="#" data-sidebar="true" data-trigger="sidebar" data-header="<strong>{{ event.planned_starting_time }} Uhr</strong> ({{ event.name }})
{% if event.trip_type %}<small class='block'>{{ event.trip_type.desc }}</small>{% endif %}
{% if event.notes %}<small class='block'>{{ event.notes }}</small>{% endif %}
" data-body="#event{{ event.trip_details_id }}" class="inline-block link-primary mr-3">
Details
</a>
</div>
<div class="text-right grid gap-2">
{# --- START Row Buttons --- #}
{% set_global cur_user_participates = false %}
{% for rower in event.rower %}
{% if rower.name == loggedin_user.name %}
{% set_global cur_user_participates = true %}
{% endif %}
{% endfor %}
{% if cur_user_participates %}
<a href="/planned/remove/{{ event.trip_details_id }}"
class="btn btn-attention btn-fw">Abmelden</a>
{% endif %}
{% if event.max_people > event.rower | length and cur_user_participates == false %}
<a href="/planned/join/{{ event.trip_details_id }}"
class="btn btn-primary btn-fw"
{% if event.trip_type %}onclick="return confirm('{{ event.trip_type.question }}');"{% endif %}>Mitrudern</a>
{% endif %}
{# --- END Row Buttons --- #}
{# --- START Cox Buttons --- #}
{% if loggedin_user.allowed_to_steer %}
{% set_global cur_user_participates = false %}
{% for cox in event.cox %}
{% if cox.name == loggedin_user.name %}
{% set_global cur_user_participates = true %}
{% endif %}
{% endfor %}
{% if cur_user_participates %}
<a href="/cox/remove/{{ event.id }}"
class="block btn btn-attention btn-fw">
{% include "includes/cox-icon" %}
Abmelden
</a>
{% elif event.planned_amount_cox > 0 %}
<a href="/cox/join/{{ event.id }}"
class="block btn {% if amount_cox_missing > 0 %} btn-dark {% else %} btn-gray {% endif %} btn-fw"
{% if event.trip_type %}onclick="return confirm('{{ event.trip_type.question }}');"{% endif %}>
{% include "includes/cox-icon" %}
Steuern
</a>
{% endif %}
{% endif %}
{# --- END Cox Buttons --- #}
</div>
</div>
{# --- START Sidebar Content --- #}
<div class="hidden">
<div id="event{{ event.trip_details_id }}">
{# --- START List Coxes --- #}
{% if event.planned_amount_cox > 0 %}
{% if event.max_people == 0 %}
{{ macros::box(participants=event.cox, empty_seats="", header='Absage', bg='[#f43f5e]') }}
{% else %}
{% if amount_cox_missing > 0 %}
{{ macros::box(participants=event.cox, empty_seats=event.planned_amount_cox - amount_cur_cox, header='Noch benötigte Steuerleute:', text='Keine Steuerleute angemeldet') }}
{% else %}
{{ macros::box(participants=event.cox, empty_seats="", header='Genügend Steuerleute haben sich angemeldet :-)', text='Keine Steuerleute angemeldet') }}
{% endif %}
{% endif %}
{% endif %}
{# --- END List Coxes --- #}
{# --- START List Rowers --- #}
{% set amount_cur_rower = event.rower | length %}
{% if event.max_people == 0 %}
{{ macros::box(header='Absage', bg='[#f43f5e]', participants=event.rower, trip_details_id=event.trip_details_id, allow_removing="manage_events" in loggedin_user.roles) }}
{% else %}
{{ macros::box(participants=event.rower, empty_seats=event.max_people - amount_cur_rower, bg='primary-100', color='black', trip_details_id=event.trip_details_id, allow_removing="manage_events" in loggedin_user.roles) }}
{% endif %}
{# --- END List Rowers --- #}
{% if "manage_events" in loggedin_user.roles %}
<form action="/planned/join/{{ event.trip_details_id }}" method="get" />
{{ macros::input(label='Gast', class="input rounded-t", name='user_note', type='text', required=true) }}
<input value="Gast hinzufügen"
class="btn btn-primary w-full rounded-t-none-important"
type="submit" />
</form>
{% endif %}
{% if event.allow_guests %}
<div class="text-primary-900 bg-primary-50 text-center p-1 mb-4">Gäste willkommen!</div>
{% endif %}
{% if "manage_events" in loggedin_user.roles %}
{# --- START Edit Form --- #}
<div class="bg-gray-100 dark:bg-primary-900 p-3 mt-4 rounded-md">
<h3 class="text-primary-950 dark:text-white font-bold uppercase tracking-wide mb-2">Ausfahrt bearbeiten</h3>
<form action="/admin/planned-event" method="post" class="grid gap-3">
<input type="hidden" name="_method" value="put" />
<input type="hidden" name="id" value="{{ event.id }}" />
{{ macros::input(label='Titel', name='name', type='input', value=event.name) }}
{{ macros::input(label='Anzahl Ruderer', name='max_people', type='number', required=true, value=event.max_people, min='1') }}
{{ macros::input(label='Anzahl Steuerleute', name='planned_amount_cox', type='number', value=event.planned_amount_cox, required=true, min='0') }}
{{ macros::checkbox(label='Immer anzeigen', name='always_show', id=event.id,checked=event.always_show) }}
{{ macros::checkbox(label='Gesperrt', name='is_locked', id=event.id,checked=event.is_locked) }}
{{ macros::select(label='Typ', name='trip_type', data=trip_types, default='Reguläre Ausfahrt', selected_id=event.trip_type_id) }}
{{ macros::input(label='Anmerkungen', name='notes', type='input', value=event.notes) }}
<input value="Speichern" class="btn btn-primary" type="submit" />
</form>
</div>
{# --- END Edit Form --- #}
{# --- START Delete Btn --- #}
{% if event.rower | length == 0 and amount_cur_cox == 0 %}
<div class="text-right mt-6">
<a href="/admin/planned-event/{{ event.id }}/delete"
class="inline-block btn btn-alert">
{% include "includes/delete-icon" %}
Termin löschen
</a>
</div>
{% else %}
{% if event.max_people == 0 %}
Wenn du deine Absage absagen (:^)) willst, einfach entsprechende Anzahl an Ruderer oben eintragen.
{% else %}
<div class="bg-gray-100 dark:bg-primary-900 p-3 mt-4 rounded-md">
<h3 class="text-primary-950 dark:text-white font-bold uppercase tracking-wide mb-2">Event absagen</h3>
<form action="/admin/planned-event" method="post" class="grid">
<input type="hidden" name="_method" value="put" />
<input type="hidden" name="id" value="{{ event.id }}" />
{{ macros::input(label='Grund der Absage', name='notes', type='input', value='') }}
{{ macros::input(label='', name='max_people', type='hidden', value=0) }}
{{ macros::input(label='', name='name', type='hidden', value=event.name) }}
{{ macros::input(label='', name='max_people', type='hidden', value=event.max_people) }}
{{ macros::input(label='', name='planned_amount_cox', type='hidden', value=event.planned_amount_cox) }}
{{ macros::input(label='', name='always_show', type='hidden', value=event.always_show) }}
{{ macros::input(label='', name='is_locked', type='hidden', value=event.is_locked) }}
{{ macros::input(label='', name='trip_type', type='hidden', value=event.trip_type_id) }}
<input value="Ausfahrt absagen" class="btn btn-alert" type="submit" />
</form>
</div>
{% endif %}
{% endif %}
{% endif %}
{# --- END Delete Btn --- #}
</div>
</div>
{# --- END Sidebar Content --- #}
</div>
{% endfor %}
{% endif %}
{# --- END Events --- #}
{# --- START Trips --- #}
{% if day.trips | length > 0 %}
{% for trip in day.trips | sort(attribute="planned_starting_time") %}
<div class="pt-2 px-3 reset-js border-t border-gray-200"
style="order: {{ trip.planned_starting_time | replace(from=":", to="") }}"
data-coxneeded="false">
<div class="flex justify-between items-center">
<div class="mr-1">
{% if trip.always_show and not day.regular_sees_this_day %}
<span title="Du siehst diese Ausfahrt schon, obwohl sie mehr als {{ amount_days_to_show_trips_ahead }} Tage in der Zukunft liegt. Du Magier!">🔮</span>
{% endif -%}
{% if trip.max_people == 0 %}
<strong class="text-[#f43f5e]">&#9888;
{{ trip.planned_starting_time }}
Uhr</strong>
<small class="text-[#f43f5e]">(Absage
{{ trip.cox_name -}}
{% if trip.trip_type %}
-
{{ trip.trip_type.icon | safe }}&nbsp;{{ trip.trip_type.name }}
{%- endif -%}
)</small>
{% else %}
<strong class="text-primary-900 dark:text-white">{{ trip.planned_starting_time }}
Uhr</strong>
<small class="text-gray-600 dark:text-gray-100">({{ trip.cox_name -}}
{% if trip.trip_type %}
- {{ trip.trip_type.icon | safe }}&nbsp;{{ trip.trip_type.name }}
{%- endif -%}
)</small>
{% endif %}
<br />
<a href="#" data-sidebar="true" data-trigger="sidebar" data-header="<strong>
{% if trip.max_people == 0 %}&#9888;{% endif %}
{{ trip.planned_starting_time }} Uhr</strong> ({{ trip.cox_name }})
{% if trip.trip_type %}<small class='block'>{{ trip.trip_type.desc }}</small>{% endif %}
{% if trip.notes %}<small class='block'>{{ trip.notes }}</small>{% endif %}
" data-body="#trip{{ trip.trip_details_id }}" class="inline-block link-primary mr-3">
Details
</a>
</div>
<div>
{% set_global cur_user_participates = false %}
{% for rower in trip.rower %}
{% if rower.name == loggedin_user.name %}
{% set_global cur_user_participates = true %}
{% endif %}
{% endfor %}
{% if cur_user_participates %}
<a href="/planned/remove/{{ trip.trip_details_id }}"
class="btn btn-attention btn-fw">Abmelden</a>
{% endif %}
{% if trip.max_people > trip.rower | length and trip.cox_id != loggedin_user.id and cur_user_participates == false %}
<a href="/planned/join/{{ trip.trip_details_id }}"
class="btn btn-primary btn-fw"
{% if trip.trip_type %}onclick="return confirm('{{ trip.trip_type.question }}');"{% endif %}>Mitrudern</a>
{% endif %}
</div>
</div>
{# --- START Sidebar Content --- #}
<div class="hidden">
<div id="trip{{ trip.trip_details_id }}">
{% if trip.max_people == 0 %}
{# --- border-[#f43f5e] bg-[#f43f5e] --- #}
{{ macros::box(participants=trip.rower,bg='[#f43f5e]',header='Absage', trip_details_id=trip.trip_details_id, allow_removing=loggedin_user.id == trip.cox_id) }}
{% else %}
{% set amount_cur_rower = trip.rower | length %}
{{ macros::box(participants=trip.rower, empty_seats=trip.max_people - amount_cur_rower, bg='primary-100', color='black', trip_details_id=trip.trip_details_id, allow_removing=loggedin_user.id == trip.cox_id) }}
{% if trip.cox_id == loggedin_user.id %}
<form action="/planned/join/{{ trip.trip_details_id }}" method="get" />
{{ macros::input(label='Gast', class="input rounded-t", name='user_note', type='text', required=true) }}
<input value="Gast hinzufügen"
class="btn btn-primary w-full rounded-t-none-important"
type="submit" />
</form>
{% endif %}
{% endif %}
{# --- START Edit Form --- #}
{% if trip.cox_id == loggedin_user.id %}
<div class="bg-gray-100 dark:bg-primary-900 p-3 mt-4 rounded-md">
<h3 class="text-primary-950 dark:text-white font-bold uppercase tracking-wide mb-2">Ausfahrt bearbeiten</h3>
<form action="/cox/trip/{{ trip.id }}" method="post" class="grid gap-3">
{{ macros::input(label='Anzahl Ruderer', name='max_people', type='number', required=true, value=trip.max_people, min=trip.rower | length) }}
{{ macros::input(label='Anmerkungen', name='notes', type='input', value=trip.notes) }}
{{ macros::checkbox(label='Gesperrt', name='is_locked', id=trip.id,checked=trip.is_locked) }}
{% if loggedin_user.allowed_to_steer %}
{{ macros::select(label='Typ', name='trip_type', data=trip_types, default='Reguläre Ausfahrt', selected_id=trip.trip_type_id, only_ergo=not loggedin_user.allowed_to_steer) }}
{% else %}
{{ macros::select(label='Typ', name='trip_type', data=trip_types, selected_id=trip.trip_type_id, only_ergo=not loggedin_user.allowed_to_steer, only_ergos=true) }}
{% endif %}
<input value="Speichern" class="btn btn-primary" type="submit" />
</form>
</div>
{% if trip.rower | length == 0 %}
<div class="text-right mt-6">
<a href="/cox/remove/trip/{{ trip.id }}"
class="inline-block btn btn-alert">
{% include "includes/delete-icon" %}
Termin löschen
</a>
</div>
{% else %}
{% if trip.max_people == 0 %}
Wenn du deine Absage absagen (:^)) willst, einfach entsprechende Anzahl an Ruderer oben eintragen.
{% else %}
<div class="bg-gray-100 dark:bg-primary-900 p-3 mt-4 rounded-md">
<h3 class="text-primary-950 dark:text-white font-bold uppercase tracking-wide mb-2">Ausfahrt absagen</h3>
<form action="/cox/trip/{{ trip.id }}" method="post" class="grid">
{{ macros::input(label='', name='max_people', type='hidden', value=0) }}
{{ macros::input(label='Grund der Absage', name='notes', type='input', value='') }}
{{ macros::input(label='', name='is_locked', type='hidden', value=trip.is_locked) }}
{{ macros::input(label='', name='trip_type', type='hidden', value=trip.trip_type_id) }}
<input value="Ausfahrt absagen" class="btn btn-alert" type="submit" />
</form>
</div>
{% endif %}
{% endif %}
{% endif %}
{# --- END Edit Form --- #}
{# --- START Admin Form --- #}
{% if allowed_to_update_always_show_trip %}
<div class="bg-gray-100 dark:bg-primary-900 p-3 mt-4 rounded-md">
<h3 class="text-primary-950 dark:text-white font-bold uppercase tracking-wide mb-2">Admin-Modus</h3>
{% if not day.regular_sees_this_day %}
<form action="/cox/trip/{{ trip.id }}/toggle-always-show"
method="get"
class="grid gap-3">
{% if not trip.always_show %}
<small>Diese Ausfahrt sehen aktuell nur Steuerleute (und Admins). {{ amount_days_to_show_trips_ahead }} Tage vorher sehen sie dann alle.</small>
{% else %}
<small>Diese Ausfahrt sehen alle Mitglieder.</small>
{% endif %}
<input value="{% if trip.always_show %}Ausfahrt nur Steuerleute (und Admins) anzeigen{% else %}Ausfahrt allen anzeigen{% endif %}"
class="btn btn-primary"
type="submit" />
</form>
{% endif %}
<a href="/cox/remove/trip/{{ trip.id }}"
class="inline-block btn btn-alert mt-5 w-full">
{% include "includes/delete-icon" %}
Termin löschen
</a>
</div>
{% endif %}
{# --- END Admin Form --- #}
</div>
</div>
{# --- END Sidebar Content --- #}
</div>
{% endfor %}
{% endif %}
{# --- END Trips --- #}
</div>
{% endif %}
</div>
{# --- START Add Buttons --- #}
{% if "manage_events" in loggedin_user.roles or loggedin_user.allowed_to_steer or "ergo" in loggedin_user.roles %}
<div class="grid {% if "manage_events" in loggedin_user.roles and (loggedin_user.allowed_to_steer or "ergo" in loggedin_user.roles) %}grid-cols-2{% endif %} text-center">
{% if "manage_events" in loggedin_user.roles %}
<a href="#" data-sidebar="true" data-trigger="sidebar" data-header="<strong>Event</strong> am {{ day.day| date(format='%d.%m.%Y') }} erstellen" data-day="{{ day.day }}" data-body="#addEventForm" class="relative inline-block w-full bg-primary-900 hover:bg-primary-950 focus:bg-primary-950 dark:bg-primary-950 text-white py-2 text-sm font-semibold
{% if loggedin_user.allowed_to_steer or "ergo" in loggedin_user.roles %}
rounded-bl-md
{% else %}
rounded-b-md
{% endif %}
">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">{% include "includes/plus-icon" %}</span>
Event
</a>
{% endif %}
{% if loggedin_user.allowed_to_steer or "ergo" in loggedin_user.roles %}
<a href="#" data-sidebar="true" data-trigger="sidebar" data-header="<strong>Ausfahrt</strong> am {{ day.day| date(format='%d.%m.%Y') }} erstellen" data-day="{{ day.day }}" data-body="#sidebarForm" class="relative inline-block w-full py-2 text-primary-900 hover:text-primary-950 dark:bg-primary-600 dark:text-white dark:hover:bg-primary-500 dark:hover:text-white focus:text-primary-950 text-sm font-semibold bg-gray-100 hover:bg-gray-200 focus:bg-gray-200
{% if "manage_events" in loggedin_user.roles %}
rounded-br-md
{% else %}
rounded-b-md
{% endif %}
">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">{% include "includes/plus-icon" %}</span>
{% if not loggedin_user.allowed_to_steer %}Ergo-Session
{%- else -%}
Ausfahrt{%endif%}
</a>
{% endif %}
</div>
{% endif %}
{# --- END Add Buttons --- #}
</div>
{% endfor %}
</div>
</div>
{% if loggedin_user.allowed_to_steer or "ergo" in loggedin_user.roles %}
{% include "forms/trip" %}
{% endif %}
{% if "manage_events" in loggedin_user.roles %}
{% include "forms/event" %}
{% endif %}
{% endblock content %}

@ -0,0 +1,103 @@
{% import "includes/macros" as macros %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">
Statistik
<select id="yearSelect"
onchange="changeYear()"
style="background: transparent;
background-image: none;
text-decoration: underline"></select>
</h1>
<div class="search-wrapper">
<label for="name" class="sr-only">Suche</label>
<input type="search"
name="name"
id="filter-js"
class="search-bar"
placeholder="Suchen nach Namen..." />
</div>
<div id="filter-result-js" class="search-result"></div>
<div class="border-r border-l border-gray-200 dark:border-primary-600">
<div class="border-t border-gray-200 dark:border-primary-600 bg-white dark:bg-primary-900 text-black dark:text-white flex justify-between items-center px-3 py-1"
data-filterable="false"
data-filter="Header">
<span class="text-sm text-gray-600 dark:text-gray-100 w-10"><b>#</b></span>
<span class="grow"><b>Name</b></span>
<span class="pl-3 w-20 text-right"><b>km</b></span>
<span class="pl-3 w-20 text-right"><b>Fahrten</b></span>
</div>
{% set_global km = 0 %} {% set_global km = 0 %} {% set_global index = 1 %}
{% for s in stat %}
<div class="border-t border-gray-200 dark:border-primary-600 bg-white dark:bg-primary-900 text-black dark:text-white flex justify-between items-center px-3 py-1"
data-filterable="true"
data-filter="{{ s.name }}">
<span class="text-sm text-gray-600 dark:text-gray-100 w-10">
{% if km != s.rowed_km %}
{{ loop.index }}
{% set_global index = loop.index %}
{% else %}
{{ index }}
{% endif %}
</span>
<span class="grow">{{ s.name }}</span>
<span class="pl-3 w-20 text-right">{{ s.rowed_km }}</span>
<span class="pl-3 w-20 text-right">{{ s.amount_trips }}</span>
{% set_global km = s.rowed_km %}
</div>
{% endfor %}
<div class="border-t border-black dark:border-white bg-white dark:bg-primary-900 text-black dark:text-white flex justify-between items-center px-3 py-1"
data-filterable="false"
data-filter="Summe Vereinsmitglieder">
<span class="text-sm text-gray-600 dark:text-gray-100 w-10"></span>
<span class="grow"><b>Summe Vereinsmitglieder</b></span>
<span class="pl-3 w-20 text-right"><b>{{ club_km }}</b></span>
<span class="pl-3 w-20 text-right"><b>{{ club_trips }}</b></span>
</div>
<div class="border-t border-gray-200 dark:border-primary-600 bg-white dark:bg-primary-900 text-black dark:text-white flex justify-between items-center px-3 py-1"
data-filterable="false"
data-filter="Summe {{ guest_km.name }}">
<span class="text-sm text-gray-600 dark:text-gray-100 w-10"></span>
<span class="grow"><b>Summe {{ guest_km.name }}</b></span>
<span class="pl-3 w-20 text-right"><b>{{ guest_km.rowed_km }}</b></span>
<span class="pl-3 w-20 text-right"><b>{{ guest_km.amount_trips }}</b></span>
</div>
<div class="border-t border-gray-200 dark:border-primary-600 border-b bg-white dark:bg-primary-900 text-black dark:text-white flex justify-between items-center px-3 py-1"
data-filterable="false"
data-filter="Gesamtsumme">
<span class="text-sm text-gray-600 dark:text-gray-100 w-10"></span>
<span class="grow"><b>Gesamtsumme</b></span>
<span class="pl-3 w-20 text-right"><b>{{ club_km + guest_km.rowed_km }}</b></span>
<span class="pl-3 w-20 text-right"><b>{{ guest_km.amount_trips + club_trips }}</b></span>
</div>
</div>
</div>
<script>
function getYearFromURL() {
var queryParams = new URLSearchParams(window.location.search);
return queryParams.get('year');
}
function populateYears() {
var select = document.getElementById('yearSelect');
var currentYear = new Date().getFullYear();
var selectedYear = getYearFromURL() || currentYear;
for (var year = 1977; year <= currentYear; year++) {
var option = document.createElement('option');
option.value = option.textContent = year;
if (year == selectedYear) {
option.selected = true;
}
select.appendChild(option);
}
}
function changeYear() {
var selectedYear = document.getElementById('yearSelect').value;
window.location.href = '?year=' + selectedYear;
}
populateYears();
</script>
{% endblock content %}