27 Commits

Author SHA1 Message Date
Philipp Hofer 79687807f2 clean /log by only showing boat reservation for the next 3 days
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
2026-04-30 11:39:54 +02:00
philipp 09defdc1f4 Merge pull request 'be able to delete nomembership user' (#1198) from delete-no-membership-user into main
CI/CD Pipeline / test (push) Failing after 34m43s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1198
2026-03-20 08:40:13 +01:00
Philipp Hofer 1beb6ebfe9 be able to delete nomembership user
CI/CD Pipeline / test (push) Failing after 32m30s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
2026-03-20 08:37:24 +01:00
philipp 55666a6eff Merge pull request 'be able to change membership status on non-membership users' (#1195) from fix-user into main
CI/CD Pipeline / test (push) Failing after 27m55s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1195
2026-03-16 10:04:05 +01:00
philipp 0e4c0573d9 be able to change membership status on non-membership users
CI/CD Pipeline / test (push) Failing after 31m12s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
2026-03-16 08:57:56 +01:00
philipp 6d36d01c2f Merge pull request 'user merging should only be done by admins' (#1183) from user-upd into main
CI/CD Pipeline / test (push) Successful in 33m21s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1183
2026-01-08 20:23:22 +01:00
philipp 2ed22d6440 user merging should only be done by admins
CI/CD Pipeline / test (push) Failing after 21m7s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
2026-01-08 20:22:17 +01:00
philipp 13de487b10 Merge pull request 'show all users on ranking board; be able to merge users' (#1181) from user-upd into main
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1181
2026-01-08 20:16:42 +01:00
philipp 3fcf24958b show all users on ranking board; be able to merge users
CI/CD Pipeline / test (push) Has started running
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2026-01-08 20:14:57 +01:00
philipp f8ea6d5aa5 Merge pull request 'new sort option' (#1179) from new-sort-option into main
CI/CD Pipeline / test (push) Successful in 19m56s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 7m34s
Reviewed-on: #1179
2026-01-05 13:09:24 +01:00
Philipp Hofer 9f9ec2f812 new sort option
CI/CD Pipeline / test (push) Successful in 19m41s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
2026-01-05 13:08:40 +01:00
philipp e5c9f30dd5 Merge pull request 'force an action on important notifications' (#1177) from force-action-on-important-notifications into main
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: #1177
2026-01-03 22:12:11 +01:00
philipp 0ccd59f8a7 force an action on important notifications
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
2026-01-03 22:11:11 +01:00
philipp fe0761a4c8 Merge pull request 'add manual deploy option' (#1175) from manual-deploy into main
CI/CD Pipeline / test (push) Successful in 17m25s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1175
2026-01-03 21:43:07 +01:00
philipp 7971cedf39 add manual deploy option
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
2026-01-03 21:42:22 +01:00
philipp 761e99ae8d Merge pull request 'be able to show total km of each rower' (#1173) from show-full-stats into main
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1173
2026-01-03 21:27:57 +01:00
philipp 2aa6def560 be able to show total km of each rower
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
2026-01-03 21:26:20 +01:00
philipp 48e1ee0d4c Merge pull request 'handle-deleted-boats' (#1170) from handle-deleted-boats into main
CI/CD Pipeline / test (push) Successful in 18m25s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 28m8s
Reviewed-on: #1170
2026-01-03 14:18:37 +01:00
philipp a891fb4803 No need to pay for deleted boats
CI/CD Pipeline / test (push) Successful in 36m37s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
2026-01-03 13:27:55 +01:00
philipp ec6c31848d Merge pull request 'yearly cleanup of roles; fixes #941' (#1161) from yeaerly-cleanup into main
CI/CD Pipeline / test (push) Failing after 26m29s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1161
2025-11-21 10:35:16 +01:00
philipp c92c5526c3 Merge pull request 'yeaerly-cleanup' (#1160) from yeaerly-cleanup into staging
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 1m27s
Reviewed-on: #1160
2025-11-21 10:35:07 +01:00
philipp 3148d744e6 yearly cleanup of roles; fixes #941
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
philipp 43d9dcc31a more-robust-ui-tests (#1158)
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
philipp 5c1d8876be more-robust-ui-tests (#1157)
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
philipp 24fe027f7b Merge pull request 'bank-name-mention' (#1156) from bank-name-mention into main
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
philipp 1add5c2a2a Merge pull request 'enable self-enrollment to ergo challenge' (#1148) from allow-ergo-entry into main
CI/CD Pipeline / test (push) Successful in 15m55s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 8m53s
Reviewed-on: #1148
2025-10-07 19:09:23 +02:00
philipp eec485dced Merge pull request 'allow ergo entry' (#1146) from allow-ergo-entry into main
CI/CD Pipeline / test (push) Failing after 2m28s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1146
2025-10-07 18:54:03 +02:00
24 changed files with 1494 additions and 266 deletions
+15
View File
@@ -0,0 +1,15 @@
#!/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'
+63 -117
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 }) => { test("cox can create and delete trip", async ({ page }) => {
await page.goto("/auth"); 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("spinbutton").fill("5");
await page.getByRole("button", { name: "Erstellen", exact: true }).click(); await page.getByRole("button", { name: "Erstellen", exact: true }).click();
await expect(page.locator("body")).toContainText("18:00 Uhr (cox) Details"); 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: group -> cox can create trips
// TODO: cox can help/register at trips/events // TODO: cox can help/register at trips/events
test.describe("cox can edit trips", () => { test.describe("cox can edit trips", () => {
let sharedPage: Page; async function createTrip(page: Page) {
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await page.goto("/auth"); await page.goto("/auth");
await page.getByPlaceholder("Name").click(); await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("cox"); 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.locator("#sidebar #planned_starting_time").press("Tab");
await page.getByRole("spinbutton").fill("5"); await page.getByRole("spinbutton").fill("5");
await page.getByRole("button", { name: "Erstellen", exact: true }).click(); await page.getByRole("button", { name: "Erstellen", exact: true }).click();
}
sharedPage = page; test("edit remarks", async ({ page }) => {
}); await createTrip(page);
test("edit remarks", async () => { await page.goto("/planned");
await sharedPage.goto("/planned"); await page.getByRole('link', { name: 'Details' }).nth(1).click();
await sharedPage.getByRole('link', { name: 'Details' }).nth(1).click(); await page.locator("#sidebar #notes").click();
await sharedPage.locator("#sidebar #notes").click(); await page.locator("#sidebar #notes").fill("Meine Anmerkung");
await sharedPage.locator("#sidebar #notes").fill("Meine Anmerkung"); await page.getByRole("button", { name: "Speichern" }).click();
await sharedPage.getByRole("button", { name: "Speichern" }).click(); await page.getByRole("link", { name: "Details" }).nth(1).click();
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); await expect(page.locator("#sidebar")).toContainText(
await expect(sharedPage.locator("#sidebar")).toContainText(
"Meine Anmerkung", "Meine Anmerkung",
); );
await sharedPage
.getByRole("button", { name: "Ausfahrt erstellen schließen" })
.click();
}); });
test("add and remove guest", async () => { test("add and remove guest", async ({ page }) => {
await sharedPage.goto("/planned"); await createTrip(page);
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await sharedPage.locator("#sidebar #user_note").click(); await page.goto("/planned");
await sharedPage.locator("#sidebar #user_note").fill("Mein Gast"); await page.getByRole("link", { name: "Details" }).nth(1).click();
await sharedPage.getByRole("button", { name: "Gast hinzufügen" }).click(); await page.locator("#sidebar #user_note").click();
await expect(sharedPage.locator("body")).toContainText( 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!", "Erfolgreich angemeldet!",
); );
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); await page.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText( await expect(page.locator("#sidebar")).toContainText(
"Freie Plätze: 4", "Freie Plätze: 4",
); );
await expect(sharedPage.locator("#sidebar")).toContainText( await expect(page.locator("#sidebar")).toContainText(
"Mein Gast (Gast) Abmelden", "Mein Gast (Gast) Abmelden",
); );
await expect( await expect(
sharedPage.getByRole("link", { name: "Termin löschen" }), page.getByRole("link", { name: "Termin löschen" }),
).not.toBeVisible(); ).not.toBeVisible();
await sharedPage.getByRole("link", { name: "Abmelden" }).click(); await page.getByRole("link", { name: "Abmelden" }).click();
await expect(sharedPage.locator("body")).toContainText( await expect(page.locator("body")).toContainText(
"Erfolgreich abgemeldet!", "Erfolgreich abgemeldet!",
); );
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); await page.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText( await expect(page.locator("#sidebar")).toContainText(
"Freie Plätze: 5", "Freie Plätze: 5",
); );
await expect(sharedPage.locator("#sidebar")).toContainText( await expect(page.locator("#sidebar")).toContainText(
"Keine Ruderer angemeldet", "Keine Ruderer angemeldet",
); );
await expect( await expect(
sharedPage.getByRole("link", { name: "Termin löschen" }), page.getByRole("link", { name: "Termin löschen" }),
).toBeVisible(); ).toBeVisible();
await sharedPage
.getByRole("button", { name: "Ausfahrt erstellen schließen" })
.click();
}); });
test("change amount rower", async () => { test("change amount rower", async ({ page }) => {
await sharedPage.goto("/planned"); await createTrip(page);
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 expect(page.locator("#sidebar")).toContainText(
"Freie Plätze: 5", "Freie Plätze: 5",
); );
await sharedPage.getByRole("spinbutton").click(); await page.getByRole("spinbutton").click();
await sharedPage.getByRole("spinbutton").fill("3"); await page.getByRole("spinbutton").fill("3");
await sharedPage.getByRole("button", { name: "Speichern" }).click(); await page.getByRole("button", { name: "Speichern" }).click();
await expect(sharedPage.locator("body")).toContainText( await expect(page.locator("body")).toContainText(
"Ausfahrt erfolgreich aktualisiert.", "Ausfahrt erfolgreich aktualisiert.",
); );
}); });
test("call off trip", async () => { test("call off trip", async ({ page }) => {
await createTrip(page);
// Someone registers... // Someone registers...
await sharedPage.goto("/auth/logout"); await page.goto("/auth/logout");
await sharedPage.goto("/auth"); await page.waitForURL("/auth");
await sharedPage.getByPlaceholder("Name").click(); await login(page, "rower", "rower");
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: 'Mitrudern' }).nth(1).click();
await page.goto("/planned");
await page.getByRole('link', { name: 'Mitrudern' }).nth(1).click();
// Login as cox again // Login as cox again
await sharedPage.goto("/auth/logout"); await page.goto("/auth/logout");
await sharedPage.goto("/auth"); await page.waitForURL("/auth");
await sharedPage.getByPlaceholder("Name").click(); await login(page, "cox", "cox");
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 sharedPage.goto("/planned"); await page.goto("/planned");
// Now cancel the trip
// ... now I can cancel trip await page.getByRole("link", { name: "Details" }).nth(1).click();
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); await page.getByRole("button", { name: "Ausfahrt absagen" }).click();
await sharedPage.getByRole("button", { name: "Ausfahrt absagen" }).click(); await expect(page.locator("body")).toContainText(
await expect(sharedPage.locator("body")).toContainText(
"Ausfahrt erfolgreich aktualisiert.", "Ausfahrt erfolgreich aktualisiert.",
); );
await expect(sharedPage.locator("body")).toContainText("(Absage cox)"); await expect(page.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 // TODO: 'Immer anzeigen' (also verify the functionality), 'Gesperrt' + type
+29
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")
]);
}
+5 -103
View File
@@ -1,4 +1,9 @@
import { test, expect } from "@playwright/test"; 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) => { test("Cox can start and cancel trip", async ({ page }, testInfo) => {
await page.goto("/auth"); await page.goto("/auth");
@@ -34,12 +39,6 @@ test("Cox can start and cancel trip", async ({ page }, testInfo) => {
"Ausfahrt erfolgreich hinzugefügt", "Ausfahrt erfolgreich hinzugefügt",
); );
await expect(page.locator("body")).toContainText("Joe"); 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) => { 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('(cox2)');
await expect(page.locator('body')).toContainText('Ottensheim (25 km)'); await expect(page.locator('body')).toContainText('Ottensheim (25 km)');
await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2'); 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) => { 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", "Ausfahrt erfolgreich hinzugefügt",
); );
await expect(page.locator("body")).toContainText("Joe"); 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) => { 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('Joe');
await expect(page.locator('body')).toContainText('Ottensheim (25 km)'); await expect(page.locator('body')).toContainText('Ottensheim (25 km)');
await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2'); 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) => { 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 page.goto('/log/show');
await expect(page.locator('body')).toContainText('cox_only_steering_boat'); await expect(page.locator('body')).toContainText('cox_only_steering_boat');
await expect(page.locator('body')).toContainText('Ottensheim (25 km)'); 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) => { 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('(cox2)');
await expect(page.locator('body')).toContainText('a (1 km)'); await expect(page.locator('body')).toContainText('a (1 km)');
await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2'); 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
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
+1 -1
View File
@@ -8,7 +8,7 @@ use rot::rest;
use rot::tera; use rot::tera;
use rot::{scheduled, tera::Config}; use rot::{scheduled, tera::Config};
use sqlx::{ConnectOptions, pool::PoolOptions, sqlite::SqliteConnectOptions}; use sqlx::{pool::PoolOptions, sqlite::SqliteConnectOptions, ConnectOptions};
#[macro_use] #[macro_use]
extern crate rocket; extern crate rocket;
+4 -4
View File
@@ -95,13 +95,13 @@ WHERE end_date >= ? AND start_date <= ?
res res
} }
pub async fn all_future(db: &SqlitePool) -> Vec<BoatReservationWithDetails> { pub async fn next_future(db: &SqlitePool) -> Vec<BoatReservationWithDetails> {
let boatreservations = sqlx::query_as!( let boatreservations = sqlx::query_as!(
Self, Self,
" "
SELECT id, boat_id, start_date, end_date, time_desc, usage, user_id_applicant, user_id_confirmation, created_at SELECT id, boat_id, start_date, end_date, time_desc, usage, user_id_applicant, user_id_confirmation, created_at
FROM boat_reservation FROM boat_reservation
WHERE end_date >= CURRENT_DATE ORDER BY end_date WHERE end_date >= CURRENT_DATE AND end_date <= date(CURRENT_DATE, '+3 days') ORDER BY end_date
" "
) )
.fetch_all(db) .fetch_all(db)
@@ -158,10 +158,10 @@ WHERE end_date >= CURRENT_DATE ORDER BY end_date
grouped_reservations grouped_reservations
} }
pub async fn all_future_with_groups( pub async fn next_future_with_groups(
db: &SqlitePool, db: &SqlitePool,
) -> HashMap<String, Vec<BoatReservationWithDetails>> { ) -> HashMap<String, Vec<BoatReservationWithDetails>> {
let reservations = Self::all_future(db).await; let reservations = Self::next_future(db).await;
Self::with_groups(reservations) Self::with_groups(reservations)
} }
+16
View File
@@ -26,6 +26,22 @@ impl Notification {
.await .await
.ok() .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( pub async fn create_with_tx(
db: &mut Transaction<'_, Sqlite>, db: &mut Transaction<'_, Sqlite>,
user: &User, user: &User,
+16 -24
View File
@@ -104,9 +104,11 @@ pub struct Stat {
impl Stat { impl Stat {
pub async fn guest(db: &SqlitePool, year: Option<i32>) -> Stat { pub async fn guest(db: &SqlitePool, year: Option<i32>) -> Stat {
let year = match year { let year = year.unwrap_or_else(|| chrono::Local::now().year());
Some(year) => year, let year_filter = if year == 0 {
None => chrono::Local::now().year(), String::new()
} else {
format!("AND l.arrival LIKE '{}-%'", year)
}; };
//TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server) //TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server)
// proper guests // proper guests
@@ -121,7 +123,7 @@ LEFT JOIN (
FROM rower FROM rower
GROUP BY logbook_id GROUP BY logbook_id
) m ON l.id = m.logbook_id ) m ON l.id = m.logbook_id
WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%' AND not b.external; WHERE l.distance_in_km IS NOT NULL {year_filter} AND not b.external;
" "
)) ))
.fetch_one(db) .fetch_one(db)
@@ -131,21 +133,16 @@ WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%' AND not b.exter
let guest_km: i32 = guests.get(0); let guest_km: i32 = guests.get(0);
let guest_amount_trips: i32 = guests.get(1); let guest_amount_trips: i32 = guests.get(1);
// e.g. scheckbücher // e.g. scheckbücher (users without any role)
let guest_user = sqlx::query(&format!( let guest_user = sqlx::query(&format!(
" "
SELECT CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km, COUNT(*) AS amount_trips SELECT CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km, COUNT(*) AS amount_trips
FROM user u FROM user u
INNER JOIN rower r ON u.id = r.rower_id INNER JOIN rower r ON u.id = r.rower_id
INNER JOIN logbook l ON r.logbook_id = l.id INNER JOIN logbook l ON r.logbook_id = l.id
WHERE u.id NOT IN ( WHERE u.id NOT IN (SELECT user_id FROM user_role)
SELECT ur.user_id
FROM user_role ur
INNER JOIN role ro ON ur.role_id = ro.id
WHERE ro.name = 'Donau Linz'
)
AND l.distance_in_km IS NOT NULL AND l.distance_in_km IS NOT NULL
AND l.arrival LIKE '{year}-%' {year_filter}
AND u.name != 'Externe Steuerperson'; AND u.name != 'Externe Steuerperson';
" "
)) ))
@@ -183,25 +180,20 @@ AND u.name != 'Externe Steuerperson';
} }
pub async fn people(db: &SqlitePool, year: Option<i32>) -> Vec<Stat> { pub async fn people(db: &SqlitePool, year: Option<i32>) -> Vec<Stat> {
let year = match year { let year = year.unwrap_or_else(|| chrono::Local::now().year());
Some(year) => year, let year_filter = if year == 0 {
None => chrono::Local::now().year(), String::new()
} else {
format!("AND l.arrival LIKE '{}-%'", year)
}; };
//TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server) //TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server)
sqlx::query(&format!( sqlx::query(&format!(
" "
SELECT u.name, CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km, COUNT(*) AS amount_trips SELECT u.name, CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km, COUNT(*) AS amount_trips
FROM ( FROM user u
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 rower r ON u.id = r.rower_id
INNER JOIN logbook l ON r.logbook_id = l.id INNER JOIN logbook l ON r.logbook_id = l.id
WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%' AND u.name != 'Externe Steuerperson' WHERE l.distance_in_km IS NOT NULL {year_filter} AND u.name != 'Externe Steuerperson'
GROUP BY u.name GROUP BY u.name
ORDER BY rowed_km DESC, u.name; ORDER BY rowed_km DESC, u.name;
" "
+2 -1
View File
@@ -14,6 +14,7 @@ pub(crate) enum Member {
Regular(User), Regular(User),
Foerdernd(User), Foerdernd(User),
Unterstuetzend(User), Unterstuetzend(User),
NoMembership(User),
} }
impl Member { impl Member {
@@ -31,7 +32,7 @@ impl Member {
} else if user.has_role(db, "Unterstützend").await { } else if user.has_role(db, "Unterstützend").await {
Self::Unterstuetzend(user) Self::Unterstuetzend(user)
} else { } else {
panic!("User {user} has no membership_type!!"); Self::NoMembership(user)
} }
} }
+490
View File
@@ -0,0 +1,490 @@
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(())
}
}
+14 -2
View File
@@ -33,6 +33,8 @@ pub(crate) mod clubmember;
mod fee; mod fee;
pub(crate) mod foerdernd; pub(crate) mod foerdernd;
pub(crate) mod member; pub(crate) mod member;
pub mod merge;
pub(crate) mod nomembership;
pub(crate) mod regular; pub(crate) mod regular;
pub(crate) mod scheckbuch; pub(crate) mod scheckbuch;
pub(crate) mod schnupperant; pub(crate) mod schnupperant;
@@ -88,17 +90,20 @@ pub struct UserWithDetails {
pub allowed_to_steer: bool, pub allowed_to_steer: bool,
pub on_water: bool, pub on_water: bool,
pub roles: Vec<String>, pub roles: Vec<String>,
pub action_notification: Option<Notification>,
} }
impl UserWithDetails { impl UserWithDetails {
pub async fn from_user(user: User, db: &SqlitePool) -> Self { pub async fn from_user(user: User, db: &SqlitePool) -> Self {
let allowed_to_steer = user.allowed_to_steer(db).await; let allowed_to_steer = user.allowed_to_steer(db).await;
let action_notification = Notification::oldest_unread_with_action(db, user.id).await;
Self { Self {
on_water: user.on_water(db).await, on_water: user.on_water(db).await,
roles: user.roles(db).await, roles: user.roles(db).await,
amount_unread_notifications: user.amount_unread_notifications(db).await, amount_unread_notifications: user.amount_unread_notifications(db).await,
allowed_to_steer, allowed_to_steer,
action_notification,
user, user,
} }
} }
@@ -136,7 +141,7 @@ impl User {
pub async fn amount_boats(&self, db: &SqlitePool) -> i64 { pub async fn amount_boats(&self, db: &SqlitePool) -> i64 {
sqlx::query!( sqlx::query!(
"SELECT COUNT(*) as count FROM boat WHERE owner = ?", "SELECT COUNT(*) as count FROM boat WHERE owner = ? and deleted = 0",
self.id self.id
) )
.fetch_one(db) .fetch_one(db)
@@ -358,6 +363,13 @@ WHERE lower(name)=lower(?)
} }
pub async fn all_with_order(db: &SqlitePool, sort: &str, asc: bool) -> Vec<Self> { 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!( 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 SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, phone, address, family_id, user_token
@@ -365,7 +377,7 @@ WHERE lower(name)=lower(?)
WHERE deleted = 0 WHERE deleted = 0
ORDER BY {} ORDER BY {}
", ",
sort sort_column
); );
if !asc { if !asc {
query.push_str(" DESC"); query.push_str(" DESC");
+233
View File
@@ -0,0 +1,233 @@
use super::foerdernd::FoerderndUser;
use super::regular::RegularUser;
use super::scheckbuch::ScheckbuchUser;
use super::unterstuetzend::UnterstuetzendUser;
use super::{ManageUserUser, User};
use crate::NonEmptyString;
use crate::model::activity::ActivityBuilder;
use crate::model::role::Role;
use crate::model::notification::Notification;
use chrono::NaiveDate;
use rocket::fs::TempFile;
use sqlx::SqlitePool;
use std::fmt::Display;
use std::ops::Deref;
pub(crate) struct NoMembershipUser {
pub(crate) user: User,
}
impl Deref for NoMembershipUser {
type Target = User;
fn deref(&self) -> &Self::Target {
&self.user
}
}
impl Display for NoMembershipUser {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.user.name)
}
}
impl NoMembershipUser {
pub(crate) async fn new(db: &SqlitePool, user: &User) -> Option<Self> {
if ScheckbuchUser::new(db, user).await.is_some() {
return None;
}
if user.has_role(db, "schnupper-interessierte").await {
return None;
}
if user.has_role(db, "schnupperant").await {
return None;
}
if user.has_role(db, "Donau Linz").await {
return None;
}
if user.has_role(db, "Förderndes Mitglied").await {
return None;
}
if user.has_role(db, "Unterstützend").await {
return None;
}
Some(Self { user: user.clone() })
}
async fn set_data_for_clubmember(
&self,
db: &SqlitePool,
changed_by: &ManageUserUser,
member_since: &NaiveDate,
birthdate: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
self.user.update_birthdate(db, changed_by, birthdate).await;
self.user
.update_member_since(db, changed_by, member_since)
.await;
self.user.update_phone(db, changed_by, &phone).await;
self.user.update_address(db, changed_by, &address).await;
self.user
.add_membership_pdf(db, changed_by, membership_pdf)
.await?;
Ok(())
}
pub(crate) async fn convert_to_regular_user(
self,
db: &SqlitePool,
smtp_pw: &str,
changed_by: &ManageUserUser,
member_since: &NaiveDate,
birthdate: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
self.set_data_for_clubmember(
db,
changed_by,
member_since,
birthdate,
phone,
address,
membership_pdf,
)
.await?;
let regular = Role::find_by_name(db, "Donau Linz").await.unwrap();
self.user.add_role(db, changed_by, &regular).await?;
let regular = RegularUser::new(db, &self.user).await.unwrap();
regular.send_welcome_mail_to_user(db, smtp_pw).await?;
Notification::create_for_steering_people(
db,
&format!(
"Liebe Steuerberechtigte, {} hatte keinen Mitgliedsstatus und ist nun seit {} ein neues reguläres Mitglied. 🎉",
self.name,
member_since
),
"Neues Vereinsmitglied",
None,
None,
)
.await;
ActivityBuilder::new(&format!(
"{changed_by} hat den User ohne Mitgliedsstatus {self} auf ein reguläres Mitglied upgegraded! Die Steuerpersonen wurden via Notification informiert."
))
.user(&self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn convert_to_unterstuetzend_user(
self,
db: &SqlitePool,
smtp_pw: &str,
changed_by: &ManageUserUser,
member_since: &NaiveDate,
birthdate: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
self.set_data_for_clubmember(
db,
changed_by,
member_since,
birthdate,
phone,
address,
membership_pdf,
)
.await?;
let unterstuetzend = Role::find_by_name(db, "Unterstützend").await.unwrap();
self.user.add_role(db, changed_by, &unterstuetzend).await?;
let unterstuetzend = UnterstuetzendUser::new(db, &self.user).await.unwrap();
unterstuetzend
.send_welcome_mail_to_user(db, smtp_pw)
.await?;
if let Some(vorstand) = Role::find_by_name(db, "vorstand").await {
Notification::create_for_role(
db,
&vorstand,
&format!(
"Lieber Vorstand, {} hatte keinen Mitgliedsstatus und ist nun seit {} ein neues unterstützendes Mitglied.",
self.name,
member_since
),
"Neues unterstützendes Vereinsmitglied",
None,
None,
)
.await;
}
ActivityBuilder::new(&format!(
"{changed_by} hat den User ohne Mitgliedsstatus {self} auf ein unterstützendes Mitglied upgegraded!"
))
.user(&self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn convert_to_foerdernd_user(
self,
db: &SqlitePool,
smtp_pw: &str,
changed_by: &ManageUserUser,
member_since: &NaiveDate,
birthdate: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
self.set_data_for_clubmember(
db,
changed_by,
member_since,
birthdate,
phone,
address,
membership_pdf,
)
.await?;
let foerdernd = Role::find_by_name(db, "Förderndes Mitglied").await.unwrap();
self.user.add_role(db, changed_by, &foerdernd).await?;
let foerdernd = FoerderndUser::new(db, &self.user).await.unwrap();
foerdernd.send_welcome_mail_to_user(db, smtp_pw).await?;
if let Some(vorstand) = Role::find_by_name(db, "vorstand").await {
Notification::create_for_role(
db,
&vorstand,
&format!(
"Lieber Vorstand, {} hatte keinen Mitgliedsstatus und ist nun seit {} ein neues förderndes Mitglied.",
self.name,
member_since
),
"Neues förderndes Vereinsmitglied",
None,
None,
)
.await;
}
ActivityBuilder::new(&format!(
"{changed_by} hat den User ohne Mitgliedsstatus {self} auf ein förderndes Mitglied upgegraded!"
))
.user(&self)
.save(db)
.await;
Ok(())
}
}
+17 -2
View File
@@ -1,5 +1,6 @@
mod waterlevel; mod waterlevel;
mod weather; mod weather;
mod yearly_role_cleanup;
use std::time::Duration; use std::time::Duration;
@@ -13,7 +14,7 @@ pub fn schedule(db: &SqlitePool, config: &Config) {
let db = db.clone(); let db = db.clone();
let openweathermap_key = config.openweathermap_key.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 { if let Err(e) = waterlevel::update(&db).await {
log::error!("Water level update error: {e}, trying again next time"); 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(); let mut sched = JobScheduler::new();
// Every hour // Every hour
let db_for_hourly = db.clone();
sched.add(Job::new("0 0 * * * * *".parse().unwrap(), move || { 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 // Use block_in_place to run async code in the synchronous function; TODO: Make it
// nicer one's rust (stable) support async closures // nicer one's rust (stable) support async closures
task::block_in_place(|| { 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)); let mut interval = time::interval(Duration::from_secs(60));
loop { loop {
sched.tick(); sched.tick();
+158
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(())
}
+210 -2
View File
@@ -8,8 +8,9 @@ use crate::{
role::Role, role::Role,
user::{ user::{
clubmember::ClubMemberUser, foerdernd::FoerderndUser, member::Member, clubmember::ClubMemberUser, foerdernd::FoerderndUser, member::Member,
regular::RegularUser, scheckbuch::ScheckbuchUser, schnupperant::SchnupperantUser, nomembership::NoMembershipUser, regular::RegularUser, scheckbuch::ScheckbuchUser,
schnupperinterest::SchnupperInterestUser, unterstuetzend::UnterstuetzendUser, schnupperant::SchnupperantUser, schnupperinterest::SchnupperInterestUser,
unterstuetzend::UnterstuetzendUser,
AdminUser, AllowedToEditPaymentStatusUser, ManageUserUser, User, UserWithDetails, AdminUser, AllowedToEditPaymentStatusUser, ManageUserUser, User, UserWithDetails,
UserWithMembershipPdf, UserWithRolesAndMembershipPdf, VorstandUser, UserWithMembershipPdf, UserWithRolesAndMembershipPdf, VorstandUser,
}, },
@@ -64,6 +65,7 @@ async fn index(
let user: User = user.into_inner(); let user: User = user.into_inner();
let allowed_to_edit = ManageUserUser::new(db, &user).await.is_some(); 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 users: Vec<UserWithRolesAndMembershipPdf> = join_all(user_futures).await;
let financial = Role::all_cluster(db, "financial").await; let financial = Role::all_cluster(db, "financial").await;
@@ -76,6 +78,7 @@ async fn index(
context.insert("flash", &msg.into_inner()); context.insert("flash", &msg.into_inner());
} }
context.insert("allowed_to_edit", &allowed_to_edit); context.insert("allowed_to_edit", &allowed_to_edit);
context.insert("is_admin", &is_admin);
context.insert("users", &users); context.insert("users", &users);
context.insert("roles", &roles); context.insert("roles", &roles);
context.insert("financial", &financial); context.insert("financial", &financial);
@@ -110,6 +113,7 @@ async fn index_admin(
context.insert("flash", &msg.into_inner()); context.insert("flash", &msg.into_inner());
} }
context.insert("allowed_to_edit", &allowed_to_edit); context.insert("allowed_to_edit", &allowed_to_edit);
context.insert("is_admin", &true);
context.insert("users", &users); context.insert("users", &users);
context.insert("roles", &roles); context.insert("roles", &roles);
context.insert("financial", &financial); context.insert("financial", &financial);
@@ -306,6 +310,97 @@ 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)] #[derive(FromForm, Debug)]
pub struct MailUpdateForm { pub struct MailUpdateForm {
mail: String, mail: String,
@@ -1047,6 +1142,115 @@ async fn scheckbook_to_regular(
} }
} }
#[post("/user/<id>/nomembership-to-regular", data = "<data>")]
async fn nomembership_to_regular(
db: &State<SqlitePool>,
data: Form<ScheckToRegularForm<'_>>,
admin: ManageUserUser,
config: &State<Config>,
id: i32,
) -> Flash<Redirect> {
let Some(user) = User::find_by_id(db, id).await else {
return Flash::error(
Redirect::to("/admin/user"),
format!("User with ID {} does not exist!", id),
);
};
let Ok(birthdate) = NaiveDate::parse_from_str(&data.birthdate, "%Y-%m-%d") else {
return Flash::error(
Redirect::to(format!("/admin/user/{id}")),
format!(
"Geburtsdatum {} ist nicht im YYYY-MM-DD Format",
&data.birthdate
),
);
};
let Ok(member_since) = NaiveDate::parse_from_str(&data.member_since, "%Y-%m-%d") else {
return Flash::error(
Redirect::to(format!("/admin/user/{id}")),
format!(
"Beitrittsdatum {} ist nicht im YYYY-MM-DD Format",
&data.birthdate
),
);
};
let Some(user) = NoMembershipUser::new(db, &user).await else {
return Flash::error(
Redirect::to(format!("/admin/user/{id}")),
"User hat keinen fehlenden Mitgliedsstatus",
);
};
let Ok(phone) = data.phone.clone().try_into() else {
return Flash::error(
Redirect::to(format!("/admin/user/{id}")),
"Vereinsmitglied braucht eine Telefonnummer",
);
};
let Ok(address) = data.address.clone().try_into() else {
return Flash::error(
Redirect::to(format!("/admin/user/{id}")),
"Vereinsmitglied braucht eine Adresse",
);
};
let response = match &*data.membertype {
"regular" => {
user.convert_to_regular_user(
db,
&config.smtp_pw,
&admin,
&member_since,
&birthdate,
phone,
address,
&data.membership_pdf,
)
.await
}
"unterstuetzend" => {
user.convert_to_unterstuetzend_user(
db,
&config.smtp_pw,
&admin,
&member_since,
&birthdate,
phone,
address,
&data.membership_pdf,
)
.await
}
"foerdernd" => {
user.convert_to_foerdernd_user(
db,
&config.smtp_pw,
&admin,
&member_since,
&birthdate,
phone,
address,
&data.membership_pdf,
)
.await
}
_ => {
return Flash::error(
Redirect::to(format!("/admin/user/{id}")),
"Membertype gibts ned",
);
}
};
match response {
Ok(_) => Flash::success(
Redirect::to(format!("/admin/user/{}", id)),
"Mitgliedstyp umgewandelt und Infos versendet",
),
Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", id)), e),
}
}
#[derive(FromForm, Debug)] #[derive(FromForm, Debug)]
pub struct ChangeMembertypeForm { pub struct ChangeMembertypeForm {
membertype: String, membertype: String,
@@ -1437,6 +1641,9 @@ pub fn routes() -> Vec<Route> {
view, view,
resetpw, resetpw,
delete, delete,
// Merge
merge_page,
merge_execute,
fees, fees,
fees_paid, fees_paid,
scheckbuch, scheckbuch,
@@ -1457,6 +1664,7 @@ pub fn routes() -> Vec<Route> {
remove_role, remove_role,
// Moves // Moves
scheckbook_to_regular, scheckbook_to_regular,
nomembership_to_regular,
schnupperant_to_regular, schnupperant_to_regular,
schnupperant_to_scheckbook, schnupperant_to_scheckbook,
schnupperinterest_to_schnupperant, schnupperinterest_to_schnupperant,
+3 -4
View File
@@ -1,11 +1,10 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use rocket::{ use rocket::{
FromForm, Route, State,
form::Form, form::Form,
get, post, get, post,
request::FlashMessage, request::FlashMessage,
response::{Flash, Redirect}, response::{Flash, Redirect},
routes, routes, FromForm, Route, State,
}; };
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
use sqlx::SqlitePool; use sqlx::SqlitePool;
@@ -27,7 +26,7 @@ async fn index_kiosk(
flash: Option<FlashMessage<'_>>, flash: Option<FlashMessage<'_>>,
_kiosk: KioskCookie, _kiosk: KioskCookie,
) -> Template { ) -> Template {
let boatreservations = BoatReservation::all_future(db).await; let boatreservations = BoatReservation::next_future(db).await;
let mut context = Context::new(); let mut context = Context::new();
if let Some(msg) = flash { if let Some(msg) = flash {
@@ -56,7 +55,7 @@ async fn index(
flash: Option<FlashMessage<'_>>, flash: Option<FlashMessage<'_>>,
user: DonauLinzUser, user: DonauLinzUser,
) -> Template { ) -> Template {
let boatreservations = BoatReservation::all_future(db).await; let boatreservations = BoatReservation::next_future(db).await;
let mut context = Context::new(); let mut context = Context::new();
if let Some(msg) = flash { if let Some(msg) = flash {
+1 -1
View File
@@ -114,7 +114,7 @@ async fn index(db: &SqlitePool, flash: Option<FlashMessage<'_>>, mut context: Co
context.insert("planned_trips", &Trip::get_for_today(db).await); context.insert("planned_trips", &Trip::get_for_today(db).await);
context.insert( context.insert(
"reservations", "reservations",
&BoatReservation::all_future_with_groups(db).await, &BoatReservation::next_future_with_groups(db).await,
); );
context.insert("coxes", &coxes); context.insert("coxes", &coxes);
context.insert("users", &users); context.insert("users", &users);
+13
View File
@@ -4,6 +4,11 @@
<div class="max-w-screen-lg w-full"> <div class="max-w-screen-lg w-full">
<h1 class="h1">Users</h1> <h1 class="h1">Users</h1>
{% if allowed_to_edit %} {% 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"> <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"> <summary class="px-3 cursor-pointer text-md font-bold text-primary-950 dark:text-white">
Neue Person hinzufügen Neue Person hinzufügen
@@ -163,6 +168,14 @@
<a href="?sort=name" <a href="?sort=name"
class="block px-4 py-2 hover:bg-gray-100 hover:text-secondary-950">Name Z-A</a> class="block px-4 py-2 hover:bg-gray-100 hover:text-secondary-950">Name Z-A</a>
</li> </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> </ul>
</div> </div>
</div> </div>
+141
View File
@@ -0,0 +1,141 @@
{% 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 %}
+16 -1
View File
@@ -73,6 +73,8 @@
Förderndes Vereinsmitglied Förderndes Vereinsmitglied
{% elif "Unterstuetzend" in member %} {% elif "Unterstuetzend" in member %}
Unterstützendes Vereinsmitglied Unterstützendes Vereinsmitglied
{% elif "NoMembership" in member %}
⚠️ Kein Mitgliedsstatus!
{% endif %} {% endif %}
</small> </small>
</h2> </h2>
@@ -228,8 +230,19 @@
</a> </a>
</div> </div>
{% endif %} {% endif %}
{% elif "NoMembership" in member %}
{% if allowed_to_edit %}
<div class="grid pt-3">
<a href="/admin/user/{{ user.id }}/delete"
class="btn btn-alert"
onclick="return confirm('Willst du die Daten von {{ user.name }} wirklich löschen?');">
{% include "includes/delete-icon" %}
Daten löschen
</a>
</div>
{% endif %}
{% endif %} {% endif %}
{% if "Scheckbuch" in member or "Schnupperant" in member %} {% if "Scheckbuch" in member or "Schnupperant" in member or "NoMembership" in member %}
{% if allowed_to_edit %} {% if allowed_to_edit %}
<div class="grid gap-3 pb-3 mt-3"> <div class="grid gap-3 pb-3 mt-3">
<button type="button" <button type="button"
@@ -257,6 +270,8 @@
{% set action = "scheckbook-to-regular" %} {% set action = "scheckbook-to-regular" %}
{% elif "Schnupperant" in member %} {% elif "Schnupperant" in member %}
{% set action = "schnupperant-to-regular" %} {% set action = "schnupperant-to-regular" %}
{% elif "NoMembership" in member %}
{% set action = "nomembership-to-regular" %}
{% endif %} {% endif %}
<form action="/admin/user/{{ user.id }}/{{ action }}" <form action="/admin/user/{{ user.id }}/{{ action }}"
method="post" method="post"
+15
View File
@@ -53,6 +53,21 @@
{% include "includes/footer" %} {% include "includes/footer" %}
{% endif %} {% endif %}
{% include "dynamics/sidebar" %} {% 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> <script src="/public/main.js"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -40,7 +40,7 @@ function setChoiceByLabel(choicesInstance, label) {
{% endmacro plannedtrips %} {% endmacro plannedtrips %}
{% macro boatreservation() %} {% macro boatreservation() %}
<div class="bg-white dark:bg-primary-900 rounded-md shadow pb-2 mt-3"> <div class="bg-white dark:bg-primary-900 rounded-md shadow pb-2 mt-3">
<h2 class="h2">Reservierungen ({{ reservations | length }})</h2> <h2 class="h2">Reservierungen<br /><small>in den nächsten 3 Tagen</small></h2>
<div class="grid grid-cols-1 gap-3 mb-3 w-full"> <div class="grid grid-cols-1 gap-3 mb-3 w-full">
{% for _, reservations_for_event in reservations %} {% for _, reservations_for_event in reservations %}
{% set reservation = reservations_for_event[0] %} {% set reservation = reservations_for_event[0] %}
+9
View File
@@ -83,6 +83,7 @@
var select = document.getElementById('yearSelect'); var select = document.getElementById('yearSelect');
var currentYear = new Date().getFullYear(); var currentYear = new Date().getFullYear();
var selectedYear = getYearFromURL() || currentYear; var selectedYear = getYearFromURL() || currentYear;
for (var year = 1977; year <= currentYear; year++) { for (var year = 1977; year <= currentYear; year++) {
var option = document.createElement('option'); var option = document.createElement('option');
option.value = option.textContent = year; option.value = option.textContent = year;
@@ -91,6 +92,14 @@
} }
select.appendChild(option); 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() { function changeYear() {