11 Commits

Author SHA1 Message Date
c92c5526c3 Merge pull request 'yeaerly-cleanup' (#1160) from yeaerly-cleanup into staging
Some checks failed
CI/CD Pipeline / test (push) Failing after 24m59s
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Update Cargo Dependencies / update-dependencies (push) Successful in 1m24s
Reviewed-on: #1160
2025-11-21 10:35:07 +01:00
3148d744e6 yearly cleanup of roles; fixes #941
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-11-21 10:32:59 +01:00
43d9dcc31a more-robust-ui-tests (#1158)
All checks were successful
CI/CD Pipeline / test (push) Successful in 23m4s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Co-authored-by: Philipp Hofer <philipp.hofer@mag.linz.at>
Reviewed-on: #1158
Co-authored-by: Philipp Hofer <philipp@hofer.link>
Co-committed-by: Philipp Hofer <philipp@hofer.link>
2025-11-20 19:21:11 +01:00
5c1d8876be more-robust-ui-tests (#1157)
All checks were successful
CI/CD Pipeline / test (push) Successful in 20m22s
CI/CD Pipeline / deploy-staging (push) Successful in 34m20s
CI/CD Pipeline / deploy-main (push) Has been skipped
Update Cargo Dependencies / update-dependencies (push) Successful in 2m17s
Co-authored-by: Philipp Hofer <philipp.hofer@mag.linz.at>
Reviewed-on: #1157
Co-authored-by: Philipp Hofer <philipp@hofer.link>
Co-committed-by: Philipp Hofer <philipp@hofer.link>
2025-11-20 19:20:43 +01:00
24fe027f7b Merge pull request 'bank-name-mention' (#1156) from bank-name-mention into main
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1156
2025-11-20 08:19:22 +01:00
e89c5c7439 Merge pull request 'Update src/model/mail.rs' (#1155) from bank-name-mention into staging
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1155
2025-11-20 08:19:19 +01:00
b605f82af7 Update src/model/mail.rs
Some checks failed
CI/CD Pipeline / deploy-main (push) Blocked by required conditions
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-11-20 08:16:30 +01:00
a59d8c0331 Merge pull request 'enable self-enrollment to ergo challenge' (#1147) from allow-ergo-entry into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 18m23s
CI/CD Pipeline / deploy-staging (push) Successful in 10m49s
CI/CD Pipeline / deploy-main (push) Has been skipped
Update Cargo Dependencies / update-dependencies (push) Successful in 1m22s
Reviewed-on: #1147
2025-10-07 19:08:49 +02:00
71760a500f Merge pull request 'allow-ergo-entry' (#1145) from allow-ergo-entry into staging
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
Reviewed-on: #1145
2025-10-07 18:53:41 +02:00
a1b18d6f92 Merge pull request 'nicer formatting' (#1144) from add-ergo-role into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 19m13s
CI/CD Pipeline / deploy-staging (push) Successful in 9m6s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1144
2025-10-07 16:52:57 +02:00
465a42acac Merge pull request 'also show button again' (#1142) from add-ergo-role into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 17m8s
CI/CD Pipeline / deploy-staging (push) Successful in 9m6s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1142
2025-10-07 16:24:37 +02:00
7 changed files with 293 additions and 224 deletions

View File

@@ -1,4 +1,9 @@
import { test, expect } from "@playwright/test";
import { test, expect, Page } from "@playwright/test";
import { resetDatabase, login } from "./helpers";
test.beforeEach(async () => {
await resetDatabase();
});
test("cox can create and delete trip", async ({ page }) => {
await page.goto("/auth");
@@ -16,22 +21,13 @@ 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", () => {
let sharedPage: Page;
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
async function createTrip(page: Page) {
await page.goto("/auth");
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("cox");
@@ -46,151 +42,101 @@ 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();
}
sharedPage = page;
});
test("edit remarks", 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(
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",
);
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(
test("add and remove guest", async ({ page }) => {
await createTrip(page);
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(
"Erfolgreich angemeldet!",
);
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
await page.getByRole("link", { name: "Details" }).nth(1).click();
await expect(page.locator("#sidebar")).toContainText(
"Freie Plätze: 4",
);
await expect(sharedPage.locator("#sidebar")).toContainText(
await expect(page.locator("#sidebar")).toContainText(
"Mein Gast (Gast) Abmelden",
);
await expect(
sharedPage.getByRole("link", { name: "Termin löschen" }),
page.getByRole("link", { name: "Termin löschen" }),
).not.toBeVisible();
await sharedPage.getByRole("link", { name: "Abmelden" }).click();
await expect(sharedPage.locator("body")).toContainText(
await page.getByRole("link", { name: "Abmelden" }).click();
await expect(page.locator("body")).toContainText(
"Erfolgreich abgemeldet!",
);
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
await page.getByRole("link", { name: "Details" }).nth(1).click();
await expect(page.locator("#sidebar")).toContainText(
"Freie Plätze: 5",
);
await expect(sharedPage.locator("#sidebar")).toContainText(
await expect(page.locator("#sidebar")).toContainText(
"Keine Ruderer angemeldet",
);
await expect(
sharedPage.getByRole("link", { name: "Termin löschen" }),
page.getByRole("link", { name: "Termin löschen" }),
).toBeVisible();
await sharedPage
.getByRole("button", { name: "Ausfahrt erstellen schließen" })
.click();
});
test("change amount rower", async () => {
await sharedPage.goto("/planned");
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
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(
"Freie Plätze: 5",
);
await sharedPage.getByRole("spinbutton").click();
await sharedPage.getByRole("spinbutton").fill("3");
await sharedPage.getByRole("button", { name: "Speichern" }).click();
await expect(sharedPage.locator("body")).toContainText(
await page.getByRole("spinbutton").click();
await page.getByRole("spinbutton").fill("3");
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page.locator("body")).toContainText(
"Ausfahrt erfolgreich aktualisiert.",
);
});
test("call off trip", async () => {
test("call off trip", async ({ page }) => {
await createTrip(page);
// Someone registers...
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("/auth/logout");
await page.waitForURL("/auth");
await login(page, "rower", "rower");
await sharedPage.goto("/planned");
await sharedPage.getByRole('link', { name: 'Mitrudern' }).nth(1).click();
await page.goto("/planned");
await page.getByRole('link', { name: 'Mitrudern' }).nth(1).click();
// Login as cox again
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("/auth/logout");
await page.waitForURL("/auth");
await login(page, "cox", "cox");
await sharedPage.goto("/planned");
await page.goto("/planned");
// ... 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(
// 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(
"Ausfahrt erfolgreich aktualisiert.",
);
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();
await expect(page.locator("body")).toContainText("(Absage cox)");
});
// TODO: 'Immer anzeigen' (also verify the functionality), 'Gesperrt' + type

29
frontend/tests/helpers.ts Normal file
View File

@@ -0,0 +1,29 @@
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,4 +1,9 @@
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");
@@ -34,12 +39,6 @@ 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) => {
@@ -102,28 +101,6 @@ 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) => {
@@ -151,12 +128,6 @@ 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) => {
@@ -210,29 +181,6 @@ 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) => {
@@ -286,29 +234,6 @@ 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) => {
@@ -355,27 +280,4 @@ 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();
});

19
reset_test_data.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/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. 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 (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\
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 (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 (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.)
Mit freundlichen Grüßen,\n\
Der Vorstand");

View File

@@ -1,5 +1,6 @@
mod waterlevel;
mod weather;
mod yearly_role_cleanup;
use std::time::Duration;
@@ -13,7 +14,7 @@ pub fn schedule(db: &SqlitePool, config: &Config) {
let db = db.clone();
let openweathermap_key = config.openweathermap_key.clone();
tokio::task::spawn(async {
tokio::task::spawn(async move {
if let Err(e) = waterlevel::update(&db).await {
log::error!("Water level update error: {e}, trying again next time");
}
@@ -24,8 +25,9 @@ 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.clone();
let db_clone = db_for_hourly.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(|| {
@@ -40,6 +42,19 @@ 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

@@ -0,0 +1,158 @@
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(())
}