1 Commits

Author SHA1 Message Date
Marie Birner
397092bff5 [NPM] update choices.js to 11.1.0 to set searchResultLimit -1
Some checks failed
CI/CD Pipeline / test (push) Failing after 4m58s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
2025-07-20 11:46:59 +02:00
26 changed files with 371 additions and 1299 deletions

View File

@@ -17,9 +17,6 @@ jobs:
- name: Run Test DB Script
run: ./test_db.sh
- name: Test
run: npm --version
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
@@ -28,15 +25,15 @@ jobs:
cargo build
cd frontend && npm install && npm run build
- name: Frontend tests
run: cd frontend && npx playwright install && npx playwright test --workers 1 --reporter html,line
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 30
run: cd frontend && npx playwright install && npx playwright test --workers 1 --reporter line
- name: Backend tests
run: cargo test --verbose
#- uses: actions/upload-artifact@v3
# if: always()
# with:
# name: playwright-report
# path: frontend/playwright-report/
# retention-days: 30
deploy-staging:
runs-on: ubuntu-latest

View File

@@ -1,15 +0,0 @@
#!/bin/bash
cargo b -r --target x86_64-unknown-linux-musl
strip target/x86_64-unknown-linux-musl/release/rot
cd frontend && npm install && npm run build
cd ..
scp -C target/x86_64-unknown-linux-musl/release/rot row-server:/root/rowing-prod/rot-updating
scp -C -r static row-server:/root/rowing-prod/
scp -C -r templates row-server:/root/rowing-prod/
scp -C -r svelte row-server:/root/rowing-prod/
ssh row-server 'mkdir -p /root/rowing-prod/svelte/build && mkdir -p /root/rowing-prod/data-ergo/thirty && mkdir -p /root/rowing-prod/data-ergo/dozen'
ssh row-server 'sudo systemctl stop rowing-prod'
ssh row-server 'mv /root/rowing-prod/rot-updating /root/rowing-prod/rot'
ssh row-server 'sudo systemctl start rowing-prod'

View File

@@ -413,7 +413,7 @@ function initNewChoice(select: HTMLInputElement) {
steering_person.setAttribute("required", "required");
}
const choice = new Choices(select, {
searchResultLimit: 100,
searchResultLimit: -1,
searchFields: ["label", "value", "customProperties.searchableText"],
removeItemButton: true,
loadingText: "Wird geladen...",
@@ -426,6 +426,7 @@ function initNewChoice(select: HTMLInputElement) {
return `Nur ${maxItemCount} Ruderer können hinzugefügt werden`;
},
callbackOnInit: function () {
console.log(this);
this._currentState.items.forEach(function (obj) {
if (boat_in_ottensheim && obj.customProperties) {
if (obj.customProperties.is_racing) {

View File

@@ -16,12 +16,12 @@
"postcss": "^8.4.21",
"sass": "^1.60.0",
"tailwindcss": "^3.3.1",
"typescript": "^5.9.3",
"typescript": "^4.9.5",
"vite": "^4.2.0",
"vite-plugin-static-copy": "^0.13.1"
},
"dependencies": {
"choices.js": "^10.2.0",
"choices.js": "^11.1.0",
"d3": "^7.8.5",
"terser": "^5.21.0"
}

View File

@@ -1,9 +1,4 @@
import { test, expect, Page } from "@playwright/test";
import { resetDatabase, login } from "./helpers";
test.beforeEach(async () => {
await resetDatabase();
});
import { test, expect } from "@playwright/test";
test("cox can create and delete trip", async ({ page }) => {
await page.goto("/auth");
@@ -21,13 +16,22 @@ test("cox can create and delete trip", async ({ page }) => {
await page.getByRole("spinbutton").fill("5");
await page.getByRole("button", { name: "Erstellen", exact: true }).click();
await expect(page.locator("body")).toContainText("18:00 Uhr (cox) Details");
await page.goto("/planned");
await page.getByRole('link', { name: 'Details' }).nth(1).click();
await page.getByRole("link", { name: "Termin löschen" }).click();
await expect(page.locator("body")).toContainText("Erfolgreich gelöscht!");
});
// TODO: group -> cox can create trips
// TODO: cox can help/register at trips/events
test.describe("cox can edit trips", () => {
async function createTrip(page: Page) {
let sharedPage: Page;
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await page.goto("/auth");
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("cox");
@@ -42,101 +46,151 @@ test.describe("cox can edit trips", () => {
await page.locator("#sidebar #planned_starting_time").press("Tab");
await page.getByRole("spinbutton").fill("5");
await page.getByRole("button", { name: "Erstellen", exact: true }).click();
}
test("edit remarks", async ({ page }) => {
await createTrip(page);
await page.goto("/planned");
await page.getByRole('link', { name: 'Details' }).nth(1).click();
await page.locator("#sidebar #notes").click();
await page.locator("#sidebar #notes").fill("Meine Anmerkung");
await page.getByRole("button", { name: "Speichern" }).click();
await page.getByRole("link", { name: "Details" }).nth(1).click();
await expect(page.locator("#sidebar")).toContainText(
"Meine Anmerkung",
);
sharedPage = page;
});
test("add and remove guest", async ({ page }) => {
await createTrip(page);
test("edit remarks", async () => {
await sharedPage.goto("/planned");
await sharedPage.getByRole('link', { name: 'Details' }).nth(1).click();
await sharedPage.locator("#sidebar #notes").click();
await sharedPage.locator("#sidebar #notes").fill("Meine Anmerkung");
await sharedPage.getByRole("button", { name: "Speichern" }).click();
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
"Meine Anmerkung",
);
await page.goto("/planned");
await page.getByRole("link", { name: "Details" }).nth(1).click();
await page.locator("#sidebar #user_note").click();
await page.locator("#sidebar #user_note").fill("Mein Gast");
await page.getByRole("button", { name: "Gast hinzufügen" }).click();
await expect(page.locator("body")).toContainText(
await sharedPage
.getByRole("button", { name: "Ausfahrt erstellen schließen" })
.click();
});
test("add and remove guest", async () => {
await sharedPage.goto("/planned");
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await sharedPage.locator("#sidebar #user_note").click();
await sharedPage.locator("#sidebar #user_note").fill("Mein Gast");
await sharedPage.getByRole("button", { name: "Gast hinzufügen" }).click();
await expect(sharedPage.locator("body")).toContainText(
"Erfolgreich angemeldet!",
);
await page.getByRole("link", { name: "Details" }).nth(1).click();
await expect(page.locator("#sidebar")).toContainText(
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
"Freie Plätze: 4",
);
await expect(page.locator("#sidebar")).toContainText(
await expect(sharedPage.locator("#sidebar")).toContainText(
"Mein Gast (Gast) Abmelden",
);
await expect(
page.getByRole("link", { name: "Termin löschen" }),
sharedPage.getByRole("link", { name: "Termin löschen" }),
).not.toBeVisible();
await page.getByRole("link", { name: "Abmelden" }).click();
await expect(page.locator("body")).toContainText(
await sharedPage.getByRole("link", { name: "Abmelden" }).click();
await expect(sharedPage.locator("body")).toContainText(
"Erfolgreich abgemeldet!",
);
await page.getByRole("link", { name: "Details" }).nth(1).click();
await expect(page.locator("#sidebar")).toContainText(
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
"Freie Plätze: 5",
);
await expect(page.locator("#sidebar")).toContainText(
await expect(sharedPage.locator("#sidebar")).toContainText(
"Keine Ruderer angemeldet",
);
await expect(
page.getByRole("link", { name: "Termin löschen" }),
sharedPage.getByRole("link", { name: "Termin löschen" }),
).toBeVisible();
await sharedPage
.getByRole("button", { name: "Ausfahrt erstellen schließen" })
.click();
});
test("change amount rower", async ({ page }) => {
await createTrip(page);
await page.goto("/planned");
await page.getByRole("link", { name: "Details" }).nth(1).click();
await expect(page.locator("#sidebar")).toContainText(
test("change amount rower", async () => {
await sharedPage.goto("/planned");
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
"Freie Plätze: 5",
);
await page.getByRole("spinbutton").click();
await page.getByRole("spinbutton").fill("3");
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page.locator("body")).toContainText(
await sharedPage.getByRole("spinbutton").click();
await sharedPage.getByRole("spinbutton").fill("3");
await sharedPage.getByRole("button", { name: "Speichern" }).click();
await expect(sharedPage.locator("body")).toContainText(
"Ausfahrt erfolgreich aktualisiert.",
);
});
test("call off trip", async ({ page }) => {
await createTrip(page);
test("call off trip", async () => {
// Someone registers...
await page.goto("/auth/logout");
await page.waitForURL("/auth");
await login(page, "rower", "rower");
await sharedPage.goto("/auth/logout");
await sharedPage.goto("/auth");
await sharedPage.getByPlaceholder("Name").click();
await sharedPage.getByPlaceholder("Name").fill("rower");
await sharedPage.getByPlaceholder("Name").press("Tab");
await sharedPage.getByPlaceholder("Passwort").fill("rower");
await sharedPage.getByPlaceholder("Passwort").press("Enter");
await page.goto("/planned");
await page.getByRole('link', { name: 'Mitrudern' }).nth(1).click();
await sharedPage.goto("/planned");
await sharedPage.getByRole('link', { name: 'Mitrudern' }).nth(1).click();
// Login as cox again
await page.goto("/auth/logout");
await page.waitForURL("/auth");
await login(page, "cox", "cox");
await sharedPage.goto("/auth/logout");
await sharedPage.goto("/auth");
await sharedPage.getByPlaceholder("Name").click();
await sharedPage.getByPlaceholder("Name").fill("cox");
await sharedPage.getByPlaceholder("Name").press("Tab");
await sharedPage.getByPlaceholder("Passwort").fill("cox");
await sharedPage.getByPlaceholder("Passwort").press("Enter");
await page.goto("/planned");
await sharedPage.goto("/planned");
// Now cancel the trip
await page.getByRole("link", { name: "Details" }).nth(1).click();
await page.getByRole("button", { name: "Ausfahrt absagen" }).click();
await expect(page.locator("body")).toContainText(
// ... now I can cancel trip
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await sharedPage.getByRole("button", { name: "Ausfahrt absagen" }).click();
await expect(sharedPage.locator("body")).toContainText(
"Ausfahrt erfolgreich aktualisiert.",
);
await expect(page.locator("body")).toContainText("(Absage cox)");
await expect(sharedPage.locator("body")).toContainText("(Absage cox)");
// Done with the test -> cancel the cancellation of the trip, otherwise the afterAll function below fails
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await sharedPage.getByRole("spinbutton").click();
await sharedPage.getByRole("spinbutton").fill("3");
await sharedPage.getByRole("button", { name: "Speichern" }).click();
// deregistering
await sharedPage.goto("/auth/logout");
await sharedPage.goto("/auth");
await sharedPage.getByPlaceholder("Name").click();
await sharedPage.getByPlaceholder("Name").fill("rower");
await sharedPage.getByPlaceholder("Name").press("Tab");
await sharedPage.getByPlaceholder("Passwort").fill("rower");
await sharedPage.getByPlaceholder("Passwort").press("Enter");
await sharedPage.goto("/planned");
await sharedPage.getByRole('link', { name: 'Abmelden' }).click();
// now cox can delete trip again in afterAll
await sharedPage.goto("/auth/logout");
await sharedPage.goto("/auth");
await sharedPage.getByPlaceholder("Name").click();
await sharedPage.getByPlaceholder("Name").fill("cox");
await sharedPage.getByPlaceholder("Name").press("Tab");
await sharedPage.getByPlaceholder("Passwort").fill("cox");
await sharedPage.getByPlaceholder("Passwort").press("Enter");
});
test.afterAll(async () => {
await sharedPage.goto("/planned");
await sharedPage.getByRole('link', { name: 'Details' }).nth(1).click();
await sharedPage.getByRole("link", { name: "Termin löschen" }).click();
await sharedPage.close();
});
// TODO: 'Immer anzeigen' (also verify the functionality), 'Gesperrt' + type

View File

@@ -1,29 +0,0 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { Page } from '@playwright/test';
const execAsync = promisify(exec);
export async function resetDatabase(): Promise<void> {
await execAsync('cd .. && ./reset_test_data.sh');
}
export async function login(page: Page, username: string, password: string): Promise<void> {
// Clear cookies to ensure clean state
await page.context().clearCookies();
// Navigate to auth page and wait for it to fully load
await page.goto("/auth", { waitUntil: 'load' });
await page.waitForLoadState('networkidle');
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill(username);
await page.getByPlaceholder("Passwort").click();
await page.getByPlaceholder("Passwort").fill(password);
// Wait for navigation after form submission
await Promise.all([
page.waitForURL(/\/(planned|log|$)/, { timeout: 10000 }),
page.getByPlaceholder("Passwort").press("Enter")
]);
}

View File

@@ -1,9 +1,4 @@
import { test, expect } from "@playwright/test";
import { resetDatabase } from "./helpers";
test.beforeEach(async () => {
await resetDatabase();
});
test("Cox can start and cancel trip", async ({ page }, testInfo) => {
await page.goto("/auth");
@@ -39,6 +34,12 @@ test("Cox can start and cancel trip", async ({ page }, testInfo) => {
"Ausfahrt erfolgreich hinzugefügt",
);
await expect(page.locator("body")).toContainText("Joe");
await page.getByRole("link", { name: "Joe" }).click();
page.once("dialog", (dialog) => {
dialog.accept().catch(() => {});
});
await page.getByRole("link", { name: "Löschen" }).click();
});
test("Cox can start and finish trip", async ({ page }, testInfo) => {
@@ -101,6 +102,28 @@ test("Cox can start and finish trip", async ({ page }, testInfo) => {
await expect(page.locator('body')).toContainText('(cox2)');
await expect(page.locator('body')).toContainText('Ottensheim (25 km)');
await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2');
//Ausloggen...
await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click();
await page.getByRole('link', { name: 'Ausloggen' }).click();
// Login as admin
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("main");
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("admin");
await page.getByPlaceholder("Passwort").press("Enter");
await page.goto("/log/show");
await page.getByRole('link', { name: 'Joe' }).nth(1).click();
page.once("dialog", (dialog) => {
dialog.accept().catch(() => {});
});
await page.getByRole('link', { name: 'Löschen' }).click();
//Ausloggen...
await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click();
await page.getByRole('link', { name: 'Ausloggen' }).click();
});
test("Kiosk can start and cancel trip", async ({ page }, testInfo) => {
@@ -128,6 +151,12 @@ test("Kiosk can start and cancel trip", async ({ page }, testInfo) => {
"Ausfahrt erfolgreich hinzugefügt",
);
await expect(page.locator("body")).toContainText("Joe");
await page.getByRole("link", { name: "Joe" }).click();
page.once("dialog", (dialog) => {
dialog.accept().catch(() => {});
});
await page.getByRole("link", { name: "Löschen" }).click();
});
test("Kiosk can start and finish trip", async ({ page }, testInfo) => {
@@ -181,6 +210,29 @@ test("Kiosk can start and finish trip", async ({ page }, testInfo) => {
await expect(page.locator('body')).toContainText('Joe');
await expect(page.locator('body')).toContainText('Ottensheim (25 km)');
await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2');
//Ausloggen...
await page.context().clearCookies();
await page.goto("/auth");
// Login as admin
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("main");
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("admin");
await page.getByPlaceholder("Passwort").press("Enter");
await page.goto("/log/show");
await page.getByRole('link', { name: 'Joe' }).nth(1).click();
page.once("dialog", (dialog) => {
dialog.accept().catch(() => {});
});
await page.getByRole('link', { name: 'Löschen' }).click();
//Ausloggen...
await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click();
await page.getByRole('link', { name: 'Ausloggen' }).click();
});
test("Cox can start and finish trip with cox steering only", async ({ page }, testInfo) => {
@@ -234,6 +286,29 @@ test("Cox can start and finish trip with cox steering only", async ({ page }, te
await page.goto('/log/show');
await expect(page.locator('body')).toContainText('cox_only_steering_boat');
await expect(page.locator('body')).toContainText('Ottensheim (25 km)');
//Ausloggen...
await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click();
await page.getByRole('link', { name: 'Ausloggen' }).click();
// Login as admin
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("main");
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("admin");
await page.getByPlaceholder("Passwort").press("Enter");
await page.goto("/log/show");
await page.getByRole("link", { name: "cox_only_steering_boat" }).click();
page.once("dialog", (dialog) => {
dialog.accept().catch(() => {});
});
await page.getByRole('link', { name: 'Löschen' }).click();
//Ausloggen...
await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click();
await page.getByRole('link', { name: 'Ausloggen' }).click();
});
test("Kiosk can start and finish trip in one stop", async ({ page }, testInfo) => {
@@ -280,4 +355,27 @@ test("Kiosk can start and finish trip in one stop", async ({ page }, testInfo) =
await expect(page.locator('body')).toContainText('(cox2)');
await expect(page.locator('body')).toContainText('a (1 km)');
await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2');
//Ausloggen...
await page.context().clearCookies();
await page.goto("/auth");
// Login as admin
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("main");
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("admin");
await page.getByPlaceholder("Passwort").press("Enter");
await page.goto("/log/show");
await page.getByRole('link', { name: 'Joe' }).nth(1).click();
page.once("dialog", (dialog) => {
dialog.accept().catch(() => {});
});
await page.getByRole('link', { name: 'Löschen' }).click();
//Ausloggen...
await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click();
await page.getByRole('link', { name: 'Ausloggen' }).click();
});

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "rowt",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1,19 +0,0 @@
#!/usr/bin/env bash
set -e
DB_FILE="db.sqlite"
# Clear all data and reseed
sqlite3 "$DB_FILE" << 'EOF'
PRAGMA writable_schema = 1;
DELETE FROM sqlite_sequence;
PRAGMA writable_schema = 0;
PRAGMA foreign_keys = OFF;
EOF
# Get all tables and delete from them
sqlite3 "$DB_FILE" "SELECT 'DELETE FROM ' || name || ';' FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';" | sqlite3 "$DB_FILE"
# Re-enable foreign keys and reseed
sqlite3 "$DB_FILE" "PRAGMA foreign_keys = ON;"
sqlite3 "$DB_FILE" < seeds.sql

View File

@@ -207,7 +207,7 @@ dein Vereinsbeitrag für das aktuelle Jahr beträgt {}€",
fees.name
))
}
content.push_str("\nBitte überweise diesen auf folgendes Konto: IBAN: AT58 2032 0321 0072 9256 (Name: ASKÖ Ruderverein Donau Linz). 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\
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 kassier@rudernlinz.at. @Studenten: Bitte die aktuelle Studienbestätigung an kassier@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\
@@ -333,7 +333,7 @@ Dein Vereinsbeitrag für das aktuelle Jahr beträgt {}€",
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 (Name: ASKÖ Ruderverein Donau Linz; 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.)
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");

View File

@@ -26,22 +26,6 @@ impl Notification {
.await
.ok()
}
pub async fn oldest_unread_with_action(db: &SqlitePool, user_id: i64) -> Option<Self> {
sqlx::query_as!(
Self,
"SELECT id, user_id, message, read_at, created_at, category, link, action_after_reading
FROM notification
WHERE user_id = ? AND read_at IS NULL AND action_after_reading IS NOT NULL
ORDER BY created_at ASC
LIMIT 1",
user_id
)
.fetch_optional(db)
.await
.unwrap()
}
pub async fn create_with_tx(
db: &mut Transaction<'_, Sqlite>,
user: &User,

View File

@@ -104,11 +104,9 @@ pub struct Stat {
impl Stat {
pub async fn guest(db: &SqlitePool, year: Option<i32>) -> Stat {
let year = year.unwrap_or_else(|| chrono::Local::now().year());
let year_filter = if year == 0 {
String::new()
} else {
format!("AND l.arrival LIKE '{}-%'", year)
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
@@ -123,7 +121,7 @@ LEFT JOIN (
FROM rower
GROUP BY logbook_id
) m ON l.id = m.logbook_id
WHERE l.distance_in_km IS NOT NULL {year_filter} AND not b.external;
WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%' AND not b.external;
"
))
.fetch_one(db)
@@ -133,16 +131,21 @@ WHERE l.distance_in_km IS NOT NULL {year_filter} AND not b.external;
let guest_km: i32 = guests.get(0);
let guest_amount_trips: i32 = guests.get(1);
// e.g. scheckbücher (users without any role)
// 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 user_id FROM user_role)
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
{year_filter}
AND l.arrival LIKE '{year}-%'
AND u.name != 'Externe Steuerperson';
"
))
@@ -180,20 +183,25 @@ AND u.name != 'Externe Steuerperson';
}
pub async fn people(db: &SqlitePool, year: Option<i32>) -> Vec<Stat> {
let year = year.unwrap_or_else(|| chrono::Local::now().year());
let year_filter = if year == 0 {
String::new()
} else {
format!("AND l.arrival LIKE '{}-%'", year)
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 user u
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 {year_filter} AND u.name != 'Externe Steuerperson'
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;
"

View File

@@ -8,7 +8,7 @@ use crate::model::{
notification::Notification,
role::Role,
};
use chrono::{Datelike, Local, NaiveDate};
use chrono::NaiveDate;
use rocket::{fs::TempFile, tokio::io::AsyncReadExt};
use sqlx::SqlitePool;
@@ -578,32 +578,4 @@ impl User {
Ok(())
}
pub(crate) async fn has_to_pay_einschreibgebuehr_this_year(&self, db: &SqlitePool) -> bool {
if !self.has_role(db, "schnupperant").await {
if let Some(member_since_date) = &self.member_since_date {
if let Ok(member_since_date) =
NaiveDate::parse_from_str(member_since_date, "%Y-%m-%d")
{
if member_since_date.year() == Local::now().year()
&& !self.has_role(db, "no-einschreibgebuehr").await
{
return true;
}
}
}
}
false
}
pub(crate) fn has_to_pay_only_half(&self) -> bool {
if let Some(member_since_date) = &self.member_since_date {
if let Ok(member_since_date) = NaiveDate::parse_from_str(member_since_date, "%Y-%m-%d")
{
let halfprice_startdate =
NaiveDate::from_ymd_opt(Local::now().year(), 7, 1).unwrap();
return member_since_date >= halfprice_startdate;
}
}
false
}
}

View File

@@ -1,9 +1,10 @@
use super::User;
use crate::{
model::family::Family, BOAT_STORAGE, DUAL_MEMBERSHIP, EINSCHREIBGEBUEHR, FAMILY_THREE_OR_MORE,
FAMILY_TWO, FOERDERND, REGULAR, RENNRUDERBEITRAG, SCHECKBUCH, STUDENT_OR_PUPIL, TRIAL_ROWING,
TRIAL_ROWING_REDUCED, UNTERSTUETZEND,
BOAT_STORAGE, DUAL_MEMBERSHIP, EINSCHREIBGEBUEHR, FAMILY_THREE_OR_MORE, FAMILY_TWO, FOERDERND,
REGULAR, RENNRUDERBEITRAG, SCHECKBUCH, STUDENT_OR_PUPIL, TRIAL_ROWING, TRIAL_ROWING_REDUCED,
UNTERSTUETZEND, model::family::Family,
};
use chrono::{Datelike, Local, NaiveDate};
use serde::Serialize;
use sqlx::SqlitePool;
@@ -80,52 +81,30 @@ impl User {
let mut fee = Fee::new();
if let Some(family) = Family::find_by_opt_id(db, self.family_id).await {
let mut einschreibgebuehr = false;
let mut half_price = true;
for member in family.members(db).await {
fee.add_person(&member);
if member.has_role(db, "paid").await {
fee.paid();
}
fee.merge(member.fee_without_families(db, true).await);
if member.has_to_pay_einschreibgebuehr_this_year(db).await {
einschreibgebuehr = true;
}
if !member.has_to_pay_only_half() {
half_price = false;
}
fee.merge(member.fee_without_families(db).await);
}
if family.amount_family_members(db).await > 2 {
if half_price {
fee.add(
"Familie 3+ Personen (Halbpreis)".into(),
FAMILY_THREE_OR_MORE / 2,
);
} else {
fee.add("Familie 3+ Personen".into(), FAMILY_THREE_OR_MORE);
}
fee.add("Familie 3+ Personen".into(), FAMILY_THREE_OR_MORE);
} else {
if half_price {
fee.add("Familie 2 Personen (Halbpreis)".into(), FAMILY_TWO / 2);
} else {
fee.add("Familie 2 Personen".into(), FAMILY_TWO);
}
}
if einschreibgebuehr {
fee.add("Einschreibgebühr (Familie)".into(), EINSCHREIBGEBUEHR);
fee.add("Familie 2 Personen".into(), FAMILY_TWO);
}
} else {
fee.add_person(self);
if self.has_role(db, "paid").await {
fee.paid();
}
fee.merge(self.fee_without_families(db, false).await);
fee.merge(self.fee_without_families(db).await);
}
Some(fee)
}
async fn fee_without_families(&self, db: &SqlitePool, entry_fee_paid_with_family: bool) -> Fee {
async fn fee_without_families(&self, db: &SqlitePool) -> Fee {
let mut fee = Fee::new();
if !self.has_role(db, "Donau Linz").await
@@ -146,24 +125,38 @@ impl User {
let amount_boats = self.amount_boats(db).await;
if amount_boats > 0 {
if self.has_to_pay_only_half() {
fee.add(
format!("{}x Bootsplatz (Halbpreis)", amount_boats),
amount_boats * BOAT_STORAGE / 2,
);
} else {
fee.add(
format!("{}x Bootsplatz", amount_boats),
amount_boats * BOAT_STORAGE,
);
fee.add(
format!("{}x Bootsplatz", amount_boats),
amount_boats * BOAT_STORAGE,
);
}
if !self.has_role(db, "schnupperant").await {
if let Some(member_since_date) = &self.member_since_date {
if let Ok(member_since_date) =
NaiveDate::parse_from_str(member_since_date, "%Y-%m-%d")
{
if member_since_date.year() == Local::now().year()
&& !self.has_role(db, "no-einschreibgebuehr").await
{
fee.add("Einschreibgebühr".into(), EINSCHREIBGEBUEHR);
}
}
}
}
if self.has_to_pay_einschreibgebuehr_this_year(db).await && !entry_fee_paid_with_family {
fee.add("Einschreibgebühr".into(), EINSCHREIBGEBUEHR);
}
let halfprice = self.has_to_pay_only_half();
let halfprice = if let Some(member_since_date) = &self.member_since_date {
match NaiveDate::parse_from_str(member_since_date, "%Y-%m-%d") {
Ok(member_since_date) => {
let halfprice_startdate =
NaiveDate::from_ymd_opt(Local::now().year(), 7, 1).unwrap();
member_since_date >= halfprice_startdate
}
Err(_) => false,
}
} else {
false
};
if self.has_role(db, "schnupperant").await {
if self.has_role(db, "Student").await || self.has_role(db, "Schüler").await {

View File

@@ -1,490 +0,0 @@
use serde::Serialize;
use sqlx::{Row, Sqlite, SqlitePool, Transaction};
use std::ops::DerefMut;
use super::{ManageUserUser, User};
use crate::model::{activity::ActivityBuilder, stat::Stat};
#[derive(Serialize, Debug, Clone)]
pub struct UserWithKm {
pub id: i64,
pub name: String,
pub total_km: i32,
pub trip_count: i32,
pub deleted: bool,
}
impl UserWithKm {
/// Get all users with their total km stats, sorted by name
pub async fn all(db: &SqlitePool) -> Vec<Self> {
sqlx::query(
"
SELECT u.id, u.name, u.deleted,
COALESCE(CAST(SUM(l.distance_in_km) AS INTEGER), 0) AS total_km,
COUNT(r.logbook_id) AS trip_count
FROM user u
LEFT JOIN rower r ON u.id = r.rower_id
LEFT JOIN logbook l ON r.logbook_id = l.id AND l.distance_in_km IS NOT NULL
WHERE u.name != 'Externe Steuerperson'
GROUP BY u.id
ORDER BY u.name COLLATE NOCASE
",
)
.fetch_all(db)
.await
.unwrap()
.into_iter()
.map(|row| UserWithKm {
id: row.get("id"),
name: row.get("name"),
total_km: row.get("total_km"),
trip_count: row.get("trip_count"),
deleted: row.get("deleted"),
})
.collect()
}
}
#[derive(Serialize, Debug)]
pub struct MergePreview {
pub source_user: User,
pub target_user: User,
pub source_total_km: i32,
pub target_total_km: i32,
pub source_trip_count: i32,
pub target_trip_count: i32,
pub rower_entries_to_transfer: i64,
pub rower_conflicts: i64,
pub role_entries_to_transfer: i64,
pub role_conflicts: i64,
pub user_trip_entries_to_transfer: i64,
pub user_trip_conflicts: i64,
pub logbook_shipmaster_entries: i64,
pub logbook_steering_entries: i64,
pub trip_cox_entries: i64,
pub boat_owner_entries: i64,
pub boat_damage_entries: i64,
pub boat_reservation_entries: i64,
pub trailer_reservation_entries: i64,
pub notification_entries: i64,
}
impl User {
/// Generate a preview of what would happen if source user is merged into target user.
/// Source user will be deleted, target user will receive all references.
pub async fn merge_preview(db: &SqlitePool, source: &User, target: &User) -> MergePreview {
let source_stats = Stat::total_km(db, source).await;
let target_stats = Stat::total_km(db, target).await;
// Rower entries to transfer (no conflict - source is in logbooks target isn't)
let rower_entries_to_transfer = sqlx::query_scalar!(
"SELECT COUNT(*) FROM rower
WHERE rower_id = ?
AND logbook_id NOT IN (SELECT logbook_id FROM rower WHERE rower_id = ?)",
source.id,
target.id
)
.fetch_one(db)
.await
.unwrap();
// Rower conflicts (both users in same logbook - will delete source's entry)
let rower_conflicts = sqlx::query_scalar!(
"SELECT COUNT(*) FROM rower
WHERE rower_id = ?
AND logbook_id IN (SELECT logbook_id FROM rower WHERE rower_id = ?)",
source.id,
target.id
)
.fetch_one(db)
.await
.unwrap();
// Role entries to transfer (no conflict)
let role_entries_to_transfer = sqlx::query_scalar!(
"SELECT COUNT(*) FROM user_role
WHERE user_id = ?
AND role_id NOT IN (SELECT role_id FROM user_role WHERE user_id = ?)",
source.id,
target.id
)
.fetch_one(db)
.await
.unwrap();
// Role conflicts (both have same role - will delete source's entry)
let role_conflicts = sqlx::query_scalar!(
"SELECT COUNT(*) FROM user_role
WHERE user_id = ?
AND role_id IN (SELECT role_id FROM user_role WHERE user_id = ?)",
source.id,
target.id
)
.fetch_one(db)
.await
.unwrap();
// User trip entries to transfer (no conflict)
let user_trip_entries_to_transfer = sqlx::query_scalar!(
"SELECT COUNT(*) FROM user_trip
WHERE user_id = ?
AND trip_details_id NOT IN (SELECT trip_details_id FROM user_trip WHERE user_id = ?)",
source.id,
target.id
)
.fetch_one(db)
.await
.unwrap();
// User trip conflicts
let user_trip_conflicts = sqlx::query_scalar!(
"SELECT COUNT(*) FROM user_trip
WHERE user_id = ?
AND trip_details_id IN (SELECT trip_details_id FROM user_trip WHERE user_id = ?)",
source.id,
target.id
)
.fetch_one(db)
.await
.unwrap();
// Simple counts for other tables
let logbook_shipmaster_entries = sqlx::query_scalar!(
"SELECT COUNT(*) FROM logbook WHERE shipmaster = ?",
source.id
)
.fetch_one(db)
.await
.unwrap();
let logbook_steering_entries = sqlx::query_scalar!(
"SELECT COUNT(*) FROM logbook WHERE steering_person = ?",
source.id
)
.fetch_one(db)
.await
.unwrap();
let trip_cox_entries =
sqlx::query_scalar!("SELECT COUNT(*) FROM trip WHERE cox_id = ?", source.id)
.fetch_one(db)
.await
.unwrap();
let boat_owner_entries =
sqlx::query_scalar!("SELECT COUNT(*) FROM boat WHERE owner = ?", source.id)
.fetch_one(db)
.await
.unwrap();
let boat_damage_entries = sqlx::query_scalar!(
"SELECT COUNT(*) FROM boat_damage
WHERE user_id_created = ? OR user_id_fixed = ? OR user_id_verified = ?",
source.id,
source.id,
source.id
)
.fetch_one(db)
.await
.unwrap();
let boat_reservation_entries = sqlx::query_scalar!(
"SELECT COUNT(*) FROM boat_reservation
WHERE user_id_applicant = ? OR user_id_confirmation = ?",
source.id,
source.id
)
.fetch_one(db)
.await
.unwrap();
let trailer_reservation_entries = sqlx::query_scalar!(
"SELECT COUNT(*) FROM trailer_reservation
WHERE user_id_applicant = ? OR user_id_confirmation = ?",
source.id,
source.id
)
.fetch_one(db)
.await
.unwrap();
let notification_entries = sqlx::query_scalar!(
"SELECT COUNT(*) FROM notification WHERE user_id = ?",
source.id
)
.fetch_one(db)
.await
.unwrap();
MergePreview {
source_user: source.clone(),
target_user: target.clone(),
source_total_km: source_stats.rowed_km,
target_total_km: target_stats.rowed_km,
source_trip_count: source_stats.amount_trips,
target_trip_count: target_stats.amount_trips,
rower_entries_to_transfer,
rower_conflicts,
role_entries_to_transfer,
role_conflicts,
user_trip_entries_to_transfer,
user_trip_conflicts,
logbook_shipmaster_entries,
logbook_steering_entries,
trip_cox_entries,
boat_owner_entries,
boat_damage_entries,
boat_reservation_entries,
trailer_reservation_entries,
notification_entries,
}
}
/// Merge source user into target user, then hard delete source.
/// All foreign key references are transferred from source to target.
/// Returns Ok(()) on success, Err with description on failure.
pub async fn merge_into(
db: &SqlitePool,
source: &User,
target: &User,
merged_by: &ManageUserUser,
) -> Result<(), String> {
// Validation
if source.id == target.id {
return Err("Kann Benutzer nicht mit sich selbst zusammenführen".into());
}
if source.name == "Externe Steuerperson" {
return Err("'Externe Steuerperson' kann nicht zusammengeführt werden".into());
}
if source.on_water(db).await {
return Err(format!(
"{} ist gerade auf dem Wasser und kann nicht zusammengeführt werden",
source.name
));
}
let mut tx = db.begin().await.unwrap();
// Execute merge in transaction
Self::merge_into_tx(&mut tx, source, target).await?;
// Log activity
ActivityBuilder::new(&format!(
"{} hat Benutzer '{}' ({} km, {} Ausfahrten) in '{}' zusammengeführt und gelöscht.",
merged_by.name,
source.name,
Stat::total_km(db, source).await.rowed_km,
Stat::total_km(db, source).await.amount_trips,
target.name
))
.user(target)
.save_tx(&mut tx)
.await;
tx.commit().await.unwrap();
Ok(())
}
async fn merge_into_tx(
tx: &mut Transaction<'_, Sqlite>,
source: &User,
target: &User,
) -> Result<(), String> {
// Step 1: DELETE conflicts (where both users have same FK target)
// Delete rower entries where both users rowed in same logbook
sqlx::query!(
"DELETE FROM rower
WHERE rower_id = ?
AND logbook_id IN (SELECT logbook_id FROM rower WHERE rower_id = ?)",
source.id,
target.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// Delete role entries where both users have same role
sqlx::query!(
"DELETE FROM user_role
WHERE user_id = ?
AND role_id IN (SELECT role_id FROM user_role WHERE user_id = ?)",
source.id,
target.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// Delete user_trip entries where both users in same trip
sqlx::query!(
"DELETE FROM user_trip
WHERE user_id = ?
AND trip_details_id IN (SELECT trip_details_id FROM user_trip WHERE user_id = ?)",
source.id,
target.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// Step 2: UPDATE remaining references
// rower.rower_id
sqlx::query!(
"UPDATE rower SET rower_id = ? WHERE rower_id = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// user_role.user_id
sqlx::query!(
"UPDATE user_role SET user_id = ? WHERE user_id = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// user_trip.user_id
sqlx::query!(
"UPDATE user_trip SET user_id = ? WHERE user_id = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// logbook.shipmaster
sqlx::query!(
"UPDATE logbook SET shipmaster = ? WHERE shipmaster = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// logbook.steering_person
sqlx::query!(
"UPDATE logbook SET steering_person = ? WHERE steering_person = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// trip.cox_id
sqlx::query!(
"UPDATE trip SET cox_id = ? WHERE cox_id = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// boat.owner
sqlx::query!(
"UPDATE boat SET owner = ? WHERE owner = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// boat_damage (3 columns)
sqlx::query!(
"UPDATE boat_damage SET user_id_created = ? WHERE user_id_created = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
sqlx::query!(
"UPDATE boat_damage SET user_id_fixed = ? WHERE user_id_fixed = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
sqlx::query!(
"UPDATE boat_damage SET user_id_verified = ? WHERE user_id_verified = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// boat_reservation (2 columns)
sqlx::query!(
"UPDATE boat_reservation SET user_id_applicant = ? WHERE user_id_applicant = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
sqlx::query!(
"UPDATE boat_reservation SET user_id_confirmation = ? WHERE user_id_confirmation = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// trailer_reservation (2 columns)
sqlx::query!(
"UPDATE trailer_reservation SET user_id_applicant = ? WHERE user_id_applicant = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
sqlx::query!(
"UPDATE trailer_reservation SET user_id_confirmation = ? WHERE user_id_confirmation = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// notification.user_id
sqlx::query!(
"UPDATE notification SET user_id = ? WHERE user_id = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// Step 3: Hard delete the source user
sqlx::query!("DELETE FROM user WHERE id = ?", source.id)
.execute(tx.deref_mut())
.await
.unwrap();
Ok(())
}
}

View File

@@ -33,7 +33,6 @@ pub(crate) mod clubmember;
mod fee;
pub(crate) mod foerdernd;
pub(crate) mod member;
pub mod merge;
pub(crate) mod regular;
pub(crate) mod scheckbuch;
pub(crate) mod schnupperant;
@@ -89,20 +88,17 @@ pub struct UserWithDetails {
pub allowed_to_steer: bool,
pub on_water: bool,
pub roles: Vec<String>,
pub action_notification: Option<Notification>,
}
impl UserWithDetails {
pub async fn from_user(user: User, db: &SqlitePool) -> Self {
let allowed_to_steer = user.allowed_to_steer(db).await;
let action_notification = Notification::oldest_unread_with_action(db, user.id).await;
Self {
on_water: user.on_water(db).await,
roles: user.roles(db).await,
amount_unread_notifications: user.amount_unread_notifications(db).await,
allowed_to_steer,
action_notification,
user,
}
}
@@ -140,7 +136,7 @@ impl User {
pub async fn amount_boats(&self, db: &SqlitePool) -> i64 {
sqlx::query!(
"SELECT COUNT(*) as count FROM boat WHERE owner = ? and deleted = 0",
"SELECT COUNT(*) as count FROM boat WHERE owner = ?",
self.id
)
.fetch_one(db)
@@ -362,13 +358,6 @@ WHERE lower(name)=lower(?)
}
pub async fn all_with_order(db: &SqlitePool, sort: &str, asc: bool) -> Vec<Self> {
let allowed_sort_columns = ["last_access", "name", "member_since_date"];
let sort_column = if allowed_sort_columns.contains(&sort) {
sort
} else {
"last_access"
};
let mut query = format!(
"
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, phone, address, family_id, user_token
@@ -376,7 +365,7 @@ WHERE lower(name)=lower(?)
WHERE deleted = 0
ORDER BY {}
",
sort_column
sort
);
if !asc {
query.push_str(" DESC");
@@ -806,7 +795,6 @@ macro_rules! special_user {
}
impl $name {
#[allow(dead_code)]
pub fn into_inner(self) -> User {
self.user
}
@@ -871,7 +859,6 @@ special_user!(AllowedForPlannedTripsUser, +"Donau Linz", +"scheckbuch", +"Förde
special_user!(DonauLinzUser, +"Donau Linz", +"Förderndes Mitglied", -"Unterstützend"); // TODO:
// remove ->
// RegularUser
special_user!(ErgoAdminUser, +"ergo-admin", +"admin");
special_user!(SchnupperBetreuerUser, +"schnupper-betreuer");
special_user!(VorstandUser, +"admin", +"Vorstand");
special_user!(EventUser, +"manage_events");

View File

@@ -1,6 +1,5 @@
mod waterlevel;
mod weather;
mod yearly_role_cleanup;
use std::time::Duration;
@@ -14,7 +13,7 @@ pub fn schedule(db: &SqlitePool, config: &Config) {
let db = db.clone();
let openweathermap_key = config.openweathermap_key.clone();
tokio::task::spawn(async move {
tokio::task::spawn(async {
if let Err(e) = waterlevel::update(&db).await {
log::error!("Water level update error: {e}, trying again next time");
}
@@ -25,9 +24,8 @@ pub fn schedule(db: &SqlitePool, config: &Config) {
let mut sched = JobScheduler::new();
// Every hour
let db_for_hourly = db.clone();
sched.add(Job::new("0 0 * * * * *".parse().unwrap(), move || {
let db_clone = db_for_hourly.clone();
let db_clone = db.clone();
// Use block_in_place to run async code in the synchronous function; TODO: Make it
// nicer one's rust (stable) support async closures
task::block_in_place(|| {
@@ -42,19 +40,6 @@ pub fn schedule(db: &SqlitePool, config: &Config) {
});
}));
// January 1st at midnight - yearly role cleanup
let db_for_yearly = db.clone();
sched.add(Job::new("0 0 0 1 1 * *".parse().unwrap(), move || {
let db_clone = db_for_yearly.clone();
task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
if let Err(e) = yearly_role_cleanup::cleanup_roles(&db_clone).await {
log::error!("Yearly role cleanup error: {e}");
}
});
});
}));
let mut interval = time::interval(Duration::from_secs(60));
loop {
sched.tick();

View File

@@ -1,158 +0,0 @@
use crate::model::{notification::Notification, role::Role};
use sqlx::SqlitePool;
pub async fn cleanup_roles(db: &SqlitePool) -> Result<(), String> {
log::info!("Starting yearly role cleanup...");
let mut tx = db.begin().await.map_err(|e| e.to_string())?;
// Find all roles to remove
let paid_role = Role::find_by_name_tx(&mut tx, "paid")
.await
.ok_or("Role 'paid' not found")?;
let schueler_role = Role::find_by_name_tx(&mut tx, "Schüler")
.await
.ok_or("Role 'Schüler' not found")?;
let student_role = Role::find_by_name_tx(&mut tx, "Student")
.await
.ok_or("Role 'Student' not found")?;
let no_einschreibgebuehr_role = Role::find_by_name_tx(&mut tx, "no-einschreibgebuehr")
.await
.ok_or("Role 'no-einschreibgebuehr' not found")?;
let half_rennrudern_role = Role::find_by_name_tx(&mut tx, "half-rennrudern")
.await
.ok_or("Role 'half-rennrudern' not found")?;
let participated_schnupperkurs_role =
Role::find_by_name_tx(&mut tx, "participated_schnupperkurs")
.await
.ok_or("Role 'participated_schnupperkurs' not found")?;
// Find scheckbuch role (needed to exclude users from "paid" removal -> they have still paid
// for the scheckbuch)
let scheckbuch_role = Role::find_by_name_tx(&mut tx, "scheckbuch")
.await
.ok_or("Role 'scheckbuch' not found")?;
// Remove "paid" role from all users EXCEPT those with scheckbuch role
let paid_removed = sqlx::query!(
"DELETE FROM user_role
WHERE role_id = ?
AND user_id NOT IN (
SELECT user_id FROM user_role WHERE role_id = ?
)",
paid_role.id,
scheckbuch_role.id
)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
// Remove other roles from all users
let schueler_removed =
sqlx::query!("DELETE FROM user_role WHERE role_id = ?", schueler_role.id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
let student_removed = sqlx::query!("DELETE FROM user_role WHERE role_id = ?", student_role.id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
let no_einschreibgebuehr_removed = sqlx::query!(
"DELETE FROM user_role WHERE role_id = ?",
no_einschreibgebuehr_role.id
)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
let half_rennrudern_removed = sqlx::query!(
"DELETE FROM user_role WHERE role_id = ?",
half_rennrudern_role.id
)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
let participated_schnupperkurs_removed = sqlx::query!(
"DELETE FROM user_role WHERE role_id = ?",
participated_schnupperkurs_role.id
)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
// Send notifications to admins and Vorstand
let admin_role = Role::find_by_name_tx(&mut tx, "admin")
.await
.ok_or("Role 'admin' not found")?;
let vorstand_role = Role::find_by_name_tx(&mut tx, "Vorstand")
.await
.ok_or("Role 'Vorstand' not found")?;
let notification_message_admin = format!(
"Jährliche Rollenbereinigung abgeschlossen. Die folgenden Rollen wurden entfernt: \
paid ({} Benutzer, außer Scheckbuch-Mitglieder), \
Schüler/Student ({}/{} Benutzer), \
no-einschreibgebuehr ({} Benutzer), \
half-rennrudern ({} Benutzer), \
participated_schnupperkurs ({} Benutzer). \
Die aktualisierten Gebühren können unter https://app.rudernlinz.at/admin/user/fees eingesehen werden.",
paid_removed,
schueler_removed,
student_removed,
no_einschreibgebuehr_removed,
half_rennrudern_removed,
participated_schnupperkurs_removed
);
let notification_message_vorstand = format!(
"Jährliche Rollenbereinigung abgeschlossen. \
Die aktualisierten Gebühren können unter https://app.rudernlinz.at/admin/user/fees eingesehen werden.",
);
// Notify admins
Notification::create_for_role_tx(
&mut tx,
&admin_role,
&notification_message_admin,
"Systembenachrichtigung",
Some("https://app.rudernlinz.at/admin/user/fees"),
None,
)
.await;
// Notify Vorstand
Notification::create_for_role_tx(
&mut tx,
&vorstand_role,
&notification_message_vorstand,
"Systembenachrichtigung",
Some("https://app.rudernlinz.at/admin/user/fees"),
None,
)
.await;
// Commit transaction
tx.commit().await.map_err(|e| e.to_string())?;
log::info!(
"Yearly role cleanup completed successfully: \
paid={}, Schüler={}, Student={}, no-einschreibgebuehr={}, \
half-rennrudern={}, participated_schnupperkurs={} removals",
paid_removed,
schueler_removed,
student_removed,
no_einschreibgebuehr_removed,
half_rennrudern_removed,
participated_schnupperkurs_removed
);
Ok(())
}

View File

@@ -64,7 +64,6 @@ async fn index(
let user: User = user.into_inner();
let allowed_to_edit = ManageUserUser::new(db, &user).await.is_some();
let is_admin = AdminUser::new(db, &user).await.is_some();
let users: Vec<UserWithRolesAndMembershipPdf> = join_all(user_futures).await;
let financial = Role::all_cluster(db, "financial").await;
@@ -77,7 +76,6 @@ async fn index(
context.insert("flash", &msg.into_inner());
}
context.insert("allowed_to_edit", &allowed_to_edit);
context.insert("is_admin", &is_admin);
context.insert("users", &users);
context.insert("roles", &roles);
context.insert("financial", &financial);
@@ -112,7 +110,6 @@ async fn index_admin(
context.insert("flash", &msg.into_inner());
}
context.insert("allowed_to_edit", &allowed_to_edit);
context.insert("is_admin", &true);
context.insert("users", &users);
context.insert("roles", &roles);
context.insert("financial", &financial);
@@ -309,97 +306,6 @@ async fn delete(db: &State<SqlitePool>, admin: ManageUserUser, user: i32) -> Fla
}
}
use crate::model::user::merge::UserWithKm;
#[get("/user/merge?<source>&<target>")]
async fn merge_page(
db: &State<SqlitePool>,
admin: ManageUserUser,
flash: Option<FlashMessage<'_>>,
source: Option<i32>,
target: Option<i32>,
) -> Template {
let users_with_km = UserWithKm::all(db).await;
let admin_user: User = admin.into_inner();
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("users", &users_with_km);
// If both source and target are selected, show preview
if let (Some(source_id), Some(target_id)) = (source, target) {
if source_id != target_id {
if let (Some(source_user), Some(target_user)) = (
User::find_by_id(db, source_id).await,
User::find_by_id(db, target_id).await,
) {
let preview = User::merge_preview(db, &source_user, &target_user).await;
context.insert("source_user", &source_user);
context.insert("target_user", &target_user);
context.insert("preview", &preview);
}
}
}
context.insert("selected_source", &source);
context.insert("selected_target", &target);
context.insert(
"loggedin_user",
&UserWithDetails::from_user(admin_user, db).await,
);
Template::render("admin/user/merge", context.into_json())
}
#[derive(FromForm, Debug)]
pub struct MergeForm {
source_id: i32,
target_id: i32,
}
#[post("/user/merge", data = "<data>")]
async fn merge_execute(
db: &State<SqlitePool>,
admin: ManageUserUser,
data: Form<MergeForm>,
) -> Flash<Redirect> {
let Some(source_user) = User::find_by_id(db, data.source_id).await else {
return Flash::error(
Redirect::to("/admin/user/merge"),
format!("User mit ID {} existiert nicht", data.source_id),
);
};
let Some(target_user) = User::find_by_id(db, data.target_id).await else {
return Flash::error(
Redirect::to("/admin/user/merge"),
format!("Ziel-User mit ID {} existiert nicht", data.target_id),
);
};
let source_name = source_user.name.clone();
match User::merge_into(db, &source_user, &target_user, &admin).await {
Ok(()) => Flash::success(
Redirect::to(format!("/admin/user/{}", data.target_id)),
format!(
"Benutzer '{}' erfolgreich in '{}' zusammengeführt",
source_name, target_user.name
),
),
Err(e) => Flash::error(
Redirect::to(format!(
"/admin/user/merge?source={}&target={}",
data.source_id, data.target_id
)),
e,
),
}
}
#[derive(FromForm, Debug)]
pub struct MailUpdateForm {
mail: String,
@@ -1531,9 +1437,6 @@ pub fn routes() -> Vec<Route> {
view,
resetpw,
delete,
// Merge
merge_page,
merge_execute,
fees,
fees_paid,
scheckbuch,

View File

@@ -1,7 +1,8 @@
use std::env;
use chrono::{Datelike, Utc};
use chrono::Utc;
use rocket::{
FromForm, Route, State,
form::Form,
fs::TempFile,
get,
@@ -9,19 +10,18 @@ use rocket::{
post,
request::FlashMessage,
response::{Flash, Redirect},
routes, FromForm, Route, State,
routes,
};
use rocket_dyn_templates::{context, Template};
use rocket_dyn_templates::{Template, context};
use serde::Serialize;
use sqlx::SqlitePool;
use tera::Context;
use crate::model::{
activity::ActivityBuilder,
log::Log,
notification::Notification,
role::Role,
user::{AdminUser, ErgoAdminUser, User, UserWithDetails},
user::{AdminUser, User, UserWithDetails},
};
#[derive(Serialize)]
@@ -59,7 +59,7 @@ async fn send(db: &State<SqlitePool>, _user: AdminUser) -> Template {
}
#[get("/reset")]
async fn reset(db: &State<SqlitePool>, _user: ErgoAdminUser) -> Flash<Redirect> {
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
@@ -74,7 +74,7 @@ async fn reset(db: &State<SqlitePool>, _user: ErgoAdminUser) -> Flash<Redirect>
#[get("/<challenge>/user/<user_id>/new?<new>")]
async fn update(
db: &State<SqlitePool>,
_admin: ErgoAdminUser,
_admin: AdminUser,
challenge: &str,
user_id: i64,
new: &str,
@@ -146,61 +146,47 @@ pub struct UserAdd {
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 info@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
sqlx::query!(
"INSERT INTO user_role(user_id, role_id) VALUES (?, ?)",
user.id,
ergo.id
)
.execute(db.inner())
.await
.unwrap();
ActivityBuilder::new(&format!(
"{user} nimmt an der Ergo-Challenge teil und hat gerade die Daten eingegeben."
))
.user(&user)
.save(db)
.await;
Flash::success(
Redirect::to("/ergo"),
"Du hast deine Daten erfolgreich eingegeben. Viel Spaß beim Schwitzen :-)",
)
}
//#[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> {
@@ -373,7 +359,10 @@ async fn new_dozen(
}
pub fn routes() -> Vec<Route> {
routes![index, new_thirty, new_dozen, send, reset, update, new_user]
routes![
index, new_thirty, new_dozen, send, reset, update,
// new_user
]
}
#[cfg(test)]

View File

@@ -2,7 +2,7 @@ use std::{fs::OpenOptions, io::Write};
use chrono::{Datelike, Local};
use rocket::{
catch, catchers,
Build, Data, FromForm, Request, Rocket, State, catch, catchers,
fairing::{AdHoc, Fairing, Info, Kind},
form::Form,
fs::FileServer,
@@ -13,7 +13,6 @@ use rocket::{
response::{Flash, Redirect},
routes,
time::{Duration, OffsetDateTime},
Build, Data, FromForm, Request, Rocket, State,
};
use rocket_dyn_templates::Template;
use serde::Deserialize;
@@ -21,6 +20,7 @@ use sqlx::SqlitePool;
use tera::Context;
use crate::{
SCHECKBUCH,
model::{
logbook::Logbook,
notification::Notification,
@@ -28,7 +28,6 @@ use crate::{
role::Role,
user::{User, UserWithDetails},
},
SCHECKBUCH,
};
pub(crate) mod admin;
@@ -331,11 +330,13 @@ mod test {
assert_eq!(response.status(), Status::Ok);
assert!(response
.into_string()
.await
.unwrap()
.contains("Ruderassistent"));
assert!(
response
.into_string()
.await
.unwrap()
.contains("Ruderassistent")
);
}
#[sqlx::test]

View File

@@ -4,11 +4,6 @@
<div class="max-w-screen-lg w-full">
<h1 class="h1">Users</h1>
{% if allowed_to_edit %}
{% if is_admin %}
<div class="mt-5 flex gap-3">
<a href="/admin/user/merge" class="btn btn-dark">Benutzer zusammenführen</a>
</div>
{% endif %}
<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
@@ -168,14 +163,6 @@
<a href="?sort=name"
class="block px-4 py-2 hover:bg-gray-100 hover:text-secondary-950">Name Z-A</a>
</li>
<li>
<a href="?sort=member_since_date&asc"
class="block px-4 py-2 hover:bg-gray-100 hover:text-secondary-950">Mitglied seit (älteste)</a>
</li>
<li>
<a href="?sort=member_since_date"
class="block px-4 py-2 hover:bg-gray-100 hover:text-secondary-950">Mitglied seit (neueste)</a>
</li>
</ul>
</div>
</div>

View File

@@ -1,141 +0,0 @@
{% import "includes/macros" as macros %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-xl w-full">
<div class="mb-5 lg:mb-0">
<a href="/admin/user" class="link link-primary link-no-underline">&larr; Userverwaltung</a>
</div>
<h1 class="h1">Benutzer zusammenführen</h1>
<p class="text-gray-600 dark:text-gray-300 mb-6">
Wähle zwei Benutzer aus: Der erste (Quelle) wird gelöscht und alle Daten werden zum zweiten (Ziel) übertragen.
</p>
<div class="grid lg:grid-cols-2 gap-6 mb-6">
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md shadow p-4">
<h2 class="text-lg font-bold mb-3 text-red-600 dark:text-red-400">Quelle (wird gelöscht)</h2>
<form method="get" id="source-form">
{% if selected_target %}
<input type="hidden" name="target" value="{{ selected_target }}" />
{% endif %}
<select name="source" class="input rounded-md w-full" onchange="this.form.submit()">
<option value="">-- Benutzer auswählen --</option>
{% for user in users %}
<option value="{{ user.id }}" {% if selected_source == user.id %}selected{% endif %}>
{{ user.name }}{% if user.deleted %} [gelöscht]{% endif %} ({{ user.total_km }} km)
</option>
{% endfor %}
</select>
</form>
</div>
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md shadow p-4">
<h2 class="text-lg font-bold mb-3 text-green-600 dark:text-green-400">Ziel (bleibt erhalten)</h2>
<form method="get" id="target-form">
{% if selected_source %}
<input type="hidden" name="source" value="{{ selected_source }}" />
{% endif %}
<select name="target" class="input rounded-md w-full" onchange="this.form.submit()">
<option value="">-- Benutzer auswählen --</option>
{% for user in users %}
<option value="{{ user.id }}" {% if selected_target == user.id %}selected{% endif %}>
{{ user.name }}{% if user.deleted %} [gelöscht]{% endif %} ({{ user.total_km }} km)
</option>
{% endfor %}
</select>
</form>
</div>
</div>
{% if preview %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md shadow p-6 mb-6">
<h2 class="text-lg font-bold mb-4">Vorschau der Änderungen</h2>
<div class="grid sm:grid-cols-3 gap-6 mb-6">
<div class="border border-red-300 dark:border-red-700 rounded-md p-4 bg-red-50 dark:bg-red-900/20">
<h3 class="font-semibold text-red-700 dark:text-red-400 mb-2">
{{ source_user.name }}
<span class="text-sm font-normal block">(wird gelöscht)</span>
</h3>
<ul class="text-sm space-y-1">
<li><strong>{{ preview.source_total_km }}</strong> km</li>
<li><strong>{{ preview.source_trip_count }}</strong> Ausfahrten</li>
</ul>
</div>
<div class="flex items-center justify-center text-4xl text-gray-400">
&rarr;
</div>
<div class="border border-green-300 dark:border-green-700 rounded-md p-4 bg-green-50 dark:bg-green-900/20">
<h3 class="font-semibold text-green-700 dark:text-green-400 mb-2">
{{ target_user.name }}
<span class="text-sm font-normal block">(bleibt)</span>
</h3>
<ul class="text-sm space-y-1">
<li><strong>{{ preview.target_total_km }}</strong> km</li>
<li><strong>{{ preview.target_trip_count }}</strong> Ausfahrten</li>
</ul>
</div>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md p-4 mb-4">
<h3 class="font-semibold mb-2">Nach Zusammenführung:</h3>
<p class="text-lg">
<strong>{{ target_user.name }}</strong> wird haben:
<strong>{{ preview.source_total_km + preview.target_total_km }}</strong> km,
<strong>{{ preview.source_trip_count + preview.target_trip_count - preview.rower_conflicts }}</strong> Ausfahrten
</p>
</div>
{% set total_to_transfer = preview.rower_entries_to_transfer + preview.role_entries_to_transfer + preview.user_trip_entries_to_transfer + preview.logbook_shipmaster_entries + preview.logbook_steering_entries %}
{% if total_to_transfer > 0 %}
<div class="mb-4">
<h3 class="font-semibold mb-2">Daten die übertragen werden:</h3>
<ul class="text-sm list-disc ml-6 space-y-1">
{% if preview.rower_entries_to_transfer > 0 %}
<li>{{ preview.rower_entries_to_transfer }} Ausfahrten</li>
{% endif %}
{% if preview.role_entries_to_transfer > 0 %}
<li>{{ preview.role_entries_to_transfer }} Rollen</li>
{% endif %}
{% if preview.logbook_shipmaster_entries > 0 %}
<li>{{ preview.logbook_shipmaster_entries }} Logbuch-Einträge (als Schiffsführer)</li>
{% endif %}
{% if preview.logbook_steering_entries > 0 %}
<li>{{ preview.logbook_steering_entries }} Logbuch-Einträge (als Steuerperson)</li>
{% endif %}
</ul>
</div>
{% endif %}
{% set total_conflicts = preview.rower_conflicts + preview.role_conflicts + preview.user_trip_conflicts %}
{% if total_conflicts > 0 %}
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-700 rounded-md p-3 mb-4">
<p class="text-yellow-800 dark:text-yellow-300 font-semibold">
{{ total_conflicts }} doppelte Einträge werden entfernt
</p>
<ul class="text-sm text-yellow-700 dark:text-yellow-400 list-disc ml-6 mt-1">
{% if preview.rower_conflicts > 0 %}
<li>{{ preview.rower_conflicts }} Ausfahrten (beide waren im selben Boot)</li>
{% endif %}
{% if preview.role_conflicts > 0 %}
<li>{{ preview.role_conflicts }} Rollen (beide haben dieselbe Rolle)</li>
{% endif %}
</ul>
</div>
{% endif %}
<form action="/admin/user/merge" method="post" class="flex gap-4">
<input type="hidden" name="source_id" value="{{ source_user.id }}" />
<input type="hidden" name="target_id" value="{{ target_user.id }}" />
<a href="/admin/user/merge" class="btn btn-secondary flex-1 text-center">Abbrechen</a>
<button type="submit"
class="btn btn-alert flex-1"
onclick="return confirm('Bist du sicher? {{ source_user.name }} wird unwiderruflich gelöscht und alle Daten zu {{ target_user.name }} übertragen!')">
Zusammenführen
</button>
</form>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -53,21 +53,6 @@
{% include "includes/footer" %}
{% endif %}
{% include "dynamics/sidebar" %}
{% if loggedin_user and loggedin_user.action_notification %}
<dialog id="action-notification-modal" class="max-w-screen-sm dark:bg-primary-600 dark:text-white rounded-md">
<div class="p-4">
<small class="text-gray-600 dark:text-gray-100">
<strong>{{ loggedin_user.action_notification.category }}</strong>
</small>
<div class="my-4">{{ loggedin_user.action_notification.message }}</div>
<a href="/notification/{{ loggedin_user.action_notification.id }}/read" class="btn btn-dark w-full mt-3">
&#10003;
<span class="sr-only">Notification gelesen</span>
</a>
</div>
</dialog>
<script>document.getElementById('action-notification-modal').showModal();</script>
{% endif %}
<script src="/public/main.js"></script>
</body>
</html>

View File

@@ -15,7 +15,10 @@
class="link-primary">Überblick der Challenges</a>
</li>
<li class="py-1">
Eintragung ist jederzeit möglich, wenn du sie auch an die offizielle Liste schicken willst, kannst du das <a href="https://data.ergochallenge.at/" target="_blank" style="text-decoration: underline">hier</a> machen
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"
@@ -191,7 +194,7 @@
</div>
</details>
</div>
{% if "admin" in loggedin_user.roles or "ergo-admin" in loggedin_user.roles %}
{% 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">
@@ -230,14 +233,6 @@
</ol>
</div>
</details>
<div class="mt-3 text-right">
<a href="/ergo/reset"
class="w-28 btn btn-alert"
onclick="return confirm('Willst du wirklich alle Ergo-Eingaben löschen?');">
{% include "includes/delete-icon" %}
Einträge löschen
</a>
</div>
</div>
</div>
{% endif %}

View File

@@ -78,12 +78,11 @@
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;
@@ -92,21 +91,13 @@
}
select.appendChild(option);
}
var gesamtOption = document.createElement('option');
gesamtOption.value = 0;
gesamtOption.textContent = 'GESAMT';
if (selectedYear == 0) {
gesamtOption.selected = true;
}
select.appendChild(gesamtOption);
}
function changeYear() {
var selectedYear = document.getElementById('yearSelect').value;
window.location.href = '?year=' + selectedYear;
}
populateYears();
</script>
{% endblock content %}