Compare commits

..

69 Commits

Author SHA1 Message Date
63bf1015cc Merge pull request 'Update frontend/tests/cox.spec.ts' (#852) from fix-new-npm into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#852
2025-01-10 14:34:34 +01:00
352dad8e6c Update frontend/tests/cox.spec.ts 2025-01-10 14:16:05 +01:00
c6aa25fe0e Merge pull request 'use new rust in ci' (#850) from update-rust into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#850
2025-01-10 12:47:23 +01:00
9ba848cbab use new rust in ci 2025-01-10 12:46:43 +01:00
9047459d6c Merge pull request 'vorstand-show-old-logs' (#849) from vorstand-show-old-logs into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#849
2025-01-10 10:23:30 +01:00
b8aaf5ba2e allow vorstand to see all old logs 2025-01-10 09:51:43 +01:00
de9ea9405e Merge pull request 'update-deps' (#847) from update-deps into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#847
2025-01-09 17:23:53 +01:00
3bd229554b Merge pull request 'update-deps' (#846) from update-deps into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#846
2025-01-09 17:04:02 +01:00
f9c9f7c523 update to sqlx 0.8 2025-01-09 16:31:53 +01:00
0dfceec737 update deps 2025-01-09 16:22:08 +01:00
e5fec411f3 Merge pull request 'notfiication-on-new-personal-stat' (#843) from notfiication-on-new-personal-stat into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#843
2025-01-09 16:19:37 +01:00
ac67c6cfdb Merge pull request 'ped clippy' (#845) from notfiication-on-new-personal-stat into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#845
2025-01-09 15:36:34 +01:00
a90c4fc07e ped clippy 2025-01-09 15:35:57 +01:00
52b960cec7 Merge pull request 'cargo clippy' (#844) from notfiication-on-new-personal-stat into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#844
2025-01-09 15:32:26 +01:00
f7d109f1b2 cargo clippy 2025-01-09 15:31:05 +01:00
63505722f9 Merge pull request 'notfiication-on-new-personal-stat' (#842) from notfiication-on-new-personal-stat into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#842
2025-01-09 11:58:10 +01:00
d21272d4bb send notifiation to user + vorstand if user completes 'äquatorpreis' or 'fahrtenabzeichen'; Fixes #746 2025-01-09 11:45:24 +01:00
97dd7794fb split to separate fee file 2025-01-09 10:37:15 +01:00
cfe99c2f2a Merge pull request 'add confirm dialog before creating a new user' (#841) from confirm-user-creation into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#841
2025-01-09 10:22:58 +01:00
2a3f846c5c Merge pull request 'confirm-user-creation' (#840) from confirm-user-creation into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#840
2025-01-09 10:22:56 +01:00
af4163a065 add confirm dialog before creating a new user 2025-01-09 10:21:44 +01:00
8a9047b3c3 Merge pull request 'reservation-styling' (#839) from reservation-styling into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#839
2025-01-08 14:50:27 +01:00
ebc7c32351 Merge pull request 'reservation-styling' (#838) from reservation-styling into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#838
2025-01-08 14:50:20 +01:00
1a850535ed switch from date to time icon + add 'Reservierung' 2025-01-08 14:46:11 +01:00
99bbb2b088 Merge pull request 'stats' (#836) from stats into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#836
2025-01-07 14:51:16 +01:00
b31209a97a Merge pull request '[TASK] make stats more beautiful' (#837) from stats into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#837
2025-01-07 14:27:59 +01:00
Marie Birner
be4f302a4c [TASK] make stats more beautiful 2025-01-07 14:07:52 +01:00
e5c2bec145 Merge pull request 'stats' (#835) from stats into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#835
2025-01-07 12:58:37 +01:00
0ebcd5a284 allow changing the year in stats again 2025-01-07 11:44:56 +01:00
6237340f72 fix ci 2025-01-07 11:39:36 +01:00
Marie Birner
5b013fe389 [TASK] rm unnecessary personal stat 2025-01-07 10:54:15 +01:00
Marie Birner
022ec6bd5b [TASK] make stats more beautiful 2025-01-07 10:52:46 +01:00
09d4c0abe4 Merge pull request 'show amount of trips in stat' (#834) from show-amount-trips into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#834
2025-01-06 13:15:05 +01:00
5448558085 Merge pull request 'show-amount-trips' (#833) from show-amount-trips into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#833
2025-01-06 13:14:55 +01:00
3232a03d75 show amount of trips in stat 2025-01-06 13:14:19 +01:00
dceb57e370 Merge pull request 'fix count in statistic' (#832) from fix-count into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#832
2025-01-04 10:57:34 +01:00
f68928df00 Merge pull request 'fix-count' (#831) from fix-count into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#831
2025-01-04 10:57:05 +01:00
d3bb050534 fix count in statistic 2025-01-04 10:56:32 +01:00
32b4131aae Merge pull request 'nicer mail text' (#830) from nicer-mail-text into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#830
2025-01-03 12:38:28 +01:00
1d34cb5794 Merge pull request 'nicer-mail-text' (#829) from nicer-mail-text into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#829
2025-01-03 12:38:09 +01:00
8a4d98a90f nicer mail text 2025-01-03 12:36:29 +01:00
Marie Birner
213e9faad4 [TASK] idea reservation styling in planned events view 2025-01-02 11:22:41 +01:00
a9a8207813 Merge pull request 'show boatreservations in planned' (#828) from show-boatreservations-in-planned into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#828
2025-01-01 19:30:58 +01:00
b7b2385264 Merge pull request 'Merge pull request 'fix no 'donau linz' group' (#825) from fix-no-group into main' (#826) from show-boatreservations-in-planned into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#826
2025-01-01 19:29:58 +01:00
b560233acf show boatreservations in planned 2025-01-01 19:05:20 +01:00
d7187a7589 Merge pull request 'fix no 'donau linz' group' (#825) from fix-no-group into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#825
2025-01-01 17:46:26 +01:00
e61b16c389 Merge pull request 'fix-no-group' (#824) from fix-no-group into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#824
2025-01-01 17:45:52 +01:00
2ac8a3155c fix no 'donau linz' group 2025-01-01 17:44:48 +01:00
d01e6ea30b Merge pull request 'allow lazy people to mark all notifcations as read' (#822) from mark-all-notifications-read into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#822
2024-12-19 21:16:40 +01:00
f38ca09eb7 Merge pull request 'allow lazy people to mark all notifcations as read' (#823) from mark-all-notifications-read into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#823
2024-12-19 21:16:31 +01:00
1ad4c31979 allow lazy people to mark all notifcations as read 2024-12-19 21:15:27 +01:00
5e413d2d72 Merge pull request 'add-renntrainer' (#820) from add-renntrainer into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#820
2024-12-17 09:14:18 +01:00
0f8e1158b9 Merge pull request 'add renntrainer role' (#821) from add-renntrainer into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#821
2024-12-17 08:57:29 +01:00
af10399797 add renntrainer role 2024-12-17 08:56:48 +01:00
6344ba720d Merge pull request 'fix-mobile-link' (#818) from fix-mobile-link into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#818
2024-12-06 18:28:49 +01:00
4b1dceb08a Merge pull request 'format' (#816) from format into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#816
2024-12-05 23:40:07 +01:00
cb819c16a3 Merge pull request 'links; Fixes #755' (#815) from links into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#815
2024-12-05 11:16:34 +01:00
08a48cb4d2 Merge pull request 'update ci' (#812) from update-ci into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#812
2024-12-05 10:23:43 +01:00
9c36da32bd Merge pull request 'demo' (#810) from demo into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#810
2024-11-30 22:33:12 +01:00
77444d25ae Merge pull request 'fix' (#808) from fix into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#808
2024-11-27 08:21:48 +01:00
a683af00d0 Merge pull request 'new-link' (#805) from new-link into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#805
2024-11-27 08:14:00 +01:00
766886d857 Merge pull request 'nicer-label' (#803) from nicer-label into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#803
2024-11-25 21:01:38 +01:00
38703321e8 Merge pull request 'ergo-trips' (#801) from ergo-trips into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#801
2024-11-25 12:31:50 +01:00
ec1c717341 Merge pull request 'allow for smaller m' (#799) from trim-ergo into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#799
2024-11-11 23:12:23 +01:00
22bb79bfbd Merge pull request 'allow m in dd' (#797) from trim-ergo into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#797
2024-11-11 23:07:28 +01:00
eba4b77983 Merge pull request 'trim-ergo' (#795) from trim-ergo into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#795
2024-11-11 23:00:08 +01:00
83d266b3e0 Merge pull request 'update data' (#793) from formating-ergo into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#793
2024-11-11 18:03:43 +01:00
980bcff1d9 Merge pull request 'fix tests' (#791) from formating-ergo into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#791
2024-11-11 15:27:15 +01:00
c15ed6e9a9 Merge pull request 'formating-ergo' (#789) from formating-ergo into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#789
2024-11-11 13:34:31 +01:00
95 changed files with 13183 additions and 2170 deletions

View File

@ -35,6 +35,49 @@ jobs:
# path: frontend/playwright-report/
# retention-days: 30
deploy-staging:
runs-on: ubuntu-latest
container: git.hofer.link/philipp/ci-images:rust-latest
needs: [test]
if: github.ref == 'refs/heads/staging'
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Run Test DB Script
run: ./test_db.sh
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
- name: Build
run: |
cargo build --release --target $CARGO_TARGET
strip target/$CARGO_TARGET/release/rot
cd frontend && npm install && npm run build
- name: Deploy to Staging
run: |
mkdir -p ~/.ssh
ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
scp -C target/$CARGO_TARGET/release/rot $SSH_USER@$SSH_HOST:/home/rowing-staging/rot-updating
scp -C staging-diff.sql $SSH_USER@$SSH_HOST:/home/rowing-staging/
scp -C -r static $SSH_USER@$SSH_HOST:/home/rowing-staging/
scp -C -r templates $SSH_USER@$SSH_HOST:/home/rowing-staging/
scp -C -r svelte $SSH_USER@$SSH_HOST:/home/rowing-staging/
ssh $SSH_USER@$SSH_HOST 'sudo systemctl stop rotstaging'
ssh $SSH_USER@$SSH_HOST 'rm /home/rowing-staging/db.sqlite && cp /home/rowing/db.sqlite /home/rowing-staging/db.sqlite && mkdir -p /home/rowing-staging/svelte/build && mkdir -p /home/rowing-staging/data-ergo/thirty && mkdir -p /home/rowing-staging/data-ergo/dozen && sqlite3 /home/rowing-staging/db.sqlite < /home/rowing-staging/staging-diff.sql'
ssh $SSH_USER@$SSH_HOST 'mv /home/rowing-staging/rot-updating /home/rowing-staging/rot'
ssh $SSH_USER@$SSH_HOST 'sudo systemctl start rotstaging'
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
deploy-main:
runs-on: ubuntu-latest
container: git.hofer.link/philipp/ci-images:rust-latest
@ -56,61 +99,21 @@ jobs:
strip target/$CARGO_TARGET/release/rot
cd frontend && npm install && npm run build
- name: Deploy Normannen
- name: Deploy to production
run: |
mkdir -p ~/.ssh
ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
scp -C target/$CARGO_TARGET/release/rot $SSH_USER@$SSH_HOST:/home/normannen/rot-updating
scp -C -r static $SSH_USER@$SSH_HOST:/home/normannen/
scp -C -r templates $SSH_USER@$SSH_HOST:/home/normannen/
scp -C -r svelte $SSH_USER@$SSH_HOST:/home/normannen/
ssh $SSH_USER@$SSH_HOST 'mkdir -p /home/normannen/svelte/build'
ssh $SSH_USER@$SSH_HOST 'sudo systemctl stop normannen'
ssh $SSH_USER@$SSH_HOST 'mv /home/normannen/rot-updating /home/normannen/rot'
ssh $SSH_USER@$SSH_HOST 'sudo systemctl start normannen'
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
- name: Deploy Ister
run: |
mkdir -p ~/.ssh
ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
scp -C target/$CARGO_TARGET/release/rot $SSH_USER@$SSH_HOST:/home/ister/rot-updating
scp -C -r static $SSH_USER@$SSH_HOST:/home/ister/
scp -C -r templates $SSH_USER@$SSH_HOST:/home/ister/
scp -C -r svelte $SSH_USER@$SSH_HOST:/home/ister/
ssh $SSH_USER@$SSH_HOST 'mkdir -p /home/ister/svelte/build'
ssh $SSH_USER@$SSH_HOST 'sudo systemctl stop ister'
ssh $SSH_USER@$SSH_HOST 'mv /home/ister/rot-updating /home/ister/rot'
ssh $SSH_USER@$SSH_HOST 'sudo systemctl start ister'
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
- name: Deploy Kufstein
run: |
mkdir -p ~/.ssh
ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
scp -C target/$CARGO_TARGET/release/rot $SSH_USER@$SSH_HOST:/home/kufstein/rot-updating
scp -C -r static $SSH_USER@$SSH_HOST:/home/kufstein/
scp -C -r templates $SSH_USER@$SSH_HOST:/home/kufstein/
scp -C -r svelte $SSH_USER@$SSH_HOST:/home/kufstein/
ssh $SSH_USER@$SSH_HOST 'mkdir -p /home/kufstein/svelte/build'
ssh $SSH_USER@$SSH_HOST 'sudo systemctl stop kufstein'
ssh $SSH_USER@$SSH_HOST 'mv /home/kufstein/rot-updating /home/kufstein/rot'
ssh $SSH_USER@$SSH_HOST 'sudo systemctl start kufstein'
scp -C target/$CARGO_TARGET/release/rot $SSH_USER@$SSH_HOST:/home/rowing/rot-updating
scp -C -r static $SSH_USER@$SSH_HOST:/home/rowing/
scp -C -r templates $SSH_USER@$SSH_HOST:/home/rowing/
scp -C -r svelte $SSH_USER@$SSH_HOST:/home/rowing/
ssh $SSH_USER@$SSH_HOST 'mkdir -p /home/rowing/svelte/build && mkdir -p /home/rowing/data-ergo/thirty && mkdir -p /home/rowing/data-ergo/dozen'
ssh $SSH_USER@$SSH_HOST 'sudo systemctl stop rot'
ssh $SSH_USER@$SSH_HOST 'mv /home/rowing/rot-updating /home/rowing/rot'
ssh $SSH_USER@$SSH_HOST 'sudo systemctl start rot'
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_HOST: ${{ secrets.SSH_HOST }}

1146
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -13,18 +13,18 @@ rocket = { version = "0.5.0", features = ["secrets"]}
rocket_dyn_templates = {version = "0.2", features = [ "tera" ], optional = true }
log = "0.4"
env_logger = "0.11"
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "macros", "chrono", "time"] }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "macros", "chrono"] }
argon2 = "0.5"
serde = { version = "1.0", features = [ "derive" ]}
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"]}
chrono-tz = "0.9"
chrono-tz = "0.10"
tera = { version = "1.18", features = ["date-locale"], optional = true}
ics = "0.5"
futures = "0.3"
lettre = "0.11"
csv = "1.3"
itertools = "0.13"
itertools = "0.14"
job_scheduler_ng = "2.0"
ureq = { version = "2.9", features = ["json"] }
regex = "1.10"

View File

@ -46,4 +46,3 @@ server {
}
}
```

6
demo_db.sh Executable file
View File

@ -0,0 +1,6 @@
#!/bin/bash
rm -f db.sqlite
touch db.sqlite
sqlite3 db.sqlite < migration.sql
sqlite3 db.sqlite < seeds_demo.sql

View File

@ -8,6 +8,7 @@ test("cox can create and delete trip", async ({ page }) => {
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("cox");
await page.getByPlaceholder("Passwort").press("Enter");
await page.locator('li').filter({ hasText: 'Geplante Ausfahrten' }).getByRole('link').click();
await page.locator('a[href="#"]:has-text("Ausfahrt")').first().click();
await page.locator("#sidebar #planned_starting_time").click();
await page.locator("#sidebar #planned_starting_time").fill("18:00");
@ -17,7 +18,7 @@ test("cox can create and delete trip", async ({ page }) => {
await page.getByRole("button", { name: "Erstellen", exact: true }).click();
await expect(page.locator("body")).toContainText("18:00 Uhr (cox) Details");
await page.goto("/");
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!");
@ -38,6 +39,7 @@ test.describe("cox can edit trips", () => {
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("cox");
await page.getByPlaceholder("Passwort").press("Enter");
await page.locator('li').filter({ hasText: 'Geplante Ausfahrten' }).getByRole('link').click();
await page.locator('a[href="#"]:has-text("Ausfahrt")').first().click();
await page.locator("#sidebar #planned_starting_time").click();
await page.locator("#sidebar #planned_starting_time").fill("18:00");
@ -50,7 +52,7 @@ test.describe("cox can edit trips", () => {
});
test("edit remarks", async () => {
await sharedPage.goto("/");
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");
@ -66,7 +68,7 @@ test.describe("cox can edit trips", () => {
});
test("add and remove guest", async () => {
await sharedPage.goto("/");
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");
@ -106,7 +108,7 @@ test.describe("cox can edit trips", () => {
});
test("change amount rower", async () => {
await sharedPage.goto("/");
await sharedPage.goto("/planned");
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
"Freie Plätze: 5",
@ -120,7 +122,7 @@ test.describe("cox can edit trips", () => {
});
test("call off trip", async () => {
await sharedPage.goto("/");
await sharedPage.goto("/planned");
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
"Freie Plätze: 3",
@ -135,7 +137,7 @@ test.describe("cox can edit trips", () => {
});
test.afterAll(async () => {
await sharedPage.goto("/");
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();

383
frontend/tests/log.spec.ts Normal file
View File

@ -0,0 +1,383 @@
import { test, expect } from "@playwright/test";
test("Cox can start and cancel trip", async ({ page }, testInfo) => {
await page.goto("/auth");
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("cox2");
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("cox");
await page.getByPlaceholder("Passwort").press("Enter");
await page.goto("/");
await page.getByRole("link", { name: "Ausfahrt eintragen" }).click();
if (testInfo.project.name.includes("Mobile")) {
// No left boat selector on mobile views
await page.getByText('-- Wähle ein Boot aus ---').nth(1).click();
await page.getByRole("option", { name: "Joe" }).click();
} else {
await page.getByText('2x', { exact: true }).click();
await page.getByText("Joe", { exact: true }).click();
}
await page.getByLabel('Remove item: \'6\'').click(); // remove pre-filled cox2
await page.getByPlaceholder("Ruderer auswählen").click();
await page.getByRole("option", { name: "rower2" }).click();
await page.getByRole("option", { name: "cox2" }).click();
await expect(page.getByRole("listbox")).toContainText(
"Nur 2 Ruderer können hinzugefügt werden",
);
await expect(page.locator("#shipmaster-newrowerjs")).toContainText("cox");
await expect(page.locator("#steering_person-newrowerjs")).toContainText(
"rower2 cox",
);
await page.getByRole("button", { name: "Ausfahrt eintragen" }).click();
await expect(page.locator("body")).toContainText(
"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) => {
await page.goto("/auth");
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("cox2");
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("cox");
await page.getByPlaceholder("Passwort").press("Enter");
await page.goto("/");
await page.getByRole("link", { name: "Ausfahrt eintragen" }).click();
if (testInfo.project.name.includes("Mobile")) {
// No left boat selector on mobile views
await page.getByText('-- Wähle ein Boot aus ---').nth(1).click();
await page.getByRole("option", { name: "Joe" }).click();
} else {
await page.getByText('2x', { exact: true }).click();
await page.getByText("Joe", { exact: true }).click();
}
await page.getByLabel('Remove item: \'6\'').click(); // remove pre-filled cox2
await page.getByPlaceholder("Ruderer auswählen").click();
await page.getByRole("option", { name: "rower2" }).click();
await page.getByRole("option", { name: "cox2" }).click();
await expect(page.getByRole("listbox")).toContainText(
"Nur 2 Ruderer können hinzugefügt werden",
);
// Trip starts 2 hours ago
const datetimeSelector = '#departure';
const currentValue = await page.$eval(datetimeSelector, el => el.value);
const currentDate = new Date(currentValue);
currentDate.setMinutes(currentDate.getMinutes());
currentDate.setHours(currentDate.getHours() - new Date().getTimezoneOffset()/60 - 2);
const newDatetime = currentDate.toISOString().slice(0, 16);
await page.$eval(datetimeSelector, (el, value) => el.value = value, newDatetime);
await expect(page.locator("#shipmaster-newrowerjs")).toContainText("cox");
await expect(page.locator("#steering_person-newrowerjs")).toContainText(
"rower2 cox",
);
await page.getByRole("button", { name: "Ausfahrt eintragen" }).click();
await expect(page.locator("body")).toContainText(
"Ausfahrt erfolgreich hinzugefügt",
);
await expect(page.locator("body")).toContainText("Joe");
await page.goto("/log");
await page.locator("div:nth-child(2) > .border-0").click();
await page.getByRole("combobox", { name: "Destination" }).click();
await page.getByRole("combobox", { name: "Destination" }).fill("Ottensheim");
await page.getByRole("button", { name: "Ausfahrt beenden" }).click();
await expect(page.locator("body")).toContainText(
"Ausfahrt korrekt eingetragen",
);
await page.goto('/log/show');
await expect(page.locator('body')).toContainText('Joe');
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.getByText('(cox2)').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) => {
await page.goto("/log/kiosk/ekrv2019/Linz");
if (testInfo.project.name.includes("Mobile")) {
// No left boat selector on mobile views
await page.getByText('-- Wähle ein Boot aus ---').nth(1).click();
await page.getByRole("option", { name: "Joe" }).click();
} else {
await page.getByText('2x', { exact: true }).click();
await page.getByText("Joe", { exact: true }).click();
}
await page.getByPlaceholder("Ruderer auswählen").click();
await page.getByRole("option", { name: "rower2" }).click();
await page.getByRole("option", { name: "cox2" }).click();
await expect(page.getByRole("listbox")).toContainText(
"Nur 2 Ruderer können hinzugefügt werden",
);
await expect(page.locator("#shipmaster-newrowerjs")).toContainText("cox");
await expect(page.locator("#steering_person-newrowerjs")).toContainText(
"rower2 cox",
);
await page.getByRole("button", { name: "Ausfahrt eintragen" }).click();
await expect(page.locator("body")).toContainText(
"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) => {
await page.goto("/log/kiosk/ekrv2019/Linz");
if (testInfo.project.name.includes("Mobile")) {
// No left boat selector on mobile views
await page.getByText('-- Wähle ein Boot aus ---').nth(1).click();
await page.getByRole("option", { name: "Joe" }).click();
} else {
await page.getByText('2x', { exact: true }).click();
await page.getByText("Joe", { exact: true }).click();
}
await page.getByPlaceholder("Ruderer auswählen").click();
await page.getByRole("option", { name: "rower2" }).click();
await page.getByRole("option", { name: "cox2" }).click();
await expect(page.getByRole("listbox")).toContainText(
"Nur 2 Ruderer können hinzugefügt werden",
);
// Trip starts 2 hours ago
const datetimeSelector = '#departure';
const currentValue = await page.$eval(datetimeSelector, el => el.value);
const currentDate = new Date(currentValue);
currentDate.setMinutes(currentDate.getMinutes());
currentDate.setHours(currentDate.getHours() - new Date().getTimezoneOffset()/60 - 2);
const newDatetime = currentDate.toISOString().slice(0, 16);
await page.$eval(datetimeSelector, (el, value) => el.value = value, newDatetime);
await expect(page.locator("#shipmaster-newrowerjs")).toContainText("cox");
await expect(page.locator("#steering_person-newrowerjs")).toContainText(
"rower2 cox",
);
await page.getByRole("button", { name: "Ausfahrt eintragen" }).click();
await expect(page.locator("body")).toContainText(
"Ausfahrt erfolgreich hinzugefügt",
);
await expect(page.locator("body")).toContainText("Joe");
await page.goto("/log");
await page.locator('div:nth-child(2) > .pt-2 > div > div > div:nth-child(2) > .border-0').click(); // 2 trips currently running, try to close second one
await page.getByRole("combobox", { name: "Destination" }).click();
await page.getByRole("combobox", { name: "Destination" }).fill("Ottensheim");
await page.getByRole("button", { name: "Ausfahrt beenden" }).click();
await expect(page.locator("body")).toContainText(
"Ausfahrt korrekt eingetragen",
);
await page.getByRole('link', { name: 'Logbuch' }).click();
await expect(page.locator('body')).toContainText('Joe');
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.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.getByText('(cox2)').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) => {
await page.goto("/auth");
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("cox2");
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("cox");
await page.getByPlaceholder("Passwort").press("Enter");
await page.goto("/");
await page.getByRole("link", { name: "Ausfahrt eintragen" }).click();
if (testInfo.project.name.includes("Mobile")) {
// No left boat selector on mobile views
await page.getByText('-- Wähle ein Boot aus ---').nth(1).click();
await page.getByRole("option", { name: "cox_only_steering_boat" }).click();
} else {
await page.getByText('2+', { exact: true }).click();
await page.getByText("cox_only_steering_boat", { exact: true }).click();
}
// Trip starts 2 hours ago
const datetimeSelector = '#departure';
const currentValue = await page.$eval(datetimeSelector, el => el.value);
const currentDate = new Date(currentValue);
currentDate.setMinutes(currentDate.getMinutes());
currentDate.setHours(currentDate.getHours() - new Date().getTimezoneOffset()/60 - 2);
const newDatetime = currentDate.toISOString().slice(0, 16);
await page.$eval(datetimeSelector, (el, value) => el.value = value, newDatetime);
await expect(page.locator("#shipmaster-newrowerjs")).toContainText("cox");
await expect(page.locator("#steering_person-newrowerjs")).toContainText(
"cox",
);
await page.getByRole("button", { name: "Ausfahrt eintragen" }).click();
await expect(page.locator("body")).toContainText(
"Ausfahrt erfolgreich hinzugefügt",
);
await expect(page.locator("body")).toContainText("cox_only_steering_boat");
await page.goto("/log");
await page.locator("div:nth-child(2) > .border-0").click();
await page.getByRole("combobox", { name: "Destination" }).click();
await page.getByRole("combobox", { name: "Destination" }).fill("Ottensheim");
await page.getByRole("button", { name: "Ausfahrt beenden" }).click();
await expect(page.locator("body")).toContainText(
"Ausfahrt korrekt eingetragen",
);
await page.goto('/log/show');
await expect(page.locator('body')).toContainText('cox_only_steering_boat');
await expect(page.locator('body')).toContainText('(cox2 - handgesteuert)');
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.getByText('(cox2 - handgesteuert)').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) => {
await page.goto("/log/kiosk/ekrv2019/Linz");
if (testInfo.project.name.includes("Mobile")) {
// No left boat selector on mobile views
await page.getByText('-- Wähle ein Boot aus ---').nth(1).click();
await page.getByRole("option", { name: "Joe" }).click();
} else {
await page.getByText('2x', { exact: true }).click();
await page.getByText("Joe", { exact: true }).click();
}
await page.getByPlaceholder("Ruderer auswählen").click();
await page.getByRole("option", { name: "rower2" }).click();
await page.getByRole("option", { name: "cox2" }).click();
await expect(page.getByRole("listbox")).toContainText(
"Nur 2 Ruderer können hinzugefügt werden",
);
// Trip starts 2 hours ago
const datetimeSelector = '#departure';
const currentValue = await page.$eval(datetimeSelector, el => el.value);
const currentDate = new Date(currentValue);
currentDate.setMinutes(currentDate.getMinutes());
currentDate.setHours(currentDate.getHours() - new Date().getTimezoneOffset()/60 - 2);
const newDatetime = currentDate.toISOString().slice(0, 16);
await page.$eval(datetimeSelector, (el, value) => el.value = value, newDatetime);
await page.getByLabel('Ankunftszeit').click();
await page.locator('#destination').fill('a');
await page.getByLabel('Distanz').fill('1');
await expect(page.locator("#shipmaster-newrowerjs")).toContainText("cox");
await expect(page.locator("#steering_person-newrowerjs")).toContainText(
"rower2 cox",
);
await page.getByRole("button", { name: "Ausfahrt eintragen" }).click();
await expect(page.locator("body")).toContainText(
"Ausfahrt erfolgreich hinzugefügt",
);
await page.getByRole('link', { name: 'Logbuch' }).click();
await expect(page.locator('body')).toContainText('Joe');
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.getByText('(cox2)').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();
});

View File

@ -4,9 +4,27 @@ CREATE TABLE IF NOT EXISTS "user" (
"pw" text,
"deleted" boolean NOT NULL DEFAULT FALSE,
"last_access" DATETIME,
"dob" text,
"weight" text,
"sex" text,
"dirty_thirty" text,
"dirty_dozen" text,
"member_since_date" text,
"birthdate" text,
"mail" text,
"nickname" text,
"notes" text,
"phone" text,
"address" text,
"family_id" INTEGER REFERENCES family(id),
"membership_pdf" BLOB,
"user_token" TEXT NOT NULL DEFAULT (lower(hex(randomblob(16))))
);
CREATE TABLE IF NOT EXISTS "family" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT
);
CREATE TABLE IF NOT EXISTS "role" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" text NOT NULL UNIQUE,
@ -69,12 +87,74 @@ CREATE TABLE IF NOT EXISTS "log" (
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS "location" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" text NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS "boat" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" text NOT NULL UNIQUE,
"amount_seats" integer NOT NULL,
"location_id" INTEGER NOT NULL REFERENCES location(id) DEFAULT 1,
"owner" INTEGER REFERENCES user(id), -- null: club is owner
"year_built" INTEGER,
"boatbuilder" TEXT,
"default_shipmaster_only_steering" boolean default false not null,
"convert_handoperated_possible" boolean default false not null,
"default_destination" text,
"skull" boolean default true NOT NULL, -- false => riemen
"external" boolean default false NOT NULL, -- false => owned by different club
"deleted" boolean NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS "logbook_type" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" text NOT NULL UNIQUE -- e.g. 'Wanderfahrt', 'Regatta'
);
CREATE TABLE IF NOT EXISTS "logbook" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"boat_id" INTEGER NOT NULL REFERENCES boat(id),
"shipmaster" INTEGER NOT NULL REFERENCES user(id),
"steering_person" INTEGER NOT NULL REFERENCES user(id),
"shipmaster_only_steering" boolean not null,
"departure" datetime not null,
"arrival" datetime, -- None -> ship is on water
"destination" text,
"distance_in_km" integer,
"comments" text,
"logtype" INTEGER REFERENCES logbook_type(id)
);
CREATE TABLE IF NOT EXISTS "rower" (
"logbook_id" INTEGER NOT NULL REFERENCES logbook(id) ON DELETE CASCADE,
"rower_id" INTEGER NOT NULL REFERENCES user(id),
CONSTRAINT unq UNIQUE (logbook_id, rower_id)
);
CREATE TABLE IF NOT EXISTS "boat_damage" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"boat_id" INTEGER NOT NULL REFERENCES boat(id),
"desc" text not null,
"user_id_created" INTEGER NOT NULL REFERENCES user(id),
"created_at" datetime not null default CURRENT_TIMESTAMP,
"user_id_fixed" INTEGER REFERENCES user(id), -- none: not fixed yet
"fixed_at" datetime,
"user_id_verified" INTEGER REFERENCES user(id),
"verified_at" datetime,
"lock_boat" boolean not null default false -- if true: noone can use the boat
);
CREATE TABLE IF NOT EXISTS "boathouse" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"boat_id" INTEGER NOT NULL REFERENCES boat(id),
"aisle" TEXT NOT NULL CHECK (aisle in ('water', 'middle', 'mountain')),
"side" TEXT NOT NULL CHECK(side IN ('mountain', 'water')),
"level" INTEGER NOT NULL CHECK(level BETWEEN 0 AND 11),
CONSTRAINT unq UNIQUE (aisle, side, level) -- only 1 boat allowed to rest at each space
);
CREATE TABLE IF NOT EXISTS "notification" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL REFERENCES user(id),
@ -86,6 +166,18 @@ CREATE TABLE IF NOT EXISTS "notification" (
"link" TEXT
);
CREATE TABLE IF NOT EXISTS "boat_reservation" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"boat_id" INTEGER NOT NULL REFERENCES boat(id),
"start_date" DATE NOT NULL,
"end_date" DATE NOT NULL,
"time_desc" TEXT NOT NULL,
"usage" TEXT NOT NULL,
"user_id_applicant" INTEGER NOT NULL REFERENCES user(id),
"user_id_confirmation" INTEGER REFERENCES user(id),
"created_at" datetime not null default CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS "waterlevel" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"day" DATE NOT NULL,
@ -105,3 +197,44 @@ CREATE TABLE IF NOT EXISTS "weather" (
"wind_gust" FLOAT NOT NULL,
"rain_mm" FLOAT NOT NULL
);
CREATE TABLE IF NOT EXISTS "trailer" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" text NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS "trailer_reservation" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"trailer_id" INTEGER NOT NULL REFERENCES trailer(id),
"start_date" DATE NOT NULL,
"end_date" DATE NOT NULL,
"time_desc" TEXT NOT NULL,
"usage" TEXT NOT NULL,
"user_id_applicant" INTEGER NOT NULL REFERENCES user(id),
"user_id_confirmation" INTEGER REFERENCES user(id),
"created_at" datetime not null default CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS "distance" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"destination" text NOT NULL,
"distance_in_km" integer NOT NULL
);
CREATE TRIGGER IF NOT EXISTS prevent_multiple_roles_same_cluster
BEFORE INSERT ON user_role
BEGIN
SELECT CASE
WHEN EXISTS (
SELECT 1
FROM user_role ur
JOIN role r1 ON ur.role_id = r1.id
JOIN role r2 ON r1."cluster" = r2."cluster"
WHERE ur.user_id = NEW.user_id
AND r2.id = NEW.role_id
AND r1.id != NEW.role_id
)
THEN RAISE(ABORT, 'User already has a role in this cluster')
END;
END;

View File

@ -1,15 +1,15 @@
[Unit]
Description=Normannen
Description=Rot
[Service]
User=root
Group=root
WorkingDirectory=/home/normannen
WorkingDirectory=/home/rowing
Environment="ROCKET_ENV=prod"
Environment="ROCKET_ADDRESS=127.0.0.1"
Environment="ROCKET_PORT=9001"
Environment="ROCKET_PORT=8001"
Environment="RUST_LOG=info"
ExecStart=/home/normannen/rot
ExecStart=/home/rowing/rot
Restart=always
RestartSec=10

17
rotstaging.service Normal file
View File

@ -0,0 +1,17 @@
[Unit]
Description=Rot Staging
[Service]
User=root
Group=root
WorkingDirectory=/home/rowing-staging
Environment="ROCKET_ENV=prod"
Environment="ROCKET_ADDRESS=127.0.0.1"
Environment="ROCKET_PORT=7999"
Environment="ROCKET_LOG=info"
ExecStart=/home/rowing-staging/rot
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

View File

@ -56,3 +56,26 @@ INSERT INTO "trip" (cox_id, trip_details_id) VALUES(4, 2);
INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Regatta', 'Regatta!', 'Kein normales Event. Das ist eine Regatta! Willst du wirklich teilnehmen?', '&#127941;');
INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Lange Ausfahrt', 'Lange Ausfahrt!', 'Das ist eine lange Ausfahrt! Willst du wirklich teilnehmen?', '&#128170;');
INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Wanderfahrt', 'Wanderfahrt!', 'Kein normales Event. Das ist eine Wanderfahrt! Bitte überprüfe ob du alle Anforderungen erfüllst. Willst du wirklich teilnehmen?', '&#9969;');
INSERT INTO "location" (name) VALUES ('Linz');
INSERT INTO "location" (name) VALUES ('Ottensheim');
INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Haichenbach', 1, 1);
INSERT INTO "boat" (name, amount_seats, location_id, owner) VALUES ('private_boat_from_rower', 1, 1, 2);
INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Joe', 2, 1);
INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Kaputtes Boot :-(', 7, 1);
INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Sehr kaputtes Boot :-((', 7, 1);
INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Ottensheim Boot', 7, 2);
INSERT INTO "boat" (name, amount_seats, location_id, owner) VALUES ('second_private_boat_from_rower', 1, 1, 2);
INSERT INTO "boat" (name, amount_seats, location_id, default_shipmaster_only_steering) VALUES ('cox_only_steering_boat', 3, 1, true);
INSERT INTO "logbook_type" (name) VALUES ('Wanderfahrt');
INSERT INTO "logbook_type" (name) VALUES ('Regatta');
INSERT INTO "logbook" (boat_id, shipmaster,steering_person, shipmaster_only_steering, departure) VALUES (2, 2, 2, false, strftime('%Y', 'now') || '-12-24 10:00');
INSERT INTO "logbook" (boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km) VALUES (1, 4, 4, false, strftime('%Y', 'now') || '-12-24 10:00', strftime('%Y', 'now') || '-12-24 15:00', 'Ottensheim', 25);
INSERT INTO "logbook" (boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km) VALUES (3, 4, 4, false, strftime('%Y', 'now') || '-12-24 10:00', strftime('%Y', 'now') || '-12-24 11:30', 'Ottensheim + Regattastrecke', 29);
INSERT INTO "rower" (logbook_id, rower_id) VALUES(3,3);
INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at) VALUES(4,'Dolle bei Position 2 fehlt', 5, '2142-12-24 15:02');
INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at, lock_boat) VALUES(5, 'TOHT', 5, '2142-12-24 15:02', 1);
INSERT INTO "notification" (user_id, message, category) VALUES (1, 'This is a test notification', 'test-cat');
INSERT INTO "trailer" (name) VALUES('Großer Hänger');
INSERT INTO "trailer" (name) VALUES('Kleiner Hänger');
insert into distance(destination, distance_in_km) values('Ottensheim', 25);

104
seeds_demo.sql Normal file
View File

@ -0,0 +1,104 @@
INSERT INTO "role" (name) VALUES ('admin');
INSERT INTO "role" (name) VALUES ('cox');
INSERT INTO "role" (name) VALUES ('scheckbuch');
INSERT INTO "role" (name) VALUES ('tech');
INSERT INTO "role" (name) VALUES ('Donau Linz');
INSERT INTO "role" (name) VALUES ('manage_events');
INSERT INTO "role" (name) VALUES ('Rennrudern');
INSERT INTO "role" (name) VALUES ('paid');
INSERT INTO "role" (name) VALUES ('Vorstand');
INSERT INTO "role" (name) VALUES ('Bootsführer');
INSERT INTO "role" (name) VALUES ('schnupperant');
INSERT INTO "role" (name) VALUES ('kassier');
INSERT INTO "role" (name) VALUES ('schriftfuehrer');
INSERT INTO "role" (name) VALUES ('no-einschreibgebuehr');
INSERT INTO "role" (name) VALUES ('schnupper-betreuer');
INSERT INTO "role" (name) VALUES ('allow_website_login');
INSERT INTO "user" (name, pw) VALUES('admin', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM');
INSERT INTO "user_role" (user_id, role_id) VALUES(1,1);
INSERT INTO "user_role" (user_id, role_id) VALUES(1,2);
INSERT INTO "user_role" (user_id, role_id) VALUES(1,5);
INSERT INTO "user_role" (user_id, role_id) VALUES(1,6);
INSERT INTO "user" (name, pw) VALUES('rower', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$jWKzDmI0jqT2dqINFt6/1NjVF4Dx15n07PL1ZMBmFsY');
INSERT INTO "user_role" (user_id, role_id) VALUES(2,5);
INSERT INTO "user" (name, pw) VALUES('guest', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$GF6gizbI79Bh0zA9its8S0gram956v+YIV8w8VpwJnQ');
INSERT INTO "user_role" (user_id, role_id) VALUES(3,5);
INSERT INTO "user_role" (user_id, role_id) VALUES(3,3);
INSERT INTO "user" (name, pw) VALUES('cox', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$lnWzHx3DdqS9GQyWYel82kIotZuK2wk9EyfhPFtjNzs');
INSERT INTO "user_role" (user_id, role_id) VALUES(4,5);
INSERT INTO "user_role" (user_id, role_id) VALUES(4,2);
INSERT INTO "user_role" (user_id, role_id) VALUES(4,8);
INSERT INTO "user" (name) VALUES('new');
INSERT INTO "user_role" (user_id, role_id) VALUES(5,5);
INSERT INTO "user" (name, pw) VALUES('cox2', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$lnWzHx3DdqS9GQyWYel82kIotZuK2wk9EyfhPFtjNzs');
INSERT INTO "user_role" (user_id, role_id) VALUES(6,5);
INSERT INTO "user_role" (user_id, role_id) VALUES(6,2);
INSERT INTO "user" (name, pw) VALUES('rower2', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$jWKzDmI0jqT2dqINFt6/1NjVF4Dx15n07PL1ZMBmFsY');
INSERT INTO "user_role" (user_id, role_id) VALUES(7,5);
INSERT INTO "user" (name, pw) VALUES('teen', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$jWKzDmI0jqT2dqINFt6/1NjVF4Dx15n07PL1ZMBmFsY');
INSERT INTO "user_role" (user_id, role_id) VALUES(8,5);
INSERT INTO "user_role" (user_id, role_id) VALUES(8,7);
INSERT INTO "user" (name, pw) VALUES('Vorstandsmitglied', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$jWKzDmI0jqT2dqINFt6/1NjVF4Dx15n07PL1ZMBmFsY');
INSERT INTO "user_role" (user_id, role_id) VALUES(9,5);
INSERT INTO "user" (name, pw) VALUES('main', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM');
INSERT INTO "user_role" (user_id, role_id) VALUES(10,1);
INSERT INTO "user_role" (user_id, role_id) VALUES(10,2);
INSERT INTO "user_role" (user_id, role_id) VALUES(10,5);
INSERT INTO "user_role" (user_id, role_id) VALUES(10,6);
INSERT INTO "user_role" (user_id, role_id) VALUES(10,9);
INSERT INTO "user" (name, pw) VALUES('Lukas Rudinger', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM'); --11
INSERT INTO "user_role" (user_id, role_id) VALUES(11,5);
INSERT INTO "user_role" (user_id, role_id) VALUES(11,2);
INSERT INTO "user_role" (user_id, role_id) VALUES(11,8);
INSERT INTO "user" (name, pw) VALUES('Claudia Fröhlich', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM'); --12
INSERT INTO "user_role" (user_id, role_id) VALUES(12,6);
INSERT INTO "user_role" (user_id, role_id) VALUES(12,5);
INSERT INTO "user" (name, pw) VALUES('Adeline Krebs', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM'); --13
INSERT INTO "user_role" (user_id, role_id) VALUES(13,5);
INSERT INTO "user_role" (user_id, role_id) VALUES(13,2);
INSERT INTO "user_role" (user_id, role_id) VALUES(13,8);
INSERT INTO "user" (name, pw) VALUES('Michael Schweiß', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM'); --13
INSERT INTO "user_role" (user_id, role_id) VALUES(14,5);
INSERT INTO "user_role" (user_id, role_id) VALUES(14,8);
INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('06:00', 4, date('now'), '');
INSERT INTO "trip" (cox_id, trip_details_id) VALUES(13, 1);
INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('14:00', 8, date('now'), 'Lasst uns den Markt entern!!');
INSERT INTO "planned_event" (name, planned_amount_cox, trip_details_id) VALUES('Marktfahrt', 2, 2);
INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('17:00', 4, date('now'), 'Feierabend-Ausfahrt');
INSERT INTO "trip" (cox_id, trip_details_id) VALUES(11, 3);
INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('18:00', 8, date('now'), '');
INSERT INTO "planned_event" (name, planned_amount_cox, trip_details_id) VALUES('Anfängertraining Ergo', 1, 4);
INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('14:00', 4, date('now', '+1 day'), 'Der frühe Wurm wird vom Vogel gefressen!');
INSERT INTO "trip" (cox_id, trip_details_id) VALUES(13, 5);
INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Regatta', 'Regatta!', 'Kein normales Event. Das ist eine Regatta! Willst du wirklich teilnehmen?', '&#127941;');
INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Lange Ausfahrt', 'Lange Ausfahrt!', 'Das ist eine lange Ausfahrt! Willst du wirklich teilnehmen?', '&#128170;');
INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Wanderfahrt', 'Wanderfahrt!', 'Kein normales Event. Das ist eine Wanderfahrt! Bitte überprüfe ob du alle Anforderungen erfüllst. Willst du wirklich teilnehmen?', '&#9969;');
INSERT INTO "location" (name) VALUES ('Linz');
INSERT INTO "location" (name) VALUES ('Ottensheim');
INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Haichenbach', 1, 1);
INSERT INTO "boat" (name, amount_seats, location_id, owner) VALUES ('private_boat_from_rower', 1, 1, 2);
INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Joe', 2, 1);
INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Kaputtes Boot :-(', 7, 1);
INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Sehr kaputtes Boot :-((', 7, 1);
INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Ottensheim Boot', 7, 2);
INSERT INTO "boat" (name, amount_seats, location_id, owner) VALUES ('second_private_boat_from_rower', 1, 1, 2);
INSERT INTO "boat" (name, amount_seats, location_id, default_shipmaster_only_steering) VALUES ('cox_only_steering_boat', 3, 1, true);
INSERT INTO "logbook_type" (name) VALUES ('Wanderfahrt');
INSERT INTO "logbook_type" (name) VALUES ('Regatta');
INSERT INTO "logbook" (boat_id, shipmaster,steering_person, shipmaster_only_steering, departure) VALUES (2, 2, 2, false, strftime('%Y', 'now') || '-12-24 10:00');
INSERT INTO "logbook" (boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km) VALUES (1, 4, 4, false, strftime('%Y', 'now') || '-12-24 10:00', strftime('%Y', 'now') || '-12-24 15:00', 'Ottensheim', 25);
INSERT INTO "logbook" (boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km) VALUES (3, 4, 4, false, strftime('%Y', 'now') || '-12-24 10:00', strftime('%Y', 'now') || '-12-24 11:30', 'Ottensheim + Regattastrecke', 29);
INSERT INTO "rower" (logbook_id, rower_id) VALUES(3,3);
INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at) VALUES(4,'Dolle bei Position 2 fehlt', 5, '2142-12-24 15:02');
INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at, lock_boat) VALUES(5, 'TOHT', 5, '2142-12-24 15:02', 1);
INSERT INTO "notification" (user_id, message, category) VALUES (1, 'This is a test notification', 'test-cat');
INSERT INTO "trailer" (name) VALUES('Großer Hänger');
INSERT INTO "trailer" (name) VALUES('Kleiner Hänger');
insert into distance(destination, distance_in_km) values('Ottensheim', 25);

View File

@ -1,24 +0,0 @@
INSERT INTO "role" (name) VALUES ('admin');
INSERT INTO "role" (name) VALUES ('cox');
INSERT INTO "role" (name) VALUES ('scheckbuch');
INSERT INTO "role" (name) VALUES ('manage_events');
INSERT INTO "user" (name) VALUES('admin');
INSERT INTO "user_role" (user_id, role_id) VALUES(1,1);
INSERT INTO "user_role" (user_id, role_id) VALUES(1,2);
INSERT INTO "user_role" (user_id, role_id) VALUES(1,4);
INSERT INTO "user" (name) VALUES('Sabine Steuerfrau');
INSERT INTO "user_role" (user_id, role_id) VALUES(2,2);
INSERT INTO "user" (name) VALUES('Alfred Anfänger');
INSERT INTO "user_role" (user_id, role_id) VALUES(3,3);
INSERT INTO "user" (name) VALUES('Ria Ruderin');
INSERT INTO "user_role" (user_id, role_id) VALUES(4,3);
INSERT INTO trip_type VALUES(1,'Regatta','Regatta!','Kein normales Event. Das ist eine Regatta! Willst du wirklich teilnehmen?','&#127941;');
INSERT INTO trip_type VALUES(2,'Lange Ausfahrt','Lange Ausfahrt!','Das ist eine lange Ausfahrt! Willst du wirklich teilnehmen?','&#128170;');
INSERT INTO trip_type VALUES(3,'Wanderfahrt','Wanderfahrt!','Kein normales Event. Das ist eine Wanderfahrt! Bitte überprüfe ob du alle Anforderungen erfüllst. Willst du wirklich teilnehmen?','&#9969;');
INSERT INTO trip_type VALUES(4,'Ergo','Ergo-Fahrt im Bootshaus','Das ist keine Fahrt auf der Donau, sondern eine tolle Ergo-Einheit im Bootshaus. Willst du teilnehmen?','&#127968;');
INSERT INTO trip_type VALUES(5,'Ruderbecken','Ruderbecken-Training','Das ist ein Training im Ruderbecken. Willst du teilnehmen?','&#127968;');
INSERT INTO trip_type VALUES(6,'Theorie','Theorie','Das ist keine Ausfahrt. Stattdessen wirst du mit zusätzlichem Wissen belohnt. Willst du teilnehmen?','&#128218;');
INSERT INTO trip_type VALUES(7,'Arbeitspartie','Arbeitspartie','Keine Ausfahrt, sondern eine Arbeitspartie im Bootshaus. Willst du teilnehmen?','&#129529;');
INSERT INTO trip_type VALUES(8,'Einer-Ausfahrt','1x Ausfahrt','Das ist eine Ausfahrt in Einer-Booten (1x). Willst du teilnehmen?','1&#65039;&#8419;');

View File

@ -11,6 +11,16 @@ pub mod rest;
pub mod scheduled;
pub(crate) const AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD: i64 = 10;
pub(crate) const RENNRUDERBEITRAG: i64 = 11000;
pub(crate) const BOAT_STORAGE: i64 = 4500;
pub(crate) const FAMILY_TWO: i64 = 30000;
pub(crate) const FAMILY_THREE_OR_MORE: i64 = 35000;
pub(crate) const STUDENT_OR_PUPIL: i64 = 8000;
pub(crate) const REGULAR: i64 = 22000;
pub(crate) const UNTERSTUETZEND: i64 = 2500;
pub(crate) const FOERDERND: i64 = 8500;
pub(crate) const SCHECKBUCH: i64 = 3000;
pub(crate) const EINSCHREIBGEBUEHR: i64 = 3000;
#[cfg(test)]
#[macro_export]

651
src/model/boat.rs Normal file
View File

@ -0,0 +1,651 @@
use std::ops::DerefMut;
use chrono::NaiveDateTime;
use itertools::Itertools;
use rocket::serde::{Deserialize, Serialize};
use rocket::FromForm;
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
use crate::model::boathouse::Boathouse;
use super::location::Location;
use super::user::User;
#[derive(FromRow, Debug, Serialize, Deserialize, Eq, Hash, PartialEq, Clone)]
pub struct Boat {
pub id: i64,
pub name: String,
pub amount_seats: i64,
pub location_id: i64,
pub owner: Option<i64>,
pub year_built: Option<i64>,
pub boatbuilder: Option<String>,
pub default_destination: Option<String>,
#[serde(default = "bool::default")]
pub convert_handoperated_possible: bool,
#[serde(default = "bool::default")]
pub default_shipmaster_only_steering: bool,
#[serde(default = "bool::default")]
skull: bool,
#[serde(default = "bool::default")]
pub external: bool,
pub deleted: bool,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum BoatDamage {
None,
Light,
Locked,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct BoatWithDetails {
#[serde(flatten)]
pub(crate) boat: Boat,
damage: BoatDamage,
on_water: bool,
reserved_today: bool,
cat: String,
}
#[derive(FromForm)]
pub struct BoatToAdd<'r> {
pub name: &'r str,
pub amount_seats: i64,
pub year_built: Option<i64>,
pub boatbuilder: Option<&'r str>,
pub default_shipmaster_only_steering: bool,
pub convert_handoperated_possible: bool,
pub default_destination: Option<&'r str>,
pub skull: bool,
pub external: bool,
pub location_id: Option<i64>,
pub owner: Option<i64>,
}
#[derive(FromForm)]
pub struct BoatToUpdate<'r> {
pub name: &'r str,
pub amount_seats: i64,
pub year_built: Option<i64>,
pub boatbuilder: Option<&'r str>,
pub default_shipmaster_only_steering: bool,
pub default_destination: Option<&'r str>,
pub skull: bool,
pub convert_handoperated_possible: bool,
pub external: bool,
pub location_id: i64,
pub owner: Option<i64>,
}
impl Boat {
pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> {
sqlx::query_as!(Self, "SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, convert_handoperated_possible, default_destination, skull, external, deleted FROM boat WHERE id like ?", id)
.fetch_one(db)
.await
.ok()
}
pub async fn find_by_id_tx(db: &mut Transaction<'_, Sqlite>, id: i32) -> Option<Self> {
sqlx::query_as!(Self, "SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, convert_handoperated_possible, default_destination, skull, external, deleted FROM boat WHERE id like ?", id)
.fetch_one(db.deref_mut())
.await
.ok()
}
pub async fn find_by_name(db: &SqlitePool, name: String) -> Option<Self> {
sqlx::query_as!(Self, "SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, convert_handoperated_possible, default_destination, skull, external, deleted FROM boat WHERE name like ?", name)
.fetch_one(db)
.await
.ok()
}
pub async fn shipmaster_allowed(&self, db: &SqlitePool, user: &User) -> bool {
if let Some(owner_id) = self.owner {
return owner_id == user.id;
}
if user.has_role(db, "Rennrudern").await {
let ottensheim = Location::find_by_name(db, "Ottensheim".into())
.await
.unwrap();
if self.location_id == ottensheim.id {
return true;
}
}
if self.amount_seats == 1 {
return true;
}
user.allowed_to_steer(db).await
}
pub async fn shipmaster_allowed_tx(
&self,
db: &mut Transaction<'_, Sqlite>,
user: &User,
) -> bool {
if let Some(owner_id) = self.owner {
return owner_id == user.id;
}
if self.amount_seats == 1 {
return true;
}
user.allowed_to_steer_tx(db).await
}
pub async fn is_locked(&self, db: &SqlitePool) -> bool {
sqlx::query!("SELECT * FROM boat_damage WHERE boat_id=? AND lock_boat=true AND user_id_verified is null", self.id).fetch_optional(db).await.unwrap().is_some()
}
pub async fn has_minor_damage(&self, db: &SqlitePool) -> bool {
sqlx::query!("SELECT * FROM boat_damage WHERE boat_id=? AND lock_boat=false AND user_id_verified is null", self.id).fetch_optional(db).await.unwrap().is_some()
}
pub async fn reserved_today(&self, db: &SqlitePool) -> bool {
sqlx::query!(
"SELECT *
FROM boat_reservation
WHERE boat_id =?
AND date('now') BETWEEN start_date AND end_date;",
self.id
)
.fetch_optional(db)
.await
.unwrap()
.is_some()
}
pub async fn on_water(&self, db: &SqlitePool) -> bool {
sqlx::query!(
"SELECT * FROM logbook WHERE boat_id=? AND arrival is null",
self.id
)
.fetch_optional(db)
.await
.unwrap()
.is_some()
}
pub(crate) fn cat(&self) -> String {
if self.external {
"Vereinsfremde Boote".to_string()
} else if self.default_shipmaster_only_steering {
format!("{}+", self.amount_seats - 1)
} else {
format!("{}x", self.amount_seats)
}
}
async fn boats_to_details(db: &SqlitePool, boats: Vec<Boat>) -> Vec<BoatWithDetails> {
let mut res = Vec::new();
for boat in boats {
let mut damage = BoatDamage::None;
if boat.has_minor_damage(db).await {
damage = BoatDamage::Light;
}
if boat.is_locked(db).await {
damage = BoatDamage::Locked;
}
let cat = boat.cat();
res.push(BoatWithDetails {
damage,
on_water: boat.on_water(db).await,
reserved_today: boat.reserved_today(db).await,
boat,
cat,
});
}
res
}
pub async fn all(db: &SqlitePool) -> Vec<BoatWithDetails> {
let boats = sqlx::query_as!(
Boat,
"
SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external, deleted, convert_handoperated_possible
FROM boat
WHERE deleted=false
ORDER BY amount_seats DESC
"
)
.fetch_all(db)
.await
.unwrap(); //TODO: fixme
Self::boats_to_details(db, boats).await
}
pub async fn all_for_boatshouse(db: &SqlitePool) -> Vec<BoatWithDetails> {
let boats = sqlx::query_as!(
Boat,
"
SELECT
b.id,
b.name,
b.amount_seats,
b.location_id,
b.owner,
b.year_built,
b.boatbuilder,
b.default_shipmaster_only_steering,
b.default_destination,
b.skull,
b.external,
b.deleted,
b.convert_handoperated_possible
FROM
boat AS b
WHERE
b.external = false
AND b.location_id = (SELECT id FROM location WHERE name = 'Linz')
AND b.deleted = false
ORDER BY
b.name DESC;
"
)
.fetch_all(db)
.await
.unwrap(); //TODO: fixme
Self::boats_to_details(db, boats).await
}
pub async fn for_user(db: &SqlitePool, user: &User) -> Vec<BoatWithDetails> {
if user.has_role(db, "admin").await {
return Self::all(db).await;
}
let mut boats = if user.allowed_to_steer(db).await {
sqlx::query_as!(
Boat,
"
SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external, deleted, convert_handoperated_possible
FROM boat
WHERE (owner is null or owner = ?) AND deleted = 0
ORDER BY amount_seats DESC
",
user.id
)
.fetch_all(db)
.await
.unwrap() //TODO: fixme
} else {
sqlx::query_as!(
Boat,
"
SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external, deleted, convert_handoperated_possible
FROM boat
WHERE (owner = ? OR (owner is null and amount_seats = 1)) AND deleted = 0
ORDER BY amount_seats DESC
",
user.id
)
.fetch_all(db)
.await
.unwrap() //TODO: fixme
};
if user.has_role(db, "Rennrudern").await {
let ottensheim = Location::find_by_name(db, "Ottensheim".into())
.await
.unwrap();
let boats_in_ottensheim = sqlx::query_as!(
Boat,
"SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external, deleted, convert_handoperated_possible
FROM boat
WHERE (owner is null and location_id = ?) AND deleted = 0
ORDER BY amount_seats DESC
",ottensheim.id)
.fetch_all(db)
.await
.unwrap(); //TODO: fixme
boats.extend(boats_in_ottensheim.into_iter());
}
let boats = boats.into_iter().unique().collect();
Self::boats_to_details(db, boats).await
}
pub async fn all_at_location(db: &SqlitePool, location: String) -> Vec<BoatWithDetails> {
let boats = sqlx::query_as!(
Boat,
"
SELECT boat.id, boat.name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external, deleted, convert_handoperated_possible
FROM boat
INNER JOIN location ON boat.location_id = location.id
WHERE location.name=? AND deleted = 0
ORDER BY amount_seats DESC
",
location
)
.fetch_all(db)
.await
.unwrap(); //TODO: fixme
Self::boats_to_details(db, boats).await
}
pub async fn create(db: &SqlitePool, boat: BoatToAdd<'_>) -> Result<(), String> {
sqlx::query!(
"INSERT INTO boat(name, amount_seats, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external, location_id, owner, convert_handoperated_possible) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
boat.name,
boat.amount_seats,
boat.year_built,
boat.boatbuilder,
boat.default_shipmaster_only_steering,
boat.default_destination,
boat.skull,
boat.external,
boat.location_id,
boat.owner,
boat.convert_handoperated_possible
)
.execute(db)
.await.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn update(&self, db: &SqlitePool, boat: BoatToUpdate<'_>) -> Result<(), String> {
sqlx::query!(
"UPDATE boat SET name=?, amount_seats=?, year_built=?, boatbuilder=?, default_shipmaster_only_steering=?, default_destination=?, skull=?, external=?, location_id=?, owner=?, convert_handoperated_possible=? WHERE id=?",
boat.name,
boat.amount_seats,
boat.year_built,
boat.boatbuilder,
boat.default_shipmaster_only_steering,
boat.default_destination,
boat.skull,
boat.external,
boat.location_id,
boat.owner,
boat.convert_handoperated_possible,
self.id
)
.execute(db)
.await.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn owner(&self, db: &SqlitePool) -> Option<User> {
if let Some(owner_id) = self.owner {
Some(User::find_by_id(db, owner_id as i32).await.unwrap())
} else {
None
}
}
pub async fn delete(&self, db: &SqlitePool) {
sqlx::query!("UPDATE boat SET deleted=1 WHERE id=?", self.id)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a Boat of a valid id
}
pub async fn boathouse(&self, db: &SqlitePool) -> Option<Boathouse> {
sqlx::query_as!(
Boathouse,
"SELECT * FROM boathouse WHERE boat_id like ?",
self.id
)
.fetch_one(db)
.await
.ok()
}
pub async fn on_water_between(
&self,
db: &mut Transaction<'_, Sqlite>,
dep: NaiveDateTime,
arr: NaiveDateTime,
) -> bool {
let dep = dep.format("%Y-%m-%dT%H:%M").to_string();
let arr = arr.format("%Y-%m-%dT%H:%M").to_string();
sqlx::query!(
"SELECT COUNT(*) AS overlap_count
FROM logbook
WHERE boat_id = ?
AND (
(departure <= ? AND arrival >= ?) -- Existing entry covers the entire new period
OR (departure >= ? AND departure < ?) -- Existing entry starts during the new period
OR (arrival > ? AND arrival <= ?) -- Existing entry ends during the new period
);",
self.id,
arr,
arr,
dep,
dep,
dep,
arr
)
.fetch_one(db.deref_mut())
.await
.unwrap()
.overlap_count
> 0
}
}
#[cfg(test)]
mod test {
use crate::{
model::boat::{Boat, BoatToAdd},
testdb,
};
use sqlx::SqlitePool;
use super::BoatToUpdate;
#[sqlx::test]
fn test_find_correct_id() {
let pool = testdb!();
let boat = Boat::find_by_id(&pool, 1).await.unwrap();
assert_eq!(boat.id, 1);
}
#[sqlx::test]
fn test_find_wrong_id() {
let pool = testdb!();
let boat = Boat::find_by_id(&pool, 1337).await;
assert!(boat.is_none());
}
#[sqlx::test]
fn test_all() {
let pool = testdb!();
let res = Boat::all(&pool).await;
assert!(res.len() > 3);
}
#[sqlx::test]
fn test_succ_create() {
let pool = testdb!();
assert_eq!(
Boat::create(
&pool,
BoatToAdd {
name: "new-boat-name".into(),
amount_seats: 42,
year_built: None,
boatbuilder: "Best Boatbuilder".into(),
default_shipmaster_only_steering: true,
convert_handoperated_possible: false,
skull: true,
external: false,
location_id: Some(1),
owner: None,
default_destination: None
}
)
.await,
Ok(())
);
}
#[sqlx::test]
fn test_duplicate_name_create() {
let pool = testdb!();
assert_eq!(
Boat::create(
&pool,
BoatToAdd {
name: "Haichenbach".into(),
amount_seats: 42,
year_built: None,
boatbuilder: "Best Boatbuilder".into(),
default_shipmaster_only_steering: true,
convert_handoperated_possible: false,
skull: true,
external: false,
location_id: Some(1),
owner: None,
default_destination: None
}
)
.await,
Err(
"error returned from database: (code: 2067) UNIQUE constraint failed: boat.name"
.into()
)
);
}
#[sqlx::test]
fn test_is_locked() {
let pool = testdb!();
let res = Boat::find_by_id(&pool, 5)
.await
.unwrap()
.is_locked(&pool)
.await;
assert_eq!(res, true);
}
#[sqlx::test]
fn test_is_not_locked() {
let pool = testdb!();
let res = Boat::find_by_id(&pool, 4)
.await
.unwrap()
.is_locked(&pool)
.await;
assert_eq!(res, false);
}
#[sqlx::test]
fn test_is_not_locked_no_damage() {
let pool = testdb!();
let res = Boat::find_by_id(&pool, 3)
.await
.unwrap()
.is_locked(&pool)
.await;
assert_eq!(res, false);
}
#[sqlx::test]
fn test_has_minor_damage() {
let pool = testdb!();
let res = Boat::find_by_id(&pool, 4)
.await
.unwrap()
.has_minor_damage(&pool)
.await;
assert_eq!(res, true);
}
#[sqlx::test]
fn test_has_no_minor_damage() {
let pool = testdb!();
let res = Boat::find_by_id(&pool, 5)
.await
.unwrap()
.has_minor_damage(&pool)
.await;
assert_eq!(res, false);
}
#[sqlx::test]
fn test_on_water() {
let pool = testdb!();
let res = Boat::find_by_id(&pool, 2)
.await
.unwrap()
.on_water(&pool)
.await;
assert_eq!(res, true);
}
#[sqlx::test]
fn test_not_on_water() {
let pool = testdb!();
let res = Boat::find_by_id(&pool, 4)
.await
.unwrap()
.on_water(&pool)
.await;
assert_eq!(res, false);
}
#[sqlx::test]
fn test_succ_update() {
let pool = testdb!();
let boat = Boat::find_by_id(&pool, 1).await.unwrap();
let update = BoatToUpdate {
name: "my-new-boat-name",
amount_seats: 3,
year_built: None,
boatbuilder: None,
default_shipmaster_only_steering: false,
convert_handoperated_possible: false,
skull: true,
external: false,
location_id: 1,
owner: None,
default_destination: None,
};
boat.update(&pool, update).await.unwrap();
let boat = Boat::find_by_id(&pool, 1).await.unwrap();
assert_eq!(boat.name, "my-new-boat-name");
}
#[sqlx::test]
fn test_failed_update() {
let pool = testdb!();
let boat = Boat::find_by_id(&pool, 1).await.unwrap();
let update = BoatToUpdate {
name: "my-new-boat-name",
amount_seats: 3,
year_built: None,
boatbuilder: None,
default_shipmaster_only_steering: false,
convert_handoperated_possible: false,
skull: true,
external: false,
location_id: 999,
owner: None,
default_destination: None,
};
match boat.update(&pool, update).await {
Ok(_) => panic!("Update with invalid location should not succeed"),
Err(e) => assert_eq!(
e,
"error returned from database: (code: 787) FOREIGN KEY constraint failed"
),
};
let boat = Boat::find_by_id(&pool, 1).await.unwrap();
assert_eq!(boat.name, "Haichenbach");
}
}

350
src/model/boatdamage.rs Normal file
View File

@ -0,0 +1,350 @@
use crate::model::{boat::Boat, user::User};
use chrono::NaiveDateTime;
use rocket::serde::{Deserialize, Serialize};
use rocket::FromForm;
use sqlx::{FromRow, SqlitePool};
use super::log::Log;
use super::notification::Notification;
use super::role::Role;
#[derive(FromRow, Debug, Serialize, Deserialize)]
pub struct BoatDamage {
pub id: i64,
pub boat_id: i64,
pub desc: String,
pub user_id_created: i64,
pub created_at: NaiveDateTime,
pub user_id_fixed: Option<i64>,
pub fixed_at: Option<NaiveDateTime>,
pub user_id_verified: Option<i64>,
pub verified_at: Option<NaiveDateTime>,
pub lock_boat: bool,
}
#[derive(FromRow, Debug, Serialize, Deserialize)]
pub struct BoatDamageWithDetails {
#[serde(flatten)]
boat_damage: BoatDamage,
user_created: User,
user_fixed: Option<User>,
user_verified: Option<User>,
boat: Boat,
verified: bool,
}
#[derive(Debug)]
pub struct BoatDamageToAdd<'r> {
pub boat_id: i64,
pub desc: &'r str,
pub user_id_created: i32,
pub lock_boat: bool,
}
#[derive(FromForm, Debug)]
pub struct BoatDamageFixed<'r> {
pub desc: &'r str,
pub user_id_fixed: i32,
}
#[derive(FromForm, Debug)]
pub struct BoatDamageVerified<'r> {
pub desc: &'r str,
pub user_id_verified: i32,
}
impl BoatDamage {
pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> {
sqlx::query_as!(
Self,
"SELECT id, boat_id, desc, user_id_created, created_at, user_id_fixed, fixed_at, user_id_verified, verified_at, lock_boat
FROM boat_damage
WHERE id like ?",
id
)
.fetch_one(db)
.await
.ok()
}
pub async fn all(db: &SqlitePool) -> Vec<BoatDamageWithDetails> {
let boatdamages = sqlx::query_as!(
BoatDamage,
"
SELECT id, boat_id, desc, user_id_created, created_at, user_id_fixed, fixed_at, user_id_verified, verified_at, lock_boat
FROM boat_damage
WHERE (
verified_at IS NULL
OR verified_at >= datetime('now', '-30 days')
)
ORDER BY created_at DESC
"
)
.fetch_all(db)
.await
.unwrap(); //TODO: fixme
let mut res = Vec::new();
for boat_damage in boatdamages {
let user_fixed = match boat_damage.user_id_fixed {
Some(id) => {
let user = User::find_by_id(db, id as i32).await;
Some(user.unwrap())
}
None => None,
};
let user_verified = match boat_damage.user_id_verified {
Some(id) => {
let user = User::find_by_id(db, id as i32).await;
Some(user.unwrap())
}
None => None,
};
res.push(BoatDamageWithDetails {
boat: Boat::find_by_id(db, boat_damage.boat_id as i32)
.await
.unwrap(),
user_created: User::find_by_id(db, boat_damage.user_id_created as i32)
.await
.unwrap(),
user_fixed,
verified: user_verified.is_some(),
user_verified,
boat_damage,
});
}
res
}
pub async fn create(db: &SqlitePool, boatdamage: BoatDamageToAdd<'_>) -> Result<(), String> {
Log::create(db, format!("New boat damage: {boatdamage:?}")).await;
let Some(boat) = Boat::find_by_id(db, boatdamage.boat_id as i32).await else {
return Err("Boot gibt's ned".into());
};
let was_unusable_before = boat.is_locked(db).await;
sqlx::query!(
"INSERT INTO boat_damage(boat_id, desc, user_id_created, lock_boat) VALUES (?,?,?, ?)",
boatdamage.boat_id,
boatdamage.desc,
boatdamage.user_id_created,
boatdamage.lock_boat
)
.execute(db)
.await
.map_err(|e| e.to_string())?;
if !was_unusable_before && boat.is_locked(db).await {
Notification::create_for_steering_people(db, &format!("Liebe Steuerberechtigte, bitte beachten, dass {} bis auf weiteres aufgrund von Reparaturarbeiten gesperrt ist.", boat.name), "Boot gesperrt", None, None).await;
}
let technicals =
User::all_with_role(db, &Role::find_by_name(db, "tech").await.unwrap()).await;
for technical in technicals {
if technical.id as i32 != boatdamage.user_id_created {
Notification::create(
db,
&technical,
&format!(
"{} hat einen neuen Bootschaden für Boot '{}' angelegt: {}",
User::find_by_id(db, boatdamage.user_id_created)
.await
.unwrap()
.name,
boat.name,
boatdamage.desc
),
"Neuer Bootsschaden angelegt",
None,
None,
)
.await;
}
}
Notification::create(
db,
&User::find_by_id(db, boatdamage.user_id_created)
.await
.unwrap(),
&format!(
"Du hat einen neuen Bootschaden für Boot '{}' angelegt: {}",
Boat::find_by_id(db, boatdamage.boat_id as i32)
.await
.unwrap()
.name,
boatdamage.desc
),
"Neuer Bootsschaden angelegt",
None,
None,
)
.await;
Ok(())
}
pub async fn fixed(
&self,
db: &SqlitePool,
boat_damage: BoatDamageFixed<'_>,
) -> Result<(), String> {
Log::create(db, format!("Fixed boat damage: {boat_damage:?}")).await;
let boat = Boat::find_by_id(db, self.boat_id as i32).await.unwrap();
sqlx::query!(
"UPDATE boat_damage SET desc=?, user_id_fixed=?, fixed_at=CURRENT_TIMESTAMP WHERE id=?",
boat_damage.desc,
boat_damage.user_id_fixed,
self.id
)
.execute(db)
.await
.map_err(|e| e.to_string())?;
let user = User::find_by_id(db, boat_damage.user_id_fixed)
.await
.unwrap();
if user.has_role(db, "tech").await {
return self
.verified(
db,
BoatDamageVerified {
desc: boat_damage.desc,
user_id_verified: user.id as i32,
},
)
.await;
}
let technicals =
User::all_with_role(db, &Role::find_by_name(db, "tech").await.unwrap()).await;
for technical in technicals {
if technical.id as i32 != boat_damage.user_id_fixed {
Notification::create(
db,
&technical,
&format!(
"{} hat den Bootschaden '{}' beim Boot '{}' repariert. Könntest du das bei Gelegenheit verifizieren?",
User::find_by_id(db, boat_damage.user_id_fixed)
.await
.unwrap()
.name,
boat_damage.desc,
boat.name,
),
"Bootsschaden repariert",
None,None
)
.await;
}
}
if boat_damage.user_id_fixed != self.user_id_created as i32 {
let user_fixed = User::find_by_id(db, boat_damage.user_id_fixed)
.await
.unwrap();
let user_created = User::find_by_id(db, self.user_id_created as i32)
.await
.unwrap();
// Boatdamage is also directly verified, if a tech has repaired it. We don't want to
// send 2 notifications.
if !user_fixed.has_role(db, "tech").await {
Notification::create(
db,
&user_created,
&format!(
"{} hat den von dir eingetragenen Bootschaden '{}' beim Boot '{}' repariert. Dieser muss nun noch von unseren Bootswarten bestätigt werden.",
user_fixed.name,
boat_damage.desc, boat.name,
),
"Bootsschaden repariert",
None,None
)
.await;
}
}
Ok(())
}
pub async fn verified(
&self,
db: &SqlitePool,
boat_form: BoatDamageVerified<'_>,
) -> Result<(), String> {
if let Some(verifier) = User::find_by_id(db, boat_form.user_id_verified).await {
if !verifier.has_role(db, "tech").await {
Log::create(db, format!("User {verifier:?} tried to verify boat {boat_form:?}. The user is no tech. Manually craftted request?")).await;
return Err("You are not allowed to verify the boat!".into());
}
} else {
Log::create(db, format!("Someone tried to verify the boat {boat_form:?} with user_id={} which does not exist. Manually craftted request?", boat_form.user_id_verified)).await;
return Err("Could not find user".into());
}
let Some(boat) = Boat::find_by_id(db, self.boat_id as i32).await else {
return Err("Boot gibt's ned".into());
};
let was_unusable_before = boat.is_locked(db).await;
Log::create(db, format!("Verified boat damage: {boat_form:?}")).await;
sqlx::query!(
"UPDATE boat_damage SET desc=?, user_id_verified=?, verified_at=CURRENT_TIMESTAMP WHERE id=?",
boat_form.desc,
boat_form.user_id_verified,
self.id
)
.execute(db)
.await.map_err(|e| e.to_string())?;
if boat_form.user_id_verified != self.user_id_created as i32 {
let user_verified = User::find_by_id(db, boat_form.user_id_verified)
.await
.unwrap();
let user_created = User::find_by_id(db, self.user_id_created as i32)
.await
.unwrap();
if user_verified.id == self.user_id_fixed.unwrap() {
Notification::create(
db,
&user_created,
&format!(
"{} hat den von dir eingetragenen Bootschaden '{}' beim Boot '{}' repariert und verifiziert.",
user_verified.name,
self.desc, boat.name,
),
"Bootsschaden repariert & verifiziert",
None,
None
)
.await;
} else {
Notification::create(
db,
&user_created,
&format!(
"{} hat verifiziert, dass der von dir eingetragenen Bootschaden '{}' beim Boot '{}' korrekt repariert wurde.",
user_verified.name,
self.desc, boat.name,
),
"Bootsschaden verifiziert",
None,
None
).await;
}
}
if was_unusable_before && !boat.is_locked(db).await {
let cox = Role::find_by_name(db, "cox").await.unwrap();
Notification::create_for_role(db, &cox, &format!("Liebe Steuerberechtigte, {} wurde repariert und freut sich ab sofort wieder gerudert zu werden :-)", boat.name), "Boot repariert", None, None).await;
}
Ok(())
}
}

144
src/model/boathouse.rs Normal file
View File

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

View File

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

33
src/model/distance.rs Normal file
View File

@ -0,0 +1,33 @@
use serde::Serialize;
use sqlx::{FromRow, SqlitePool};
#[derive(FromRow, Serialize, Clone, Debug)]
pub struct Distance {
pub id: i64,
pub destination: String,
pub distance_in_km: i64,
}
impl Distance {
/// Return all default `distance`s, ordered by usage in logbook entries
pub async fn all(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as!(
Self,
"SELECT
d.id,
d.destination,
d.distance_in_km
FROM
distance d
LEFT JOIN
logbook l ON d.destination = l.destination AND d.distance_in_km = l.distance_in_km
GROUP BY
d.id, d.destination, d.distance_in_km
ORDER BY
COUNT(l.id) DESC, d.destination ASC;"
)
.fetch_all(db)
.await
.unwrap()
}
}

View File

@ -96,8 +96,8 @@ FROM trip WHERE planned_event_id = ?
.unwrap()
.into_iter()
.map(|r| Registration {
name: r.name,
registered_at: r.registered_at,
name: r.name.unwrap(),
registered_at: r.registered_at.unwrap(),
is_guest: false,
is_real_guest: false,
})
@ -232,8 +232,10 @@ WHERE trip_details.id=?
}
async fn advertise(db: &SqlitePool, day: &str, planned_starting_time: &str, name: &str) {
Notification::create_for_all(
let donau = Role::find_by_name(db, "Donau Linz").await.unwrap();
Notification::create_for_role(
db,
&donau,
&format!("Am {} um {} wurde ein neues Event angelegt: {} Wir freuen uns wenn du dabei mitmachst, die Anmeldung ist ab sofort offen :-)", day, planned_starting_time, name),
"Neues Event",
Some(&format!("/planned#{day}")),

94
src/model/family.rs Normal file
View File

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

103
src/model/location.rs Normal file
View File

@ -0,0 +1,103 @@
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
#[derive(FromRow, Debug, Serialize, Deserialize)]
pub struct Location {
pub id: i64,
pub name: String,
}
impl Location {
pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> {
sqlx::query_as!(
Self,
"
SELECT id, name
FROM location
WHERE id like ?
",
id
)
.fetch_one(db)
.await
.ok()
}
pub async fn find_by_name(db: &SqlitePool, name: String) -> Option<Self> {
sqlx::query_as!(
Self,
"
SELECT id, name
FROM location
WHERE name=?
",
name
)
.fetch_one(db)
.await
.ok()
}
pub async fn all(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as!(Self, "SELECT id, name FROM location")
.fetch_all(db)
.await
.unwrap() //TODO: fixme
}
pub async fn create(db: &SqlitePool, name: &str) -> bool {
sqlx::query!("INSERT INTO location(name) VALUES (?)", name)
.execute(db)
.await
.is_ok()
}
pub async fn delete(&self, db: &SqlitePool) {
sqlx::query!("DELETE FROM location WHERE id=?", self.id)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a Location of a valid id
}
}
#[cfg(test)]
mod test {
use crate::{model::location::Location, testdb};
use sqlx::SqlitePool;
#[sqlx::test]
fn test_find_correct_id() {
let pool = testdb!();
let location = Location::find_by_id(&pool, 1).await.unwrap();
assert_eq!(location.id, 1);
}
#[sqlx::test]
fn test_find_wrong_id() {
let pool = testdb!();
let location = Location::find_by_id(&pool, 1337).await;
assert!(location.is_none());
}
#[sqlx::test]
fn test_all() {
let pool = testdb!();
let res = Location::all(&pool).await;
assert!(res.len() > 1);
}
#[sqlx::test]
fn test_succ_create() {
let pool = testdb!();
assert_eq!(Location::create(&pool, "new-loc-name".into(),).await, true);
}
#[sqlx::test]
fn test_duplicate_name_create() {
let pool = testdb!();
assert_eq!(Location::create(&pool, "Linz".into(),).await, false);
}
}

1288
src/model/logbook.rs Normal file

File diff suppressed because it is too large Load Diff

52
src/model/logtype.rs Normal file
View File

@ -0,0 +1,52 @@
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
#[derive(FromRow, Debug, Serialize, Deserialize, Clone)]
pub struct LogType {
pub id: i64,
name: String,
}
impl LogType {
pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> {
sqlx::query_as!(
Self,
"
SELECT id, name
FROM logbook_type
WHERE id like ?
",
id
)
.fetch_one(db)
.await
.ok()
}
pub async fn all(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as!(
Self,
"
SELECT id, name
FROM logbook_type
"
)
.fetch_all(db)
.await
.unwrap() //TODO: fixme
}
}
#[cfg(test)]
mod test {
use crate::testdb;
use sqlx::SqlitePool;
#[sqlx::test]
fn test_find_true() {
let _ = testdb!();
}
//TODO: write tests
}

367
src/model/mail.rs Normal file
View File

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

View File

@ -11,12 +11,28 @@ use self::{
waterlevel::Waterlevel,
weather::Weather,
};
use boatreservation::{BoatReservation, BoatReservationWithDetails};
use std::collections::HashMap;
pub mod boat;
pub mod boatdamage;
pub mod boathouse;
pub mod boatreservation;
pub mod distance;
pub mod event;
pub mod family;
pub mod location;
pub mod log;
pub mod logbook;
pub mod logtype;
pub mod mail;
pub mod notification;
pub mod personal;
pub mod role;
pub mod rower;
pub mod stat;
pub mod trailer;
pub mod trailerreservation;
pub mod trip;
pub mod tripdetails;
pub mod triptype;
@ -34,6 +50,7 @@ pub struct Day {
regular_sees_this_day: bool,
max_waterlevel: Option<WaterlevelDay>,
weather: Option<Weather>,
boat_reservations: HashMap<String, Vec<BoatReservationWithDetails>>,
}
impl Day {
@ -50,6 +67,9 @@ impl Day {
regular_sees_this_day,
max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await,
weather: Weather::find_by_day(db, day).await,
boat_reservations: BoatReservation::with_groups(
BoatReservation::for_day(db, day).await,
),
}
} else {
Self {
@ -60,6 +80,9 @@ impl Day {
regular_sees_this_day,
max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await,
weather: Weather::find_by_day(db, day).await,
boat_reservations: BoatReservation::with_groups(
BoatReservation::for_day(db, day).await,
),
}
}
}

View File

@ -89,20 +89,6 @@ impl Notification {
tx.commit().await.unwrap();
}
pub async fn create_for_all(
db: &SqlitePool,
message: &str,
category: &str,
link: Option<&str>,
action_after_reading: Option<&str>,
) {
let users = User::all(db).await;
for user in users {
Self::create(db, &user, message, category, link, action_after_reading).await;
}
}
pub async fn create_for_steering_people_tx(
db: &mut Transaction<'_, Sqlite>,
message: &str,
@ -208,6 +194,15 @@ ORDER BY read_at DESC, created_at DESC;
}
}
}
pub(crate) async fn mark_all_read(db: &SqlitePool, user: &User) {
let notifications = Self::for_user(db, user).await;
for notification in notifications {
notification.mark_read(db).await;
}
}
pub(crate) async fn delete_by_action(db: &sqlx::Pool<Sqlite>, action: &str) {
sqlx::query!(
"DELETE FROM notification WHERE action_after_reading=? and read_at is null",
@ -217,6 +212,14 @@ ORDER BY read_at DESC, created_at DESC;
.await
.unwrap();
}
pub(crate) async fn delete_by_link(db: &sqlx::Pool<Sqlite>, link: &str) {
let link = Some(link);
sqlx::query!("DELETE FROM notification WHERE link=?", link)
.execute(db)
.await
.unwrap();
}
}
#[cfg(test)]

View File

@ -7,7 +7,10 @@ use crate::model::{event::Event, trip::Trip, user::User};
pub(crate) async fn get_personal_cal(db: &SqlitePool, user: &User) -> String {
let mut calendar = ICalendar::new("2.0", "ics-rs");
calendar.push(Property::new("X-WR-CALNAME", "ruad.at - Deine Ausfahrten"));
calendar.push(Property::new(
"X-WR-CALNAME",
"Donau Linz - Deine Ausfahrten",
));
let events = Event::all_with_user(db, user).await;
for event in events {

View File

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

View File

@ -1 +1,52 @@
use chrono::{Datelike, Local};
use equatorprice::Level;
use serde::Serialize;
use sqlx::SqlitePool;
use super::{logbook::Logbook, stat::Stat, user::User};
pub(crate) mod cal;
pub(crate) mod equatorprice;
pub(crate) mod rowingbadge;
#[derive(Serialize)]
pub(crate) struct Achievements {
pub(crate) equatorprice: equatorprice::Next,
pub(crate) curr_equatorprice_name: String,
pub(crate) new_equatorprice_this_season: bool,
pub(crate) rowingbadge: Option<rowingbadge::Status>,
pub(crate) all_time_km: i32,
pub(crate) year_first_mentioned: Option<i32>,
pub(crate) year_last_mentioned: Option<i32>,
}
impl Achievements {
pub(crate) async fn for_user(db: &SqlitePool, user: &User) -> Self {
let rowed_km = Stat::total_km(db, user).await.rowed_km;
let rowed_km_this_season = if Local::now().month() == 1 {
Stat::person(db, Some(Local::now().year() - 1), user)
.await
.rowed_km
+ Stat::person(db, Some(Local::now().year()), user)
.await
.rowed_km
} else {
Stat::person(db, Some(Local::now().year()), user)
.await
.rowed_km
};
let new_equatorprice_this_season =
Level::curr_level(rowed_km) != Level::curr_level(rowed_km - rowed_km_this_season);
Self {
equatorprice: equatorprice::Next::new(rowed_km),
curr_equatorprice_name: equatorprice::Level::curr_level(rowed_km).desc().to_string(),
new_equatorprice_this_season,
rowingbadge: rowingbadge::Status::for_user(db, user).await,
all_time_km: rowed_km,
year_first_mentioned: Logbook::year_first_logbook_entry(db, user).await,
year_last_mentioned: Logbook::year_last_logbook_entry(db, user).await,
}
}
}

View File

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

102
src/model/rower.rs Normal file
View File

@ -0,0 +1,102 @@
use std::ops::DerefMut;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
use super::{logbook::Logbook, user::User};
#[derive(FromRow, Debug, Serialize, Deserialize)]
pub struct Rower {
pub logbook_id: i64,
pub rower_id: i64,
}
impl Rower {
pub async fn for_log(db: &SqlitePool, log: &Logbook) -> Vec<User> {
let mut tx = db.begin().await.unwrap();
let ret = Self::for_log_tx(&mut tx, log).await;
tx.commit().await.unwrap();
ret
}
pub async fn for_log_tx(db: &mut Transaction<'_, Sqlite>, log: &Logbook) -> Vec<User> {
sqlx::query_as!(
User,
"
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id, user_token
FROM user
WHERE id in (SELECT rower_id FROM rower WHERE logbook_id=?)
",
log.id
)
.fetch_all(db.deref_mut())
.await
.unwrap()
}
pub async fn create(
db: &mut Transaction<'_, Sqlite>,
logbook_id: i64,
rower_id: i64,
) -> Result<(), String> {
//TODO: Check if rower is allowed to row
sqlx::query!(
"INSERT INTO rower(logbook_id, rower_id) VALUES (?,?);",
logbook_id,
rower_id
)
.execute(db.deref_mut())
.await
.map_err(|e| e.to_string())?;
Ok(())
}
}
#[cfg(test)]
mod test {
use sqlx::SqlitePool;
use super::Logbook;
use crate::model::{rower::Rower, user::User};
use crate::testdb;
#[sqlx::test]
fn test_for_log() {
let pool = testdb!();
let logbook = Logbook::find_by_id(&pool, 3).await.unwrap();
let rowers = Rower::for_log(&pool, &logbook).await;
let expected = User::find_by_id(&pool, 3).await.unwrap();
assert_eq!(rowers, vec![expected]);
}
#[sqlx::test]
fn test_for_log_none() {
let pool = testdb!();
let logbook = Logbook::find_by_id(&pool, 2).await.unwrap();
let rowers = Rower::for_log(&pool, &logbook).await;
assert_eq!(rowers, vec![]);
}
#[sqlx::test]
fn test_create() {
let pool = testdb!();
let logbook = Logbook::find_by_id(&pool, 3).await.unwrap();
let mut tx = pool.begin().await.unwrap();
Rower::create(&mut tx, logbook.id, 2).await.unwrap();
tx.commit().await.unwrap();
let rowers = Rower::for_log(&pool, &logbook).await;
assert_eq!(
rowers,
vec![
User::find_by_id(&pool, 2).await.unwrap(),
User::find_by_id(&pool, 3).await.unwrap()
]
);
}
}

335
src/model/stat.rs Normal file
View File

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

31
src/model/trailer.rs Normal file
View File

@ -0,0 +1,31 @@
use std::ops::DerefMut;
use rocket::serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
#[derive(FromRow, Debug, Serialize, Deserialize, Eq, Hash, PartialEq, Clone)]
pub struct Trailer {
pub id: i64,
pub name: String,
}
impl Trailer {
pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> {
sqlx::query_as!(Self, "SELECT id, name FROM trailer WHERE id like ?", id)
.fetch_one(db)
.await
.ok()
}
pub async fn find_by_id_tx(db: &mut Transaction<'_, Sqlite>, id: i32) -> Option<Self> {
sqlx::query_as!(Self, "SELECT id, name FROM trailer WHERE id like ?", id)
.fetch_one(db.deref_mut())
.await
.ok()
}
pub async fn all(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as!(Self, "SELECT id, name FROM trailer")
.fetch_all(db)
.await
.unwrap()
}
}

View File

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

View File

@ -9,7 +9,7 @@ use super::{
notification::Notification,
tripdetails::TripDetails,
triptype::TripType,
user::{SteeringUser, User},
user::{ErgoUser, SteeringUser, User},
usertrip::UserTrip,
};
@ -66,6 +66,16 @@ impl Trip {
Self::perform_new(db, &cox.user, trip_details).await
}
pub async fn new_own_ergo(db: &SqlitePool, ergo: &ErgoUser, trip_details: TripDetails) {
let typ = trip_details.triptype(db).await;
if let Some(typ) = typ {
let allowed_type = TripType::find_by_id(db, 4).await.unwrap();
if typ == allowed_type {
Self::perform_new(db, &ergo.user, trip_details).await;
}
}
}
async fn perform_new(db: &SqlitePool, user: &User, trip_details: TripDetails) {
let _ = sqlx::query!(
"INSERT INTO trip (cox_id, trip_details_id) VALUES(?, ?)",
@ -277,10 +287,8 @@ WHERE day=?
return Err(TripUpdateError::NotYourTrip);
}
if update.trip_type != Some(4) {
if !update.cox.allowed_to_steer(db).await {
return Err(TripUpdateError::TripTypeNotAllowed);
}
if update.trip_type != Some(4) && !update.cox.allowed_to_steer(db).await {
return Err(TripUpdateError::TripTypeNotAllowed);
}
let Some(trip_details_id) = update.trip.trip_details_id else {

View File

@ -1,714 +0,0 @@
use std::ops::{Deref, DerefMut};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use chrono::{Datelike, Local, NaiveDate};
use log::info;
use rocket::{
async_trait,
http::{Cookie, Status},
request,
request::{FromRequest, Outcome},
time::{Duration, OffsetDateTime},
Request,
};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
use super::{log::Log, role::Role, tripdetails::TripDetails, Day};
use crate::{tera::admin::user::UserEditForm, AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD};
#[derive(FromRow, Serialize, Deserialize, Clone, Debug, Eq, Hash, PartialEq)]
pub struct User {
pub id: i64,
pub name: String,
pub pw: Option<String>,
pub deleted: bool,
pub last_access: Option<chrono::NaiveDateTime>,
pub user_token: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UserWithDetails {
#[serde(flatten)]
pub user: User,
pub amount_unread_notifications: i32,
pub allowed_to_steer: bool,
pub roles: Vec<String>,
}
impl UserWithDetails {
pub async fn from_user(user: User, db: &SqlitePool) -> Self {
let allowed_to_steer = user.allowed_to_steer(db).await;
Self {
roles: user.roles(db).await,
amount_unread_notifications: user.amount_unread_notifications(db).await,
allowed_to_steer,
user,
}
}
}
#[derive(Debug)]
pub enum LoginError {
InvalidAuthenticationCombo,
UserNotFound,
UserDeleted,
NotLoggedIn,
NotAnAdmin,
NotACox,
NotATech,
GuestNotAllowed,
NoPasswordSet(Box<User>),
DeserializationError,
}
impl User {
pub async fn allowed_to_steer(&self, db: &SqlitePool) -> bool {
self.has_role(db, "cox").await || self.has_role(db, "Bootsführer").await
}
pub async fn allowed_to_steer_tx(&self, db: &mut Transaction<'_, Sqlite>) -> bool {
self.has_role_tx(db, "cox").await || self.has_role_tx(db, "Bootsführer").await
}
pub async fn amount_unread_notifications(&self, db: &SqlitePool) -> i32 {
sqlx::query!(
"SELECT COUNT(*) as count FROM notification WHERE user_id = ? AND read_at IS NULL",
self.id
)
.fetch_one(db)
.await
.unwrap()
.count
}
pub async fn has_role(&self, db: &SqlitePool, role: &str) -> bool {
if sqlx::query!(
"SELECT * FROM user_role WHERE user_id=? AND role_id = (SELECT id FROM role WHERE name = ?)",
self.id,
role
)
.fetch_optional(db)
.await
.unwrap()
.is_some()
{
return true;
}
false
}
pub async fn allowed_to_update_always_show_trip(&self, db: &SqlitePool) -> bool {
AllowedToUpdateTripToAlwaysBeShownUser::new(db, self.clone())
.await
.is_some()
}
pub async fn roles(&self, db: &SqlitePool) -> Vec<String> {
sqlx::query!(
"SELECT r.name FROM role r JOIN user_role ur ON r.id = ur.role_id JOIN user u ON u.id = ur.user_id WHERE ur.user_id = ? AND u.deleted = 0;",
self.id
)
.fetch_all(db)
.await
.unwrap()
.into_iter().map(|r| r.name).collect()
}
pub async fn real_roles(&self, db: &SqlitePool) -> Vec<Role> {
sqlx::query_as!(
Role,
"SELECT r.id, r.name, r.cluster
FROM role r
JOIN user_role ur ON r.id = ur.role_id
JOIN user u ON u.id = ur.user_id
WHERE ur.user_id = ? AND u.deleted = 0;",
self.id
)
.fetch_all(db)
.await
.unwrap()
}
pub async fn has_role_tx(&self, db: &mut Transaction<'_, Sqlite>, role: &str) -> bool {
if sqlx::query!(
"SELECT * FROM user_role WHERE user_id=? AND role_id = (SELECT id FROM role WHERE name = ?)",
self.id,
role
)
.fetch_optional(db.deref_mut())
.await
.unwrap()
.is_some()
{
return true;
}
false
}
pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> {
sqlx::query_as!(
Self,
"
SELECT id, name, pw, deleted, last_access, user_token
FROM user
WHERE id like ?
",
id
)
.fetch_one(db)
.await
.ok()
}
pub async fn find_by_id_tx(db: &mut Transaction<'_, Sqlite>, id: i32) -> Option<Self> {
sqlx::query_as!(
Self,
"
SELECT id, name, pw, deleted, last_access, user_token
FROM user
WHERE id like ?
",
id
)
.fetch_one(db.deref_mut())
.await
.ok()
}
pub async fn find_by_name(db: &SqlitePool, name: &str) -> Option<Self> {
let name = name.trim().to_lowercase();
sqlx::query_as!(
Self,
"
SELECT id, name, pw, deleted, last_access, user_token
FROM user
WHERE lower(name)=?
",
name
)
.fetch_one(db)
.await
.ok()
}
pub async fn all(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as!(
Self,
"
SELECT id, name, pw, deleted, last_access, user_token
FROM user
WHERE deleted = 0
ORDER BY last_access DESC
"
)
.fetch_all(db)
.await
.unwrap()
}
pub async fn all_with_role(db: &SqlitePool, role: &Role) -> Vec<Self> {
let mut tx = db.begin().await.unwrap();
let ret = Self::all_with_role_tx(&mut tx, role).await;
tx.commit().await.unwrap();
ret
}
pub async fn all_with_role_tx(db: &mut Transaction<'_, Sqlite>, role: &Role) -> Vec<Self> {
sqlx::query_as!(
Self,
"
SELECT id, name, pw, deleted, last_access, user_token
FROM user u
JOIN user_role ur ON u.id = ur.user_id
WHERE ur.role_id = ? AND deleted = 0
ORDER BY name;
",
role.id
)
.fetch_all(db.deref_mut())
.await
.unwrap()
}
pub async fn cox(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as!(
Self,
"
SELECT id, name, pw, deleted, last_access, user_token
FROM user
WHERE deleted = 0 AND (SELECT COUNT(*) FROM user_role WHERE user_id=user.id AND role_id = (SELECT id FROM role WHERE name = 'cox')) > 0
ORDER BY last_access DESC
"
)
.fetch_all(db)
.await
.unwrap()
}
pub async fn create(db: &SqlitePool, name: &str) {
let name = name.trim();
if sqlx::query!("INSERT INTO USER(name) VALUES (?)", name)
.execute(db)
.await
.is_ok()
{
return;
}
sqlx::query!("UPDATE user SET deleted = false where name = ?", name)
.execute(db)
.await
.unwrap();
}
pub async fn update(&self, db: &SqlitePool, data: UserEditForm) -> Result<(), String> {
let mut db = db.begin().await.map_err(|e| e.to_string())?;
sqlx::query!("UPDATE user SET name = ? where id = ?", data.name, self.id)
.execute(db.deref_mut())
.await
.unwrap(); //Okay, because we can only create a User of a valid id
// handle roles
sqlx::query!("DELETE FROM user_role WHERE user_id = ?", self.id)
.execute(db.deref_mut())
.await
.unwrap();
for role_id in data.roles.into_keys() {
let role = Role::find_by_id_tx(&mut db, role_id.parse::<i32>().unwrap())
.await
.unwrap();
self.add_role_tx(&mut db, &role).await?;
}
db.commit().await.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn add_role(&self, db: &SqlitePool, role: &Role) -> Result<(), String> {
sqlx::query!(
"INSERT INTO user_role(user_id, role_id) VALUES (?, ?)",
self.id,
role.id
)
.execute(db)
.await
.map_err(|_| {
format!(
"User already has a role in the cluster '{}'",
role.cluster
.clone()
.expect("db trigger can't activate on empty string")
)
})?;
Ok(())
}
pub async fn add_role_tx(
&self,
db: &mut Transaction<'_, Sqlite>,
role: &Role,
) -> Result<(), String> {
sqlx::query!(
"INSERT INTO user_role(user_id, role_id) VALUES (?, ?)",
self.id,
role.id
)
.execute(db.deref_mut())
.await
.map_err(|_| {
format!(
"User already has a role in the cluster '{}'",
role.cluster
.clone()
.expect("db trigger can't activate on empty string")
)
})?;
Ok(())
}
pub async fn remove_role(&self, db: &SqlitePool, role: &Role) {
sqlx::query!(
"DELETE FROM user_role WHERE user_id = ? and role_id = ?",
self.id,
role.id
)
.execute(db)
.await
.unwrap();
}
pub async fn login(db: &SqlitePool, name: &str, pw: &str) -> Result<Self, LoginError> {
let name = name.trim().to_lowercase(); // just to make sure...
let Some(user) = User::find_by_name(db, &name).await else {
Log::create(db, format!("Username ({name}) not found (tried to login)")).await;
return Err(LoginError::InvalidAuthenticationCombo); // Username not found
};
if user.deleted {
Log::create(
db,
format!("User ({name}) already deleted (tried to login)."),
)
.await;
return Err(LoginError::InvalidAuthenticationCombo); //User existed sometime ago; has
//been deleted
}
if let Some(user_pw) = user.pw.as_ref() {
let password_hash = &Self::get_hashed_pw(pw);
if password_hash == user_pw {
return Ok(user);
}
Log::create(db, format!("User {name} supplied the wrong PW")).await;
Err(LoginError::InvalidAuthenticationCombo)
} else {
info!("User {name} has no PW set");
Err(LoginError::NoPasswordSet(Box::new(user)))
}
}
pub async fn reset_pw(&self, db: &SqlitePool) {
sqlx::query!("UPDATE user SET pw = null where id = ?", self.id)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a User of a valid id
}
pub async fn update_pw(&self, db: &SqlitePool, pw: &str) {
let pw = Self::get_hashed_pw(pw);
sqlx::query!("UPDATE user SET pw = ? where id = ?", pw, self.id)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a User of a valid id
}
fn get_hashed_pw(pw: &str) -> String {
let salt = SaltString::from_b64("dS/X5/sPEKTj4Rzs/CuvzQ").unwrap();
let argon2 = Argon2::default();
argon2
.hash_password(pw.as_bytes(), &salt)
.unwrap()
.to_string()
}
pub async fn logged_in(&self, db: &SqlitePool) {
sqlx::query!(
"UPDATE user SET last_access = CURRENT_TIMESTAMP where id = ?",
self.id
)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a User of a valid id
}
pub async fn delete(&self, db: &SqlitePool) {
sqlx::query!("UPDATE user SET deleted=1 WHERE id=?", self.id)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a User of a valid id
}
pub async fn get_days(&self, db: &SqlitePool) -> Vec<Day> {
let mut days = Vec::new();
for i in 0..self.amount_days_to_show(db).await {
let date = (Local::now() + chrono::Duration::days(i)).date_naive();
if self.has_role(db, "scheckbuch").await {
days.push(Day::new_guest(db, date, false).await);
} else {
days.push(Day::new(db, date, false).await);
}
}
for date in TripDetails::pinned_days(db, self.amount_days_to_show(db).await - 1).await {
if self.has_role(db, "scheckbuch").await {
let day = Day::new_guest(db, date, true).await;
if !day.events.is_empty() {
days.push(day);
}
} else {
days.push(Day::new(db, date, true).await);
}
}
days
}
pub(crate) async fn amount_days_to_show(&self, db: &SqlitePool) -> i64 {
if self.allowed_to_steer(db).await {
let end_of_year = NaiveDate::from_ymd_opt(Local::now().year(), 12, 31).unwrap(); //Ok,
//december
//has 31
//days
let days_left_in_year = end_of_year
.signed_duration_since(Local::now().date_naive())
.num_days()
+ 1;
if days_left_in_year <= 31 {
let end_of_next_year =
NaiveDate::from_ymd_opt(Local::now().year() + 1, 12, 31).unwrap(); //Ok,
//december
//has 31
//days
end_of_next_year
.signed_duration_since(Local::now().date_naive())
.num_days()
+ 1
} else {
days_left_in_year
}
} else {
AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD
}
}
}
#[async_trait]
impl<'r> FromRequest<'r> for User {
type Error = LoginError;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
match req.cookies().get_private("loggedin_user") {
Some(user_id) => match user_id.value().parse::<i32>() {
Ok(user_id) => {
let db = req.rocket().state::<SqlitePool>().unwrap();
let Some(user) = User::find_by_id(db, user_id).await else {
return Outcome::Error((Status::Forbidden, LoginError::UserNotFound));
};
if user.deleted {
return Outcome::Error((Status::Forbidden, LoginError::UserDeleted));
}
user.logged_in(db).await;
let mut cookie = Cookie::new("loggedin_user", format!("{}", user.id));
cookie.set_expires(OffsetDateTime::now_utc() + Duration::weeks(2));
req.cookies().add_private(cookie);
Outcome::Success(user)
}
Err(_) => Outcome::Error((Status::Unauthorized, LoginError::DeserializationError)),
},
None => Outcome::Error((Status::Unauthorized, LoginError::NotLoggedIn)),
}
}
}
/// Creates a struct named $name. Allows to be created from a user, if one of the specified $roles are active for the user.
macro_rules! special_user {
($name:ident, $($role:tt)*) => {
#[derive(Debug)]
pub struct $name {
pub(crate) user: User,
}
impl Deref for $name {
type Target = User;
fn deref(&self) -> &Self::Target {
&self.user
}
}
impl $name {
pub fn into_inner(self) -> User {
self.user
}
}
#[async_trait]
impl<'r> FromRequest<'r> for $name {
type Error = LoginError;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let db = req.rocket().state::<SqlitePool>().unwrap();
match User::from_request(req).await {
Outcome::Success(user) => {
if special_user!(@check_roles user, db, $($role)*) {
Outcome::Success($name { user })
} else {
Outcome::Forward(Status::Forbidden)
}
}
Outcome::Error(f) => Outcome::Error(f),
Outcome::Forward(f) => Outcome::Forward(f),
}
}
}
impl $name {
pub async fn new(db: &SqlitePool, user: User) -> Option<Self> {
if special_user!(@check_roles user, db, $($role)*) {
Some($name { user })
} else {
None
}
}
}
};
(@check_roles $user:ident, $db:ident, $(+$role:expr),* $(,-$neg_role:expr)*) => {
{
let mut has_positive_role = false;
$(
if $user.has_role($db, $role).await {
has_positive_role = true;
}
)*
has_positive_role
$(
&& !$user.has_role($db, $neg_role).await
)*
}
};
}
special_user!(SteeringUser, +"cox");
special_user!(AdminUser, +"admin");
special_user!(EventUser, +"manage_events");
special_user!(ManageUserUser, +"admin");
special_user!(AllowedToUpdateTripToAlwaysBeShownUser, +"admin");
#[cfg(test)]
mod test {
use std::collections::HashMap;
use crate::{tera::admin::user::UserEditForm, testdb};
use super::User;
use sqlx::SqlitePool;
#[sqlx::test]
fn test_find_correct_id() {
let pool = testdb!();
let user = User::find_by_id(&pool, 1).await.unwrap();
assert_eq!(user.id, 1);
}
#[sqlx::test]
fn test_find_wrong_id() {
let pool = testdb!();
let user = User::find_by_id(&pool, 1337).await;
assert!(user.is_none());
}
#[sqlx::test]
fn test_find_correct_name() {
let pool = testdb!();
let user = User::find_by_name(&pool, "admin".into()).await.unwrap();
assert_eq!(user.id, 1);
}
#[sqlx::test]
fn test_find_wrong_name() {
let pool = testdb!();
let user = User::find_by_name(&pool, "name-does-not-exist".into()).await;
assert!(user.is_none());
}
#[sqlx::test]
fn test_all() {
let pool = testdb!();
let res = User::all(&pool).await;
assert!(res.len() > 3);
}
#[sqlx::test]
fn test_cox() {
let pool = testdb!();
let res = User::cox(&pool).await;
assert_eq!(res.len(), 4);
}
#[sqlx::test]
fn test_succ_create() {
let pool = testdb!();
User::create(&pool, "new-user-name".into()).await;
}
#[sqlx::test]
fn test_duplicate_name_create() {
let pool = testdb!();
User::create(&pool, "admin".into()).await;
}
#[sqlx::test]
fn test_update() {
let pool = testdb!();
let user = User::find_by_id(&pool, 1).await.unwrap();
user.update(
&pool,
UserEditForm {
id: 1,
name: "adminn".to_string(),
roles: HashMap::new(),
},
)
.await
.unwrap();
let user = User::find_by_id(&pool, 1).await.unwrap();
assert_eq!(user.name, "adminn".to_string());
}
#[sqlx::test]
fn succ_login_with_test_db() {
let pool = testdb!();
User::login(&pool, "admin".into(), "admin".into())
.await
.unwrap();
}
#[sqlx::test]
fn wrong_pw() {
let pool = testdb!();
assert!(User::login(&pool, "admin".into(), "admi".into())
.await
.is_err());
}
#[sqlx::test]
fn wrong_username() {
let pool = testdb!();
assert!(User::login(&pool, "admi".into(), "admin".into())
.await
.is_err());
}
#[sqlx::test]
fn reset() {
let pool = testdb!();
let user = User::find_by_id(&pool, 1).await.unwrap();
user.reset_pw(&pool).await;
let user = User::find_by_id(&pool, 1).await.unwrap();
assert_eq!(user.pw, None);
}
#[sqlx::test]
fn update_pw() {
let pool = testdb!();
let user = User::find_by_id(&pool, 1).await.unwrap();
assert!(User::login(&pool, "admin".into(), "abc".into())
.await
.is_err());
user.update_pw(&pool, "abc".into()).await;
User::login(&pool, "admin".into(), "abc".into())
.await
.unwrap();
}
}

58
src/model/user/fee.rs Normal file
View File

@ -0,0 +1,58 @@
use super::User;
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct Fee {
pub sum_in_cents: i64,
pub parts: Vec<(String, i64)>,
pub name: String,
pub user_ids: String,
pub paid: bool,
pub users: Vec<User>,
}
impl Default for Fee {
fn default() -> Self {
Self::new()
}
}
impl Fee {
pub fn new() -> Self {
Self {
sum_in_cents: 0,
name: "".into(),
parts: Vec::new(),
user_ids: "".into(),
users: Vec::new(),
paid: false,
}
}
pub fn add(&mut self, desc: String, price_in_cents: i64) {
self.sum_in_cents += price_in_cents;
self.parts.push((desc, price_in_cents));
}
pub fn add_person(&mut self, user: &User) {
if !self.name.is_empty() {
self.name.push_str(" + ");
self.user_ids.push('&');
}
self.name.push_str(&user.name);
self.user_ids.push_str(&format!("user_ids[]={}", user.id));
self.users.push(user.clone());
}
pub fn paid(&mut self) {
self.paid = true;
}
pub fn merge(&mut self, fee: Fee) {
for (desc, price_in_cents) in fee.parts {
self.add(desc, price_in_cents);
}
}
}

1367
src/model/user/mod.rs Normal file

File diff suppressed because it is too large Load Diff

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

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

View File

@ -45,7 +45,7 @@ async fn create(
)
.await;
Flash::success(Redirect::to("/"), "Event hinzugefügt")
Flash::success(Redirect::to("/planned"), "Event hinzugefügt")
}
//TODO: add constraints (e.g. planned_amount_cox > 0)
@ -79,21 +79,21 @@ async fn update(
match Event::find_by_id(db, data.id).await {
Some(planned_event) => {
planned_event.update(db, &update).await;
Flash::success(Redirect::to("/"), "Event erfolgreich bearbeitet")
Flash::success(Redirect::to("/planned"), "Event erfolgreich bearbeitet")
}
None => Flash::error(Redirect::to("/"), "Planned event id not found"),
None => Flash::error(Redirect::to("/planned"), "Planned event id not found"),
}
}
#[get("/planned-event/<id>/delete")]
async fn delete(db: &State<SqlitePool>, id: i64, _admin: EventUser) -> Flash<Redirect> {
let Some(event) = Event::find_by_id(db, id).await else {
return Flash::error(Redirect::to("/"), "Event does not exist");
return Flash::error(Redirect::to("/planned"), "Event does not exist");
};
match event.delete(db).await {
Ok(()) => Flash::success(Redirect::to("/"), "Event gelöscht"),
Err(e) => Flash::error(Redirect::to("/"), e),
Ok(()) => Flash::success(Redirect::to("/planned"), "Event gelöscht"),
Err(e) => Flash::error(Redirect::to("/planned"), e),
}
}
@ -132,7 +132,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -163,7 +163,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -199,7 +199,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -238,7 +238,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -269,7 +269,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()

86
src/tera/admin/mail.rs Normal file
View File

@ -0,0 +1,86 @@
use rocket::form::Form;
use rocket::fs::TempFile;
use rocket::response::{Flash, Redirect};
use rocket::{get, request::FlashMessage, routes, Route, State};
use rocket::{post, FromForm};
use rocket_dyn_templates::{tera::Context, Template};
use sqlx::SqlitePool;
use crate::model::log::Log;
use crate::model::mail::Mail;
use crate::model::role::Role;
use crate::model::user::UserWithDetails;
use crate::model::user::{AdminUser, VorstandUser};
use crate::tera::Config;
#[get("/mail")]
async fn index(
db: &State<SqlitePool>,
admin: VorstandUser,
flash: Option<FlashMessage<'_>>,
) -> Template {
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
let roles = Role::all(db).await;
context.insert(
"loggedin_user",
&UserWithDetails::from_user(admin.user, db).await,
);
context.insert("roles", &roles);
Template::render("admin/mail", context.into_json())
}
#[get("/mail/fee")]
async fn fee(db: &State<SqlitePool>, admin: AdminUser, config: &State<Config>) -> &'static str {
Log::create(db, format!("{admin:?} trying to send fee")).await;
Mail::fees(db, config.smtp_pw.clone()).await;
"SUCC"
}
#[get("/mail/fee-final")]
async fn fee_final(
db: &State<SqlitePool>,
admin: AdminUser,
config: &State<Config>,
) -> &'static str {
Log::create(db, format!("{admin:?} trying to send fee_final")).await;
Mail::fees_final(db, config.smtp_pw.clone()).await;
"SUCC"
}
#[derive(FromForm, Debug)]
pub struct MailToSend<'a> {
pub(crate) role_id: i32,
pub(crate) subject: String,
pub(crate) body: String,
pub(crate) files: Vec<TempFile<'a>>,
}
#[post("/mail", data = "<data>")]
async fn update(
db: &State<SqlitePool>,
data: Form<MailToSend<'_>>,
config: &State<Config>,
admin: VorstandUser,
) -> Flash<Redirect> {
let d = data.into_inner();
Log::create(db, format!("{admin:?} trying to send this mail: {d:?}")).await;
if Mail::send(db, d, config.smtp_pw.clone()).await {
Log::create(db, "Mail successfully sent".into()).await;
Flash::success(Redirect::to("/admin/mail"), "Mail versendet")
} else {
Log::create(db, "Error sending the mail".into()).await;
Flash::error(Redirect::to("/admin/mail"), "Fehler")
}
}
pub fn routes() -> Vec<Route> {
routes![index, update, fee, fee_final]
}
#[cfg(test)]
mod test {}

View File

@ -1,22 +1,86 @@
use rocket::{get, routes, Route, State};
use csv::ReaderBuilder;
use rocket::{form::Form, get, post, routes, FromForm, Route, State};
use rocket_dyn_templates::{context, Template};
use sqlx::SqlitePool;
use super::notification;
use crate::model::{log::Log, user::AdminUser};
use crate::{
model::{log::Log, role::Role, user::AdminUser},
tera::Config,
};
pub mod boat;
pub mod event;
pub mod mail;
pub mod notification;
pub mod schnupper;
pub mod user;
#[get("/log")]
async fn log(db: &State<SqlitePool>, _admin: AdminUser) -> String {
#[get("/rss?<key>")]
async fn rss(db: &State<SqlitePool>, key: &str, config: &State<Config>) -> String {
if key.eq(&config.rss_key) {
Log::generate_feed(db).await
} else {
"Not allowed".into()
}
}
#[get("/rss", rank = 2)]
async fn show_rss(db: &State<SqlitePool>, _admin: AdminUser) -> String {
Log::show(db).await
}
#[get("/list")]
async fn show_list(_admin: AdminUser) -> Template {
Template::render("admin/list/index", context!())
}
#[derive(FromForm)]
struct ListForm {
list: String,
}
#[post("/list", data = "<list_form>")]
async fn list(db: &State<SqlitePool>, _admin: AdminUser, list_form: Form<ListForm>) -> Template {
let role = Role::find_by_name(db, "Donau Linz").await.unwrap();
let acceptable_users = role.names_from_role(db).await;
let mut rdr = ReaderBuilder::new()
.has_headers(true)
.delimiter(b';')
.from_reader(list_form.list.trim().as_bytes());
let mut names_not_in_acceptable_users = Vec::new();
for result in rdr.records() {
println!("{result:?}");
let record = result.unwrap();
// Concatenate Vorname and Nachname
let vorname = record.get(2).unwrap_or_default().trim();
let nachname = record.get(3).unwrap_or_default().trim();
let full_name = format!("{} {}", vorname, nachname);
// Check if the concatenated name is not in the acceptable_users vector
if !acceptable_users.contains(&full_name) {
names_not_in_acceptable_users.push(full_name);
}
}
let context = context! {
result: names_not_in_acceptable_users
};
Template::render("admin/list/result", context)
}
pub fn routes() -> Vec<Route> {
let mut ret = Vec::new();
ret.append(&mut user::routes());
ret.append(&mut schnupper::routes());
ret.append(&mut boat::routes());
ret.append(&mut notification::routes());
ret.append(&mut mail::routes());
ret.append(&mut event::routes());
ret.append(&mut routes![log]);
ret.append(&mut routes![rss, show_rss, show_list, list]);
ret
}

View File

@ -0,0 +1,116 @@
use crate::model::{
log::Log,
notification::Notification,
role::Role,
user::{User, UserWithDetails, VorstandUser},
};
use itertools::Itertools;
use rocket::{
form::Form,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
routes, FromForm, Route, State,
};
use rocket_dyn_templates::{tera::Context, Template};
use sqlx::SqlitePool;
#[get("/notification")]
async fn index(
db: &State<SqlitePool>,
user: VorstandUser,
flash: Option<FlashMessage<'_>>,
) -> Template {
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert(
"loggedin_user",
&UserWithDetails::from_user(user.user, db).await,
);
let users: Vec<User> = User::all(db)
.await
.into_iter()
.filter(|u| u.last_access.is_some()) // Not useful to send notifications to people who are
// not logging in
.sorted_by_key(|u| u.name.clone())
.collect();
context.insert("roles", &Role::all(db).await);
context.insert("users", &users);
Template::render("admin/notification", context.into_json())
}
#[derive(FromForm, Debug)]
pub struct NotificationToSendGroup {
pub(crate) role_id: i32,
pub(crate) category: String,
pub(crate) message: String,
}
#[derive(FromForm, Debug)]
pub struct NotificationToSendUser {
pub(crate) user_id: i32,
pub(crate) category: String,
pub(crate) message: String,
}
#[post("/notification/group", data = "<data>")]
async fn send_group(
db: &State<SqlitePool>,
data: Form<NotificationToSendGroup>,
admin: VorstandUser,
) -> Flash<Redirect> {
let d = data.into_inner();
Log::create(
db,
format!("{admin:?} trying to send this notification: {d:?}"),
)
.await;
let Some(role) = Role::find_by_id(db, d.role_id).await else {
return Flash::error(Redirect::to("/admin/notification"), "Rolle gibt's ned");
};
for user in User::all_with_role(db, &role).await {
Notification::create(db, &user, &d.message, &d.category, None, None).await;
}
Log::create(db, "Notification successfully sent".into()).await;
Flash::success(
Redirect::to("/admin/notification"),
"Nachricht ausgeschickt",
)
}
#[post("/notification/user", data = "<data>")]
async fn send_user(
db: &State<SqlitePool>,
data: Form<NotificationToSendUser>,
admin: VorstandUser,
) -> Flash<Redirect> {
let d = data.into_inner();
Log::create(
db,
format!("{admin:?} trying to send this notification: {d:?}"),
)
.await;
let Some(user) = User::find_by_id(db, d.user_id).await else {
return Flash::error(Redirect::to("/admin/notification"), "User gibt's ned");
};
Notification::create(db, &user, &d.message, &d.category, None, None).await;
Log::create(db, "Notification successfully sent".into()).await;
Flash::success(
Redirect::to("/admin/notification"),
"Nachricht ausgeschickt",
)
}
pub fn routes() -> Vec<Route> {
routes![index, send_user, send_group]
}

View File

@ -0,0 +1,40 @@
use crate::model::{
role::Role,
user::{SchnupperBetreuerUser, User, UserWithDetails},
};
use futures::future::join_all;
use rocket::{get, request::FlashMessage, routes, Route, State};
use rocket_dyn_templates::{tera::Context, Template};
use sqlx::SqlitePool;
#[get("/schnupper")]
async fn index(
db: &State<SqlitePool>,
user: SchnupperBetreuerUser,
flash: Option<FlashMessage<'_>>,
) -> Template {
let schnupperant = Role::find_by_name(db, "schnupperant").await.unwrap();
let user_futures: Vec<_> = User::all_with_role(db, &schnupperant)
.await
.into_iter()
.map(|u| async move { UserWithDetails::from_user(u, db).await })
.collect();
let users: Vec<UserWithDetails> = join_all(user_futures).await;
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("schnupperanten", &users);
context.insert(
"loggedin_user",
&UserWithDetails::from_user(user.user, db).await,
);
Template::render("admin/schnupper/index", context.into_json())
}
pub fn routes() -> Vec<Route> {
routes![index]
}

View File

@ -1,15 +1,25 @@
use std::collections::HashMap;
use crate::model::{
log::Log,
role::Role,
user::{AdminUser, ManageUserUser, User, UserWithDetails},
use crate::{
model::{
family::Family,
log::Log,
logbook::Logbook,
role::Role,
user::{
AdminUser, AllowedToEditPaymentStatusUser, ManageUserUser, SchnupperBetreuerUser, User,
UserWithDetails, UserWithMembershipPdf, UserWithRolesAndMembershipPdf, VorstandUser,
},
},
tera::Config,
};
use futures::future::join_all;
use lettre::Address;
use rocket::{
form::Form,
fs::TempFile,
get,
http::Status,
http::{ContentType, Status},
post,
request::{FlashMessage, FromRequest, Outcome},
response::{Flash, Redirect},
@ -36,21 +46,22 @@ impl<'r> FromRequest<'r> for Referer {
#[get("/user")]
async fn index(
db: &State<SqlitePool>,
user: ManageUserUser,
user: VorstandUser,
flash: Option<FlashMessage<'_>>,
) -> Template {
let user_futures: Vec<_> = User::all(db)
.await
.into_iter()
.map(|u| async move { UserWithDetails::from_user(u, db).await })
.map(|u| async move { UserWithRolesAndMembershipPdf::from_user(db, u).await })
.collect();
let user: User = user.into_inner();
let allowed_to_edit = ManageUserUser::new(db, user.clone()).await.is_some();
let users: Vec<UserWithDetails> = join_all(user_futures).await;
let users: Vec<UserWithRolesAndMembershipPdf> = join_all(user_futures).await;
let roles = Role::all(db).await;
let families = Family::all_with_members(db).await;
let mut context = Context::new();
if let Some(msg) = flash {
@ -59,6 +70,7 @@ async fn index(
context.insert("allowed_to_edit", &allowed_to_edit);
context.insert("users", &users);
context.insert("roles", &roles);
context.insert("families", &families);
context.insert("loggedin_user", &UserWithDetails::from_user(user, db).await);
Template::render("admin/user/index", context.into_json())
@ -73,14 +85,15 @@ async fn index_admin(
let user_futures: Vec<_> = User::all(db)
.await
.into_iter()
.map(|u| async move { UserWithDetails::from_user(u, db).await })
.map(|u| async move { UserWithRolesAndMembershipPdf::from_user(db, u).await })
.collect();
let users: Vec<UserWithDetails> = join_all(user_futures).await;
let users: Vec<UserWithRolesAndMembershipPdf> = join_all(user_futures).await;
let user: User = user.user;
let allowed_to_edit = ManageUserUser::new(db, user.clone()).await.is_some();
let roles = Role::all(db).await;
let families = Family::all_with_members(db).await;
let mut context = Context::new();
if let Some(msg) = flash {
@ -89,11 +102,137 @@ async fn index_admin(
context.insert("allowed_to_edit", &allowed_to_edit);
context.insert("users", &users);
context.insert("roles", &roles);
context.insert("families", &families);
context.insert("loggedin_user", &UserWithDetails::from_user(user, db).await);
Template::render("admin/user/index", context.into_json())
}
#[get("/user/fees")]
async fn fees(
db: &State<SqlitePool>,
user: VorstandUser,
flash: Option<FlashMessage<'_>>,
) -> Template {
let mut context = Context::new();
let users = User::all_payer_groups(db).await;
let mut fees = Vec::new();
for user in users {
if let Some(fee) = user.fee(db).await {
fees.push(fee);
}
}
context.insert("fees", &fees);
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert(
"loggedin_user",
&UserWithDetails::from_user(user.into_inner(), db).await,
);
Template::render("admin/user/fees", context.into_json())
}
#[get("/user/scheckbuch")]
async fn scheckbuch(
db: &State<SqlitePool>,
user: VorstandUser,
flash: Option<FlashMessage<'_>>,
) -> Template {
let mut context = Context::new();
let scheckbooks = Role::find_by_name(db, "scheckbuch").await.unwrap();
let scheckbooks = User::all_with_role(db, &scheckbooks).await;
let mut scheckbooks_with_roles = Vec::new();
for s in scheckbooks {
scheckbooks_with_roles.push((
Logbook::completed_with_user(db, &s).await,
UserWithDetails::from_user(s, db).await,
))
}
context.insert("scheckbooks", &scheckbooks_with_roles);
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert(
"loggedin_user",
&UserWithDetails::from_user(user.into_inner(), db).await,
);
Template::render("admin/user/scheckbuch", context.into_json())
}
#[get("/user/fees/paid?<user_ids>")]
async fn fees_paid(
db: &State<SqlitePool>,
calling_user: AllowedToEditPaymentStatusUser,
user_ids: Vec<i32>,
referer: Referer,
) -> Flash<Redirect> {
let mut res = String::new();
for user_id in user_ids {
let user = User::find_by_id(db, user_id).await.unwrap();
res.push_str(&format!("{} + ", user.name));
if user.has_role(db, "paid").await {
Log::create(
db,
format!(
"{} set fees NOT paid for '{}'",
calling_user.user.name, user.name
),
)
.await;
user.remove_role(db, &Role::find_by_name(db, "paid").await.unwrap())
.await;
} else {
Log::create(
db,
format!(
"{} set fees paid for '{}'",
calling_user.user.name, user.name
),
)
.await;
user.add_role(db, &Role::find_by_name(db, "paid").await.unwrap())
.await
.expect("paid role has no group");
}
}
res.truncate(res.len() - 3); // remove ' + ' from the end
Flash::success(
Redirect::to(referer.0),
format!("Zahlungsstatus von {} erfolgreich geändert", res),
)
}
#[get("/user/<user>/send-welcome-mail")]
async fn send_welcome_mail(
db: &State<SqlitePool>,
_admin: ManageUserUser,
config: &State<Config>,
user: i32,
) -> Flash<Redirect> {
let Some(user) = User::find_by_id(db, user).await else {
return Flash::error(Redirect::to("/admin/user"), "User does not exist");
};
match user.send_welcome_email(db, &config.smtp_pw).await {
Ok(()) => Flash::success(
Redirect::to("/admin/user"),
format!("Willkommens-Email wurde an {} versandt.", user.name),
),
Err(e) => Flash::error(Redirect::to("/admin/user"), e),
}
}
#[get("/user/<user>/reset-pw")]
async fn resetpw(db: &State<SqlitePool>, admin: ManageUserUser, user: i32) -> Flash<Redirect> {
let user = User::find_by_id(db, user).await;
@ -131,16 +270,27 @@ async fn delete(db: &State<SqlitePool>, admin: ManageUserUser, user: i32) -> Fla
}
#[derive(FromForm, Debug)]
pub struct UserEditForm {
pub struct UserEditForm<'a> {
pub(crate) id: i32,
pub(crate) name: String,
pub(crate) dob: Option<String>,
pub(crate) weight: Option<String>,
pub(crate) sex: Option<String>,
pub(crate) roles: HashMap<String, String>,
pub(crate) member_since_date: Option<String>,
pub(crate) birthdate: Option<String>,
pub(crate) mail: Option<String>,
pub(crate) nickname: Option<String>,
pub(crate) notes: Option<String>,
pub(crate) phone: Option<String>,
pub(crate) address: Option<String>,
pub(crate) family_id: Option<i64>,
pub(crate) membership_pdf: Option<TempFile<'a>>,
}
#[post("/user", data = "<data>", format = "multipart/form-data")]
async fn update(
db: &State<SqlitePool>,
data: Form<UserEditForm>,
data: Form<UserEditForm<'_>>,
admin: ManageUserUser,
) -> Flash<Redirect> {
let user = User::find_by_id(db, data.id).await;
@ -157,14 +307,31 @@ async fn update(
};
match user.update(db, data.into_inner()).await {
Ok(_) => Flash::success(
Redirect::to("/admin/user"),
"Mitglied erfolgreich geändert!",
),
Ok(_) => Flash::success(Redirect::to("/admin/user"), "Successfully updated user"),
Err(e) => Flash::error(Redirect::to("/admin/user"), e),
}
}
#[get("/user/<user>/membership")]
async fn download_membership_pdf(
db: &State<SqlitePool>,
admin: ManageUserUser,
user: i32,
) -> (ContentType, Vec<u8>) {
let user = User::find_by_id(db, user).await.unwrap();
let user = UserWithMembershipPdf::from(db, user).await;
Log::create(
db,
format!(
"{} downloaded membership application for user: {}",
admin.user.name, user.user.name
),
)
.await;
(ContentType::PDF, user.membership_pdf.unwrap())
}
#[derive(FromForm, Debug)]
struct UserAddForm<'r> {
name: &'r str,
@ -176,20 +343,138 @@ async fn create(
data: Form<UserAddForm<'_>>,
admin: ManageUserUser,
) -> Flash<Redirect> {
User::create(db, data.name).await;
if User::create(db, data.name).await {
Log::create(
db,
format!("{} created new user: {data:?}", admin.user.name),
)
.await;
Flash::success(Redirect::to("/admin/user"), "Successfully created user")
} else {
Flash::error(
Redirect::to("/admin/user"),
format!("User {} already exists", data.name),
)
}
}
#[derive(FromForm, Debug)]
struct UserAddScheckbuchForm<'r> {
name: &'r str,
mail: &'r str,
}
#[post("/user/new/scheckbuch", data = "<data>")]
async fn create_scheckbuch(
db: &State<SqlitePool>,
data: Form<UserAddScheckbuchForm<'_>>,
admin: VorstandUser,
config: &State<Config>,
) -> Flash<Redirect> {
// 1. Check mail adress
let mail = data.mail.trim();
if mail.parse::<Address>().is_err() {
return Flash::error(
Redirect::to("/admin/user/scheckbuch"),
"Keine gültige Mailadresse".to_string(),
);
}
// 2. Check name
let name = data.name.trim();
if User::find_by_name(db, name).await.is_some() {
return Flash::error(
Redirect::to("/admin/user/scheckbuch"),
"Kann kein Scheckbuch erstellen, der Name wird bereits von einem User verwendet"
.to_string(),
);
}
// 3. Create user
User::create_with_mail(db, name, mail).await;
let user = User::find_by_name(db, name).await.unwrap();
// 4. Add 'scheckbuch' role
let scheckbuch = Role::find_by_name(db, "scheckbuch").await.unwrap();
user.add_role(db, &scheckbuch)
.await
.expect("new user has no roles yet");
// 4. Send welcome mail (+ notification)
user.send_welcome_email(db, &config.smtp_pw).await.unwrap();
Log::create(
db,
format!("{} created new user: {data:?}", admin.user.name),
format!("{} created new scheckbuch: {data:?}", admin.name),
)
.await;
Flash::success(Redirect::to("/admin/user/scheckbuch"), format!("Scheckbuch erfolgreich erstellt. Eine E-Mail in der alles erklärt wird, wurde an {mail} verschickt."))
}
Flash::success(
Redirect::to("/admin/user"),
"Mitglied erfolgreich angelegt!",
#[get("/user/move/schnupperant/<id>/to/scheckbuch")]
async fn schnupper_to_scheckbuch(
db: &State<SqlitePool>,
id: i32,
admin: SchnupperBetreuerUser,
config: &State<Config>,
) -> Flash<Redirect> {
let Some(user) = User::find_by_id(db, id).await else {
return Flash::error(
Redirect::to("/admin/schnupper"),
"user id not found".to_string(),
);
};
if !user.has_role(db, "schnupperant").await {
return Flash::error(
Redirect::to("/admin/schnupper"),
"kein schnupperant...".to_string(),
);
}
let schnupperant = Role::find_by_name(db, "schnupperant").await.unwrap();
let paid = Role::find_by_name(db, "paid").await.unwrap();
user.remove_role(db, &schnupperant).await;
user.remove_role(db, &paid).await;
let scheckbuch = Role::find_by_name(db, "scheckbuch").await.unwrap();
user.add_role(db, &scheckbuch)
.await
.expect("just removed 'schnupperant' thus can't have a role with that group");
if let Some(no_einschreibgebuehr) = Role::find_by_name(db, "no-einschreibgebuehr").await {
user.add_role(db, &no_einschreibgebuehr)
.await
.expect("role doesn't have a group");
}
user.send_welcome_email(db, &config.smtp_pw).await.unwrap();
Log::create(
db,
format!(
"{} created new scheckbuch (from schnupperant): {}",
admin.name, user.name
),
)
.await;
Flash::success(Redirect::to("/admin/schnupper"), format!("Scheckbuch erfolgreich erstellt. Eine E-Mail in der alles erklärt wird, wurde an {} verschickt.", user.mail.unwrap()))
}
pub fn routes() -> Vec<Route> {
routes![index, index_admin, resetpw, update, create, delete]
routes![
index,
index_admin,
resetpw,
update,
create,
create_scheckbuch,
schnupper_to_scheckbuch,
delete,
fees,
fees_paid,
scheckbuch,
download_membership_pdf,
send_welcome_mail
]
}

View File

@ -73,7 +73,7 @@ async fn login(
);
}
Err(_) => {
return Flash::error(Redirect::to("/auth"), "Falscher Benutzername/Passwort. Du bist Vereinsmitglied und der Login klappt nicht? Melde dich bitte unter it@verein.tld!");
return Flash::error(Redirect::to("/auth"), "Falscher Benutzername/Passwort. Du bist Vereinsmitglied und der Login klappt nicht? Kontaktiere Philipp H. (Tel.nr. siehe Signalgruppe) oder schreibe eine Mail an it@rudernlinz.at!");
}
};
@ -88,7 +88,15 @@ async fn login(
)
.await;
Flash::success(Redirect::to("/"), "Login erfolgreich")
// Check for redirect_url cookie and redirect accordingly
match cookies.get_private("redirect_url") {
Some(redirect_cookie) => {
let redirect_url = redirect_cookie.value().to_string();
cookies.remove_private(redirect_cookie); // Remove the cookie after using it
Flash::success(Redirect::to(redirect_url), "Login erfolgreich")
}
None => Flash::success(Redirect::to("/"), "Login erfolgreich"),
}
}
#[get("/set-pw/<userid>")]

View File

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

View File

@ -0,0 +1,92 @@
use crate::model::{
boat::Boat,
boathouse::Boathouse,
user::{AdminUser, UserWithDetails, VorstandUser},
};
use rocket::{
form::Form,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
routes, FromForm, Route, State,
};
use rocket_dyn_templates::{tera::Context, Template};
use sqlx::SqlitePool;
#[get("/boathouse")]
async fn index(
db: &State<SqlitePool>,
admin: VorstandUser,
flash: Option<FlashMessage<'_>>,
) -> Template {
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
let boats = Boat::all_for_boatshouse(db).await;
let mut final_boats = Vec::new();
for boat in boats {
if boat.boat.boathouse(db).await.is_none() && !boat.boat.external {
final_boats.push(boat);
}
}
context.insert("boats", &final_boats);
let boathouse = Boathouse::get(db).await;
context.insert("boathouse", &boathouse);
context.insert(
"loggedin_user",
&UserWithDetails::from_user(admin.into_inner(), db).await,
);
Template::render("board/boathouse", context.into_json())
}
#[derive(FromForm)]
pub struct FormBoathouseToAdd {
pub boat_id: i32,
pub aisle: String,
pub side: String,
pub level: i32,
}
#[post("/boathouse", data = "<data>")]
async fn new<'r>(
db: &State<SqlitePool>,
data: Form<FormBoathouseToAdd>,
_admin: AdminUser,
) -> Flash<Redirect> {
match Boathouse::create(db, data.into_inner()).await {
Ok(_) => Flash::success(Redirect::to("/board/boathouse"), "Boot hinzugefügt"),
Err(e) => Flash::error(Redirect::to("/board/boathouse"), e),
}
}
#[get("/boathouse/<boathouse_id>/delete")]
async fn delete(db: &State<SqlitePool>, _admin: AdminUser, boathouse_id: i32) -> Flash<Redirect> {
let boat = Boathouse::find_by_id(db, boathouse_id).await;
match boat {
Some(boat) => {
boat.delete(db).await;
Flash::success(Redirect::to("/board/boathouse"), "Bootsplatz gelöscht")
}
None => Flash::error(Redirect::to("/board/boathouse"), "Boatplace does not exist"),
}
}
//#[post("/boat/new", data = "<data>")]
//async fn create(
// db: &State<SqlitePool>,
// data: Form<BoatToAdd<'_>>,
// _admin: AdminUser,
//) -> Flash<Redirect> {
// match Boat::create(db, data.into_inner()).await {
// Ok(_) => Flash::success(Redirect::to("/admin/boat"), "Boot hinzugefügt"),
// Err(e) => Flash::error(Redirect::to("/admin/boat"), e),
// }
//}
pub fn routes() -> Vec<Route> {
routes![index, new, delete]
}

11
src/tera/board/mod.rs Normal file
View File

@ -0,0 +1,11 @@
use rocket::Route;
pub mod achievement;
pub mod boathouse;
pub fn routes() -> Vec<Route> {
let mut ret = Vec::new();
ret.append(&mut boathouse::routes());
ret.append(&mut achievement::routes());
ret
}

181
src/tera/boatdamage.rs Normal file
View File

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

223
src/tera/boatreservation.rs Normal file
View File

@ -0,0 +1,223 @@
use chrono::NaiveDate;
use rocket::{
form::Form,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
routes, FromForm, Route, State,
};
use rocket_dyn_templates::Template;
use sqlx::SqlitePool;
use tera::Context;
use crate::{
model::{
boat::Boat,
boatreservation::{BoatReservation, BoatReservationToAdd},
log::Log,
user::{DonauLinzUser, User, UserWithDetails},
},
tera::log::KioskCookie,
};
#[get("/")]
async fn index_kiosk(
db: &State<SqlitePool>,
flash: Option<FlashMessage<'_>>,
_kiosk: KioskCookie,
) -> Template {
let boatreservations = BoatReservation::all_future(db).await;
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
let linz_boats = Boat::all_for_boatshouse(db).await;
let mut boats = Vec::new();
for boat in linz_boats {
if boat.boat.owner.is_none() {
boats.push(boat);
}
}
context.insert("boatreservations", &boatreservations);
context.insert("boats", &boats);
context.insert("user", &User::all(db).await);
context.insert("show_kiosk_header", &true);
Template::render("boatreservations", context.into_json())
}
#[get("/", rank = 2)]
async fn index(
db: &State<SqlitePool>,
flash: Option<FlashMessage<'_>>,
user: DonauLinzUser,
) -> Template {
let boatreservations = BoatReservation::all_future(db).await;
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
let linz_boats = Boat::all_for_boatshouse(db).await;
let mut boats = Vec::new();
for boat in linz_boats {
if boat.boat.owner.is_none() {
boats.push(boat);
}
}
context.insert("boatreservations", &boatreservations);
context.insert("boats", &boats);
context.insert("user", &User::all(db).await);
context.insert(
"loggedin_user",
&UserWithDetails::from_user(user.into_inner(), db).await,
);
Template::render("boatreservations", context.into_json())
}
#[derive(Debug, FromForm)]
pub struct FormBoatReservationToAdd<'r> {
pub boat_id: i64,
pub start_date: &'r str,
pub end_date: &'r str,
pub time_desc: &'r str,
pub usage: &'r str,
pub user_id_applicant: Option<i64>,
}
#[post("/new", data = "<data>", rank = 2)]
async fn create<'r>(
db: &State<SqlitePool>,
data: Form<FormBoatReservationToAdd<'r>>,
user: DonauLinzUser,
) -> Flash<Redirect> {
let user_applicant: User = user.into_inner();
let boat = Boat::find_by_id(db, data.boat_id as i32).await.unwrap();
let boatreservation_to_add = BoatReservationToAdd {
boat: &boat,
start_date: NaiveDate::parse_from_str(data.start_date, "%Y-%m-%d").unwrap(),
end_date: NaiveDate::parse_from_str(data.end_date, "%Y-%m-%d").unwrap(),
time_desc: data.time_desc,
usage: data.usage,
user_applicant: &user_applicant,
};
match BoatReservation::create(db, boatreservation_to_add).await {
Ok(_) => Flash::success(
Redirect::to("/boatreservation"),
"Reservierung erfolgreich hinzugefügt",
),
Err(e) => Flash::error(Redirect::to("/boatreservation"), format!("Fehler: {e}")),
}
}
#[post("/new", data = "<data>")]
async fn create_from_kiosk<'r>(
db: &State<SqlitePool>,
data: Form<FormBoatReservationToAdd<'r>>,
_kiosk: KioskCookie,
) -> Flash<Redirect> {
let user_applicant: User = User::find_by_id(db, data.user_id_applicant.unwrap() as i32)
.await
.unwrap();
let boat = Boat::find_by_id(db, data.boat_id as i32).await.unwrap();
let boatreservation_to_add = BoatReservationToAdd {
boat: &boat,
start_date: NaiveDate::parse_from_str(data.start_date, "%Y-%m-%d").unwrap(),
end_date: NaiveDate::parse_from_str(data.end_date, "%Y-%m-%d").unwrap(),
time_desc: data.time_desc,
usage: data.usage,
user_applicant: &user_applicant,
};
match BoatReservation::create(db, boatreservation_to_add).await {
Ok(_) => Flash::success(
Redirect::to("/boatreservation"),
"Reservierung erfolgreich hinzugefügt",
),
Err(e) => Flash::error(Redirect::to("/boatreservation"), format!("Fehler: {e}")),
}
}
#[derive(FromForm, Debug)]
pub struct ReservationEditForm {
pub(crate) id: i32,
pub(crate) time_desc: String,
pub(crate) usage: String,
}
#[post("/", data = "<data>")]
async fn update(
db: &State<SqlitePool>,
data: Form<ReservationEditForm>,
user: User,
) -> Flash<Redirect> {
let Some(reservation) = BoatReservation::find_by_id(db, data.id).await else {
return Flash::error(
Redirect::to("/boatreservation"),
format!("Reservation with ID {} does not exist!", data.id),
);
};
if user.id != reservation.user_id_applicant && !user.has_role(db, "admin").await {
return Flash::error(
Redirect::to("/boatreservation"),
"Not allowed to update reservation (only admins + creator do so).".to_string(),
);
}
Log::create(
db,
format!(
"{} updated reservation from {reservation:?} to {data:?}",
user.name
),
)
.await;
reservation.update(db, data.into_inner()).await;
Flash::success(
Redirect::to("/boatreservation"),
"Reservierung erfolgreich bearbeitet",
)
}
#[get("/<reservation_id>/delete")]
async fn delete<'r>(
db: &State<SqlitePool>,
reservation_id: i32,
user: DonauLinzUser,
) -> Flash<Redirect> {
let reservation = BoatReservation::find_by_id(db, reservation_id)
.await
.unwrap();
if user.id == reservation.user_id_applicant || user.has_role(db, "admin").await {
reservation.delete(db).await;
Flash::success(
Redirect::to("/boatreservation"),
"Reservierung erfolgreich gelöscht",
)
} else {
Flash::error(
Redirect::to("/boatreservation"),
"Nur der Reservierer darf die Reservierung löschen.".to_string(),
)
}
}
pub fn routes() -> Vec<Route> {
routes![
index,
index_kiosk,
create,
create_from_kiosk,
delete,
update
]
}

View File

@ -11,9 +11,32 @@ use crate::model::{
log::Log,
trip::{self, CoxHelpError, Trip, TripDeleteError, TripHelpDeleteError, TripUpdateError},
tripdetails::{TripDetails, TripDetailsToAdd},
user::{AllowedToUpdateTripToAlwaysBeShownUser, SteeringUser, User},
user::{AllowedToUpdateTripToAlwaysBeShownUser, ErgoUser, SteeringUser, User},
};
#[post("/trip", data = "<data>", rank = 2)]
async fn create_ergo(
db: &State<SqlitePool>,
data: Form<TripDetailsToAdd<'_>>,
cox: ErgoUser,
) -> Flash<Redirect> {
let trip_details_id = TripDetails::create(db, data.into_inner()).await;
let trip_details = TripDetails::find_by_id(db, trip_details_id).await.unwrap(); //Okay, bc just
//created
Trip::new_own_ergo(db, &cox, trip_details).await; //TODO: fix
//Log::create(
// db,
// format!(
// "Cox {} created trip on {} @ {} for {} rower",
// cox.name, trip_details.day, trip_details.planned_starting_time, trip_details.max_people,
// ),
//)
//.await;
Flash::success(Redirect::to("/planned"), "Ausfahrt erfolgreich erstellt.")
}
#[post("/trip", data = "<data>")]
async fn create(
db: &State<SqlitePool>,
@ -34,7 +57,7 @@ async fn create(
//)
//.await;
Flash::success(Redirect::to("/"), "Ausfahrt erfolgreich erstellt.")
Flash::success(Redirect::to("/planned"), "Ausfahrt erfolgreich erstellt.")
}
#[derive(FromForm)]
@ -62,19 +85,23 @@ async fn update(
is_locked: data.is_locked,
};
match Trip::update_own(db, &update).await {
Ok(_) => Flash::success(Redirect::to("/"), "Ausfahrt erfolgreich aktualisiert."),
Ok(_) => Flash::success(
Redirect::to("/planned"),
"Ausfahrt erfolgreich aktualisiert.",
),
Err(TripUpdateError::NotYourTrip) => {
Flash::error(Redirect::to("/"), "Nicht deine Ausfahrt!")
}
Err(TripUpdateError::TripTypeNotAllowed) => {
Flash::error(Redirect::to("/"), "Du darfst nur Ergo-Events erstellen")
Flash::error(Redirect::to("/planned"), "Nicht deine Ausfahrt!")
}
Err(TripUpdateError::TripTypeNotAllowed) => Flash::error(
Redirect::to("/planned"),
"Du darfst nur Ergo-Events erstellen",
),
Err(TripUpdateError::TripDetailsDoesNotExist) => {
Flash::error(Redirect::to("/"), "Ausfahrt gibt's nicht")
Flash::error(Redirect::to("/planned"), "Ausfahrt gibt's nicht")
}
}
} else {
Flash::error(Redirect::to("/"), "Ausfahrt gibt's nicht")
Flash::error(Redirect::to("/planned"), "Ausfahrt gibt's nicht")
}
}
@ -86,9 +113,12 @@ async fn toggle_always_show(
) -> Flash<Redirect> {
if let Some(trip) = Trip::find_by_id(db, trip_id).await {
trip.toggle_always_show(db).await;
Flash::success(Redirect::to("/"), "'Immer anzeigen' erfolgreich gesetzt!")
Flash::success(
Redirect::to("/planned"),
"'Immer anzeigen' erfolgreich gesetzt!",
)
} else {
Flash::error(Redirect::to("/"), "Ausfahrt gibt's nicht")
Flash::error(Redirect::to("/planned"), "Ausfahrt gibt's nicht")
}
}
@ -105,24 +135,24 @@ async fn join(db: &State<SqlitePool>, planned_event_id: i64, cox: SteeringUser)
),
)
.await;
Flash::success(Redirect::to("/"), "Danke für's helfen!")
Flash::success(Redirect::to("/planned"), "Danke für's helfen!")
}
Err(CoxHelpError::CanceledEvent) => {
Flash::error(Redirect::to("/"), "Die Ausfahrt wurde leider abgesagt...")
Flash::error(Redirect::to("/planned"), "Die Ausfahrt wurde leider abgesagt...")
}
Err(CoxHelpError::AlreadyRegisteredAsCox) => {
Flash::error(Redirect::to("/"), "Du hilfst bereits aus!")
Flash::error(Redirect::to("/planned"), "Du hilfst bereits aus!")
}
Err(CoxHelpError::AlreadyRegisteredAsRower) => Flash::error(
Redirect::to("/"),
Redirect::to("/planned"),
"Du hast dich bereits als Ruderer angemeldet!",
),
Err(CoxHelpError::DetailsLocked) => {
Flash::error(Redirect::to("/"), "Die Bootseinteilung wurde bereits gemacht. Wenn du noch steuern möchtest, frag bitte bei einer bereits angemeldeten Steuerperson nach, ob das noch möglich ist.")
Flash::error(Redirect::to("/planned"), "Die Bootseinteilung wurde bereits gemacht. Wenn du noch steuern möchtest, frag bitte bei einer bereits angemeldeten Steuerperson nach, ob das noch möglich ist.")
}
}
} else {
Flash::error(Redirect::to("/"), "Event gibt's nicht")
Flash::error(Redirect::to("/planned"), "Event gibt's nicht")
}
}
@ -130,18 +160,18 @@ async fn join(db: &State<SqlitePool>, planned_event_id: i64, cox: SteeringUser)
async fn remove_trip(db: &State<SqlitePool>, trip_id: i64, cox: User) -> Flash<Redirect> {
let trip = Trip::find_by_id(db, trip_id).await;
match trip {
None => Flash::error(Redirect::to("/"), "Trip gibt's nicht!"),
None => Flash::error(Redirect::to("/planned"), "Trip gibt's nicht!"),
Some(trip) => match trip.delete(db, &cox).await {
Ok(_) => {
Log::create(db, format!("Cox {} deleted trip.id={}", cox.name, trip_id)).await;
Flash::success(Redirect::to("/"), "Erfolgreich gelöscht!")
Flash::success(Redirect::to("/planned"), "Erfolgreich gelöscht!")
}
Err(TripDeleteError::SomebodyAlreadyRegistered) => Flash::error(
Redirect::to("/"),
Redirect::to("/planned"),
"Ausfahrt kann nicht gelöscht werden, da bereits jemand registriert ist!",
),
Err(TripDeleteError::NotYourTrip) => {
Flash::error(Redirect::to("/"), "Nicht deine Ausfahrt!")
Flash::error(Redirect::to("/planned"), "Nicht deine Ausfahrt!")
}
},
}
@ -165,23 +195,24 @@ async fn remove(
)
.await;
Flash::success(Redirect::to("/"), "Erfolgreich abgemeldet!")
Flash::success(Redirect::to("/planned"), "Erfolgreich abgemeldet!")
}
Err(TripHelpDeleteError::DetailsLocked) => {
Flash::error(Redirect::to("/"), "Die Bootseinteilung wurde bereits gemacht. Wenn du doch nicht steuern kannst, melde dich bitte unbedingt schnellstmöglich bei einer anderen Steuerperson!")
Flash::error(Redirect::to("/planned"), "Die Bootseinteilung wurde bereits gemacht. Wenn du doch nicht steuern kannst, melde dich bitte unbedingt schnellstmöglich bei einer anderen Steuerperson!")
}
Err(TripHelpDeleteError::CoxNotHelping) => {
Flash::error(Redirect::to("/"), "Steuermann hilft nicht aus...")
Flash::error(Redirect::to("/planned"), "Steuermann hilft nicht aus...")
}
}
} else {
Flash::error(Redirect::to("/"), "Planned_event does not exist.")
Flash::error(Redirect::to("/planned"), "Planned_event does not exist.")
}
}
pub fn routes() -> Vec<Route> {
routes![
create,
create_ergo,
join,
remove,
remove_trip,
@ -229,7 +260,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -279,7 +310,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -318,7 +349,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -358,7 +389,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -386,7 +417,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -399,7 +430,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -423,14 +454,14 @@ mod test {
.body("name=cox&password=cox"); // Add the form data to the request body;
login.dispatch().await;
let req = client.get("/join/1");
let req = client.get("/planned/join/1");
let _ = req.dispatch().await;
let req = client.get("/cox/join/1");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -461,7 +492,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -502,7 +533,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -530,7 +561,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -558,7 +589,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()

365
src/tera/ergo.rs Normal file
View File

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

1111
src/tera/log.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,7 @@
use rocket::{get, http::ContentType, request::FlashMessage, routes, Route, State};
use rocket::{get, http::ContentType, routes, Route, State};
use sqlx::SqlitePool;
use crate::model::{
event::Event,
personal::cal::get_personal_cal,
user::{User, UserWithDetails},
};
use rocket_dyn_templates::Template;
use tera::Context;
use crate::model::{event::Event, personal::cal::get_personal_cal, user::User};
#[get("/cal")]
async fn cal(db: &State<SqlitePool>) -> (ContentType, String) {
@ -15,19 +9,6 @@ async fn cal(db: &State<SqlitePool>) -> (ContentType, String) {
(ContentType::Calendar, Event::get_ics_feed(db).await)
}
#[get("/kalender")]
async fn calinfo(db: &State<SqlitePool>, user: User, flash: Option<FlashMessage<'_>>) -> Template {
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("loggedin_user", &UserWithDetails::from_user(user, db).await);
Template::render("calinfo", context.into_json())
}
#[get("/cal/personal/<user_id>/<uuid>")]
async fn cal_registered(
db: &State<SqlitePool>,
@ -38,7 +19,7 @@ async fn cal_registered(
return Err("Invalid".into());
};
if &user.user_token != uuid {
if user.user_token != uuid {
return Err("Invalid".into());
}
@ -46,7 +27,7 @@ async fn cal_registered(
}
pub fn routes() -> Vec<Route> {
routes![cal, cal_registered, calinfo]
routes![cal, cal_registered]
}
#[cfg(test)]

View File

@ -1,12 +1,14 @@
use std::{fs::OpenOptions, io::Write};
use chrono::Local;
use chrono::{Datelike, Local};
use rocket::{
catch, catchers,
fairing::{AdHoc, Fairing, Info, Kind},
form::Form,
fs::FileServer,
get,
http::Cookie,
post,
request::FlashMessage,
response::{Flash, Redirect},
routes,
@ -18,17 +20,30 @@ use serde::Deserialize;
use sqlx::SqlitePool;
use tera::Context;
use crate::model::{
role::Role,
user::{User, UserWithDetails},
use crate::{
model::{
logbook::Logbook,
notification::Notification,
personal::Achievements,
role::Role,
user::{User, UserWithDetails},
},
SCHECKBUCH,
};
pub(crate) mod admin;
mod auth;
pub(crate) mod board;
mod boatdamage;
pub(crate) mod boatreservation;
mod cox;
mod ergo;
mod log;
mod misc;
mod notification;
mod planned;
mod stat;
pub(crate) mod trailerreservation;
#[derive(FromForm, Debug)]
struct LoginForm<'r> {
@ -36,6 +51,31 @@ struct LoginForm<'r> {
password: &'r str,
}
#[get("/")]
async fn index(db: &State<SqlitePool>, user: User, flash: Option<FlashMessage<'_>>) -> Template {
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
if user.has_role(db, "scheckbuch").await {
let last_trips = Logbook::completed_with_user(db, &user).await;
context.insert("last_trips", &last_trips);
}
let date = chrono::Utc::now();
if date.month() <= 3 || date.month() >= 10 {
context.insert("show_quick_ergo_button", "yes");
}
context.insert("achievements", &Achievements::for_user(db, &user).await);
context.insert("notifications", &Notification::for_user(db, &user).await);
context.insert("loggedin_user", &UserWithDetails::from_user(user, db).await);
context.insert("costs_scheckbuch", &SCHECKBUCH);
Template::render("index", context.into_json())
}
#[get("/impressum")]
async fn impressum(db: &State<SqlitePool>, user: Option<User>) -> Template {
let mut context = Context::new();
@ -67,6 +107,22 @@ async fn steering(db: &State<SqlitePool>, user: User, flash: Option<FlashMessage
Template::render("steering", context.into_json())
}
#[post("/", data = "<login>")]
async fn wikiauth(db: &State<SqlitePool>, login: Form<LoginForm<'_>>) -> String {
if let Ok(user) = User::login(db, login.name, login.password).await {
if user.has_role(db, "allow_website_login").await {
return String::from("SUCC");
}
if user.has_role(db, "admin").await {
return String::from("SUCC");
}
if user.has_role(db, "Vorstand").await {
return String::from("SUCC");
}
}
"FAIL".into()
}
#[catch(401)] //Unauthorized
fn unauthorized_error(req: &Request) -> Redirect {
// Save the URL the user tried to access, to be able to go there once logged in
@ -78,6 +134,60 @@ fn unauthorized_error(req: &Request) -> Redirect {
Redirect::to("/auth")
}
#[derive(FromForm, Debug)]
struct NewBlogpostForm<'r> {
article_url: &'r str,
article_title: &'r str,
pw: &'r str,
}
#[post("/", data = "<blogpost>")]
async fn new_blogpost(
db: &State<SqlitePool>,
blogpost: Form<NewBlogpostForm<'_>>,
config: &State<Config>,
) -> String {
if blogpost.pw == config.wordpress_key {
let member = Role::find_by_name(db, "Donau Linz").await.unwrap();
Notification::create_for_role(
db,
&member,
&urlencoding::decode(blogpost.article_title).expect("UTF-8"),
"Neuer Blogpost",
Some(blogpost.article_url),
None,
)
.await;
"ACK".into()
} else {
"WRONG pw".into()
}
}
#[derive(FromForm, Debug)]
struct BlogpostUnpublishedForm<'r> {
article_url: &'r str,
pw: &'r str,
}
#[post("/", data = "<blogpost>")]
async fn blogpost_unpublished(
db: &State<SqlitePool>,
blogpost: Form<BlogpostUnpublishedForm<'_>>,
config: &State<Config>,
) -> String {
if blogpost.pw == config.wordpress_key {
Notification::delete_by_link(
db,
&urlencoding::decode(blogpost.article_url).expect("UTF-8"),
)
.await;
"ACK".into()
} else {
"WRONG pw".into()
}
}
#[catch(403)] //forbidden
fn forbidden_error() -> Flash<Redirect> {
Flash::error(Redirect::to("/"), "Keine Berechtigung für diese Aktion. Wenn du der Meinung bist, dass du das machen darfst, melde dich bitte bei it@rudernlinz.at.")
@ -152,12 +262,22 @@ pub struct Config {
pub fn config(rocket: Rocket<Build>) -> Rocket<Build> {
rocket
.mount("/", routes![steering, impressum])
.mount("/", routes![index, steering, impressum])
.mount("/auth", auth::routes())
.mount("/", planned::routes())
.mount("/wikiauth", routes![wikiauth])
.mount("/new-blogpost", routes![new_blogpost])
.mount("/blogpost-unpublished", routes![blogpost_unpublished])
.mount("/log", log::routes())
.mount("/planned", planned::routes())
.mount("/ergo", ergo::routes())
.mount("/notification", notification::routes())
.mount("/stat", stat::routes())
.mount("/boatdamage", boatdamage::routes())
.mount("/boatreservation", boatreservation::routes())
.mount("/trailerreservation", trailerreservation::routes())
.mount("/cox", cox::routes())
.mount("/admin", admin::routes())
.mount("/board", board::routes())
.mount("/", misc::routes())
.mount("/public", FileServer::from("static/"))
.register("/", catchers![unauthorized_error, forbidden_error])

View File

@ -11,25 +11,28 @@ use crate::model::{notification::Notification, user::User};
async fn mark_read(db: &State<SqlitePool>, user: User, notification_id: i64) -> Flash<Redirect> {
let Some(notification) = Notification::find_by_id(db, notification_id).await else {
return Flash::error(
Redirect::to("/notifications"),
Redirect::to("/"),
format!("Nachricht mit ID {notification_id} nicht gefunden."),
);
};
if notification.user_id == user.id {
notification.mark_read(db).await;
Flash::success(
Redirect::to("/notifications"),
"Nachricht als gelesen markiert",
)
Flash::success(Redirect::to("/"), "Nachricht als gelesen markiert")
} else {
Flash::success(
Redirect::to("/notifications"),
Redirect::to("/"),
"Du kannst fremde Nachrichten nicht als gelesen markieren.",
)
}
}
pub fn routes() -> Vec<Route> {
routes![mark_read]
#[get("/read/all")]
async fn mark_all_read(db: &State<SqlitePool>, user: User) -> Flash<Redirect> {
Notification::mark_all_read(db, &user).await;
Flash::success(Redirect::to("/"), "Alle Nachrichten als gelesen markiert")
}
pub fn routes() -> Vec<Route> {
routes![mark_read, mark_all_read]
}

View File

@ -11,17 +11,22 @@ use tera::Context;
use crate::{
model::{
log::Log,
notification::Notification,
tripdetails::TripDetails,
triptype::TripType,
user::{User, UserWithDetails},
user::{AllowedForPlannedTripsUser, User, UserWithDetails},
usertrip::{UserTrip, UserTripDeleteError, UserTripError},
},
AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD,
};
#[get("/")]
async fn index(db: &State<SqlitePool>, user: User, flash: Option<FlashMessage<'_>>) -> Template {
async fn index(
db: &State<SqlitePool>,
user: AllowedForPlannedTripsUser,
flash: Option<FlashMessage<'_>>,
) -> Template {
let user: User = user.into_inner();
let mut context = Context::new();
if user.allowed_to_steer(db).await
@ -42,54 +47,25 @@ async fn index(db: &State<SqlitePool>, user: User, flash: Option<FlashMessage<'_
"allowed_to_update_always_show_trip",
&user.allowed_to_update_always_show_trip(db).await,
);
context.insert("fee", &user.fee(db).await);
context.insert(
"amount_days_to_show_trips_ahead",
&AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD,
);
context.insert("loggedin_user", &UserWithDetails::from_user(user, db).await);
context.insert("days", &days);
Template::render("index", context.into_json())
}
#[get("/faq")]
async fn faq(db: &State<SqlitePool>, user: User, flash: Option<FlashMessage<'_>>) -> Template {
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("loggedin_user", &UserWithDetails::from_user(user, db).await);
Template::render("faq", context.into_json())
}
#[get("/notifications")]
async fn notifications(
db: &State<SqlitePool>,
user: User,
flash: Option<FlashMessage<'_>>,
) -> Template {
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("notifications", &Notification::for_user(db, &user).await);
context.insert("loggedin_user", &UserWithDetails::from_user(user, db).await);
Template::render("notifications", context.into_json())
Template::render("planned", context.into_json())
}
#[get("/join/<trip_details_id>?<user_note>")]
async fn join(
db: &State<SqlitePool>,
trip_details_id: i64,
user: User,
user: AllowedForPlannedTripsUser,
user_note: Option<String>,
) -> Flash<Redirect> {
let user: User = user.into_inner();
let Some(trip_details) = TripDetails::find_by_id(db, trip_details_id).await else {
return Flash::error(Redirect::to("/"), "Trip_details do not exist.");
};
@ -114,35 +90,35 @@ async fn join(
),
).await;
}
Flash::success(Redirect::to("/"), "Erfolgreich angemeldet!")
Flash::success(Redirect::to("/planned"), "Erfolgreich angemeldet!")
}
Err(UserTripError::EventAlreadyFull) => {
Flash::error(Redirect::to("/"), "Event bereits ausgebucht!")
Flash::error(Redirect::to("/planned"), "Event bereits ausgebucht!")
}
Err(UserTripError::AlreadyRegistered) => {
Flash::error(Redirect::to("/"), "Du nimmst bereits teil!")
Flash::error(Redirect::to("/planned"), "Du nimmst bereits teil!")
}
Err(UserTripError::AlreadyRegisteredAsCox) => {
Flash::error(Redirect::to("/"), "Du hilfst bereits als Steuerperson aus!")
Flash::error(Redirect::to("/planned"), "Du hilfst bereits als Steuerperson aus!")
}
Err(UserTripError::CantRegisterAtOwnEvent) => Flash::error(
Redirect::to("/"),
Redirect::to("/planned"),
"Du kannst bei einer selbst ausgeschriebenen Fahrt nicht mitrudern ;)",
),
Err(UserTripError::GuestNotAllowedForThisEvent) => Flash::error(
Redirect::to("/"),
Redirect::to("/planned"),
"Bei dieser Ausfahrt können leider keine Gäste mitfahren.",
),
Err(UserTripError::NotAllowedToAddGuest) => Flash::error(
Redirect::to("/"),
Redirect::to("/planned"),
"Du darfst keine Gäste hinzufügen.",
),
Err(UserTripError::NotVisibleToUser) => Flash::error(
Redirect::to("/"),
Redirect::to("/planned"),
"Du kannst dich nicht registrieren, weil du die Ausfahrt gar nicht sehen solltest.",
),
Err(UserTripError::DetailsLocked) => Flash::error(
Redirect::to("/"),
Redirect::to("/planned"),
"Die Bootseinteilung wurde bereits gemacht. Wenn du noch mitrudern möchtest, frag bitte bei einer angemeldeten Steuerperson nach, ob das noch möglich ist.",
),
}
@ -152,11 +128,13 @@ async fn join(
async fn remove_guest(
db: &State<SqlitePool>,
trip_details_id: i64,
user: User,
user: AllowedForPlannedTripsUser,
name: String,
) -> Flash<Redirect> {
let user: User = user.into_inner();
let Some(trip_details) = TripDetails::find_by_id(db, trip_details_id).await else {
return Flash::error(Redirect::to("/"), "TripDetailsId does not exist");
return Flash::error(Redirect::to("/planned"), "TripDetailsId does not exist");
};
match UserTrip::delete(db, &user, &trip_details, Some(name)).await {
@ -170,7 +148,7 @@ async fn remove_guest(
)
.await;
Flash::success(Redirect::to("/"), "Erfolgreich abgemeldet!")
Flash::success(Redirect::to("/planned"), "Erfolgreich abgemeldet!")
}
Err(UserTripDeleteError::DetailsLocked) => {
Log::create(
@ -182,26 +160,32 @@ async fn remove_guest(
)
.await;
Flash::error(Redirect::to("/"), "Die Bootseinteilung wurde bereits gemacht. Wenn du doch nicht mitrudern kannst, melde dich bitte unbedingt schnellstmöglich bei einer angemeldeten Steuerperson!")
Flash::error(Redirect::to("/planned"), "Die Bootseinteilung wurde bereits gemacht. Wenn du doch nicht mitrudern kannst, melde dich bitte unbedingt schnellstmöglich bei einer angemeldeten Steuerperson!")
}
Err(UserTripDeleteError::GuestNotParticipating) => {
Flash::error(Redirect::to("/"), "Gast nicht angemeldet.")
Flash::error(Redirect::to("/planned"), "Gast nicht angemeldet.")
}
Err(UserTripDeleteError::NotVisibleToUser) => Flash::error(
Redirect::to("/"),
Redirect::to("/planned"),
"Du kannst dich nicht abmelden, weil du die Ausfahrt gar nicht sehen solltest.",
),
Err(UserTripDeleteError::NotAllowedToDeleteGuest) => Flash::error(
Redirect::to("/"),
Redirect::to("/planned"),
"Keine Berechtigung um den Gast zu entfernen.",
),
}
}
#[get("/remove/<trip_details_id>")]
async fn remove(db: &State<SqlitePool>, trip_details_id: i64, user: User) -> Flash<Redirect> {
async fn remove(
db: &State<SqlitePool>,
trip_details_id: i64,
user: AllowedForPlannedTripsUser,
) -> Flash<Redirect> {
let user: User = user.into_inner();
let Some(trip_details) = TripDetails::find_by_id(db, trip_details_id).await else {
return Flash::error(Redirect::to("/"), "TripDetailsId does not exist");
return Flash::error(Redirect::to("/planned"), "TripDetailsId does not exist");
};
match UserTrip::delete(db, &user, &trip_details, None).await {
@ -215,7 +199,7 @@ async fn remove(db: &State<SqlitePool>, trip_details_id: i64, user: User) -> Fla
)
.await;
Flash::success(Redirect::to("/"), "Erfolgreich abgemeldet!")
Flash::success(Redirect::to("/planned"), "Erfolgreich abgemeldet!")
}
Err(UserTripDeleteError::DetailsLocked) => {
Log::create(
@ -227,7 +211,7 @@ async fn remove(db: &State<SqlitePool>, trip_details_id: i64, user: User) -> Fla
)
.await;
Flash::error(Redirect::to("/"), "Das Boot ist bereits eingeteilt. Bitte kontaktiere den Schiffsführer (Nummern siehe Signalgruppe) falls du dich doch abmelden willst.")
Flash::error(Redirect::to("/planned"), "Das Boot ist bereits eingeteilt. Bitte kontaktiere den Schiffsführer (Nummern siehe Signalgruppe) falls du dich doch abmelden willst.")
}
Err(UserTripDeleteError::NotVisibleToUser) => {
Log::create(
@ -239,7 +223,7 @@ async fn remove(db: &State<SqlitePool>, trip_details_id: i64, user: User) -> Fla
)
.await;
Flash::error(Redirect::to("/"), "Abmeldung nicht möglich, da du dieses Event eigentlich gar nicht sehen solltest...")
Flash::error(Redirect::to("/planned"), "Abmeldung nicht möglich, da du dieses Event eigentlich gar nicht sehen solltest...")
}
Err(_) => {
panic!("Not possible to be here");
@ -248,7 +232,7 @@ async fn remove(db: &State<SqlitePool>, trip_details_id: i64, user: User) -> Fla
}
pub fn routes() -> Vec<Route> {
routes![index, join, remove, remove_guest, notifications, faq]
routes![index, join, remove, remove_guest]
}
#[cfg(test)]
@ -275,11 +259,11 @@ mod test {
.body("name=rower&password=rower"); // Add the form data to the request body;
login.dispatch().await;
let req = client.get("/join/1");
let req = client.get("/planned/join/1");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -288,11 +272,11 @@ mod test {
assert_eq!(flash_cookie.value(), "7:successErfolgreich angemeldet!");
let req = client.get("/remove/1");
let req = client.get("/planned/remove/1");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@ -316,7 +300,7 @@ mod test {
.body("name=rower&password=rower"); // Add the form data to the request body;
login.dispatch().await;
let req = client.get("/join/9999");
let req = client.get("/planned/join/9999");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);

64
src/tera/stat.rs Normal file
View File

@ -0,0 +1,64 @@
use rocket::{get, routes, Route, State};
use rocket_dyn_templates::{context, Template};
use sqlx::SqlitePool;
use crate::model::{
stat::{self, BoatStat, Stat},
user::{DonauLinzUser, UserWithDetails},
};
use super::log::KioskCookie;
#[get("/boats", rank = 2)]
async fn index_boat(db: &State<SqlitePool>, user: DonauLinzUser) -> Template {
let stat = BoatStat::get(db).await;
let kiosk = false;
Template::render(
"stat.boats",
context!(loggedin_user: &UserWithDetails::from_user(user.into_inner(), db).await, stat, kiosk),
)
}
#[get("/boats")]
async fn index_boat_kiosk(db: &State<SqlitePool>, _kiosk: KioskCookie) -> Template {
let stat = BoatStat::get(db).await;
let kiosk = true;
Template::render("stat.boats", context!(stat, kiosk, show_kiosk_header: true))
}
#[get("/?<year>", rank = 2)]
async fn index(db: &State<SqlitePool>, user: DonauLinzUser, year: Option<i32>) -> Template {
let stat = Stat::people(db, year).await;
let club_km = Stat::sum_people(db, year).await;
let club_trips = Stat::trips_people(db, year).await;
let guest_km = Stat::guest(db, year).await;
let personal = stat::get_personal(db, &user).await;
let kiosk = false;
Template::render(
"stat.people",
context!(loggedin_user: &UserWithDetails::from_user(user.into_inner(), db).await, stat, personal, kiosk, guest_km, club_km, club_trips),
)
}
#[get("/?<year>")]
async fn index_kiosk(db: &State<SqlitePool>, _kiosk: KioskCookie, year: Option<i32>) -> Template {
let stat = Stat::people(db, year).await;
let club_km = Stat::sum_people(db, year).await;
let guest_km = Stat::guest(db, year).await;
let kiosk = true;
Template::render(
"stat.people",
context!(stat, kiosk, show_kiosk_header: true, guest_km, club_km),
)
}
pub fn routes() -> Vec<Route> {
routes![index, index_kiosk, index_boat, index_boat_kiosk]
}
#[cfg(test)]
mod test {}

View File

@ -0,0 +1,211 @@
use chrono::NaiveDate;
use rocket::{
form::Form,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
routes, FromForm, Route, State,
};
use rocket_dyn_templates::Template;
use sqlx::SqlitePool;
use tera::Context;
use crate::{
model::{
log::Log,
trailer::Trailer,
trailerreservation::{TrailerReservation, TrailerReservationToAdd},
user::{DonauLinzUser, User, UserWithDetails},
},
tera::log::KioskCookie,
};
#[get("/")]
async fn index_kiosk(
db: &State<SqlitePool>,
flash: Option<FlashMessage<'_>>,
_kiosk: KioskCookie,
) -> Template {
let trailerreservations = TrailerReservation::all_future(db).await;
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("trailerreservations", &trailerreservations);
context.insert("trailers", &Trailer::all(db).await);
context.insert("user", &User::all(db).await);
context.insert("show_kiosk_header", &true);
Template::render("trailerreservations", context.into_json())
}
#[get("/", rank = 2)]
async fn index(
db: &State<SqlitePool>,
flash: Option<FlashMessage<'_>>,
user: DonauLinzUser,
) -> Template {
let trailerreservations = TrailerReservation::all_future(db).await;
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("trailerreservations", &trailerreservations);
context.insert("trailers", &Trailer::all(db).await);
context.insert("user", &User::all(db).await);
context.insert(
"loggedin_user",
&UserWithDetails::from_user(user.into_inner(), db).await,
);
Template::render("trailerreservations", context.into_json())
}
#[derive(Debug, FromForm)]
pub struct FormTrailerReservationToAdd<'r> {
pub trailer_id: i64,
pub start_date: &'r str,
pub end_date: &'r str,
pub time_desc: &'r str,
pub usage: &'r str,
pub user_id_applicant: Option<i64>,
}
#[post("/new", data = "<data>", rank = 2)]
async fn create<'r>(
db: &State<SqlitePool>,
data: Form<FormTrailerReservationToAdd<'r>>,
user: DonauLinzUser,
) -> Flash<Redirect> {
let user_applicant: User = user.into_inner();
let trailer = Trailer::find_by_id(db, data.trailer_id as i32)
.await
.unwrap();
let trailerreservation_to_add = TrailerReservationToAdd {
trailer: &trailer,
start_date: NaiveDate::parse_from_str(data.start_date, "%Y-%m-%d").unwrap(),
end_date: NaiveDate::parse_from_str(data.end_date, "%Y-%m-%d").unwrap(),
time_desc: data.time_desc,
usage: data.usage,
user_applicant: &user_applicant,
};
match TrailerReservation::create(db, trailerreservation_to_add).await {
Ok(_) => Flash::success(
Redirect::to("/trailerreservation"),
"Reservierung erfolgreich hinzugefügt",
),
Err(e) => Flash::error(Redirect::to("/trailerreservation"), format!("Fehler: {e}")),
}
}
#[post("/new", data = "<data>")]
async fn create_from_kiosk<'r>(
db: &State<SqlitePool>,
data: Form<FormTrailerReservationToAdd<'r>>,
_kiosk: KioskCookie,
) -> Flash<Redirect> {
let user_applicant: User = User::find_by_id(db, data.user_id_applicant.unwrap() as i32)
.await
.unwrap();
let trailer = Trailer::find_by_id(db, data.trailer_id as i32)
.await
.unwrap();
let trailerreservation_to_add = TrailerReservationToAdd {
trailer: &trailer,
start_date: NaiveDate::parse_from_str(data.start_date, "%Y-%m-%d").unwrap(),
end_date: NaiveDate::parse_from_str(data.end_date, "%Y-%m-%d").unwrap(),
time_desc: data.time_desc,
usage: data.usage,
user_applicant: &user_applicant,
};
match TrailerReservation::create(db, trailerreservation_to_add).await {
Ok(_) => Flash::success(
Redirect::to("/trailerreservation"),
"Reservierung erfolgreich hinzugefügt",
),
Err(e) => Flash::error(Redirect::to("/trailerreservation"), format!("Fehler: {e}")),
}
}
#[derive(FromForm, Debug)]
pub struct ReservationEditForm {
pub(crate) id: i32,
pub(crate) time_desc: String,
pub(crate) usage: String,
}
#[post("/", data = "<data>")]
async fn update(
db: &State<SqlitePool>,
data: Form<ReservationEditForm>,
user: User,
) -> Flash<Redirect> {
let Some(reservation) = TrailerReservation::find_by_id(db, data.id).await else {
return Flash::error(
Redirect::to("/trailerreservation"),
format!("Reservation with ID {} does not exist!", data.id),
);
};
if user.id != reservation.user_id_applicant && !user.has_role(db, "admin").await {
return Flash::error(
Redirect::to("/trailerreservation"),
"Not allowed to update reservation (only admins + creator do so).".to_string(),
);
}
Log::create(
db,
format!(
"{} updated reservation from {reservation:?} to {data:?}",
user.name
),
)
.await;
reservation.update(db, data.into_inner()).await;
Flash::success(
Redirect::to("/trailerreservation"),
"Reservierung erfolgreich bearbeitet",
)
}
#[get("/<reservation_id>/delete")]
async fn delete<'r>(
db: &State<SqlitePool>,
reservation_id: i32,
user: DonauLinzUser,
) -> Flash<Redirect> {
let reservation = TrailerReservation::find_by_id(db, reservation_id)
.await
.unwrap();
if user.id == reservation.user_id_applicant || user.has_role(db, "admin").await {
reservation.delete(db).await;
Flash::success(
Redirect::to("/trailerreservation"),
"Reservierung erfolgreich gelöscht",
)
} else {
Flash::error(
Redirect::to("/trailerreservation"),
"Nur der Reservierer darf die Reservierung löschen.".to_string(),
)
}
}
pub fn routes() -> Vec<Route> {
routes![
index,
index_kiosk,
create,
create_from_kiosk,
delete,
update
]
}

5
staging-diff.sql Normal file
View File

@ -0,0 +1,5 @@
-- test user
INSERT INTO user(name) VALUES('Marie');
INSERT INTO "user_role" (user_id, role_id) VALUES((SELECT id from user where name = 'Marie'),(SELECT id FROM role where name = 'Donau Linz'));
INSERT INTO user(name) VALUES('Philipp');
INSERT INTO "user_role" (user_id, role_id) VALUES((SELECT id from user where name = 'Philipp'),(SELECT id FROM role where name = 'Donau Linz'));

View File

@ -0,0 +1,100 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/log" as log %}
{% extends "base" %}
{% block content %}
<link rel="stylesheet" href="/public/table.css" />
<div class="w-full">
<h1 class="h1">Abzeichen für {{ rowingbadge_year }}</h1>
<div class="text-black dark:text-white">
<table id="basic">
<thead>
<tr>
<th>Name</th>
<th>Erster Log</th>
<th>Letzter Log</th>
<th>Gesamt-KM</th>
<th>Äquatorpreis (ÄP)</th>
<th>
ÄP diese
<br>
Saison bekommen
</th>
<th>
Fahrtenabzeichen (FA)
<br>
geschafft
</th>
<th>FA - KM</th>
<th>FA - fehlende KM</th>
<th>Eintagesausfahrten</th>
<th>Mehrtagesausfahrten</th>
</tr>
</thead>
<tbody>
{% for person in people %}
{% set user = person.0 %}
{% set achievement = person.1 %}
<tr>
<td>{{ user.name }}</td>
<td>
{% if achievement.year_first_mentioned %}{{ achievement.year_first_mentioned }}{% endif %}
</td>
<td>
{% if achievement.year_last_mentioned %}{{ achievement.year_last_mentioned }}{% endif %}
</td>
<td>{{ achievement.all_time_km }}</td>
<td>{{ achievement.curr_equatorprice_name }}</td>
<td>
{% if achievement.new_equatorprice_this_season %}
🎉
{% else %}
-
{% endif %}
</td>
{% if achievement.rowingbadge %}
{% set badge = achievement.rowingbadge %}
<td>
{% if badge.achieved %}
ja
{% else %}
nein
{% endif %}
</td>
<td>{{ badge.rowed_km }} / {{ badge.required_km }}</td>
<td>{{ badge.missing_km }}</td>
<td>
<details>
<summary>
> {{ badge.single_day_trips_required_distance }} km: {{ badge.single_day_trips_over_required_distance | length }} / 2
</summary>
{% for log in badge.single_day_trips_over_required_distance %}
{{ log::show_old(log=log, state="completed", only_ones=false, index=loop.index, hide_type=true) }}
{% endfor %}
</details>
</td>
<td>
<details>
<summary>
> {{ badge.multi_day_trips_required_distance }} km: {{ badge.multi_day_trips_over_required_distance | length }} / 1
</summary>
{% for log in badge.multi_day_trips_over_required_distance %}
{{ log::show_old(log=log, state="completed", only_ones=false, index=loop.index, hide_type=true) }}
{% endfor %}
</details>
</td>
{% else %}
<td>Geb.datum fehlt 👻</td>
<td>Geb.datum fehlt 👻</td>
<td>Geb.datum fehlt 👻</td>
<td>Geb.datum fehlt 👻</td>
<td>Geb.datum fehlt 👻</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script src="/public/jstable.min.js"></script>
<script src="/public/table.js"></script>
{% endblock content %}

View File

@ -0,0 +1,10 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/boat" as boat %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">Boats</h1>
{{ boat::new() }}
{% for boat in boats %}{{ boat::edit(boat=boat, uuid=loop.index) }}{% endfor %}
</div>
{% endblock content %}

View File

@ -0,0 +1,11 @@
{% import "includes/macros" as macros %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">List</h1>
<form action="/admin/list" method="post">
<textarea name="list" rows="4" cols="50"></textarea>
<input type="submit" />
</form>
</div>
{% endblock content %}

View File

@ -0,0 +1,10 @@
{% import "includes/macros" as macros %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">List - Result</h1>
<ol>
{% for person in result %}<li>{{ person }}</li>{% endfor %}
</ol>
</div>
{% endblock content %}

View File

@ -0,0 +1,27 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/boat" as boat %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full dark:text-white">
<h1 class="h1">Mail</h1>
<div class="grid ">
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Mail versenden</h2>
<form action="/admin/mail"
method="post"
enctype="multipart/form-data"
class="grid gap-3 p-3">
{{ macros::select(label="Gruppe", data=roles, name="role_id") }}
{{ macros::input(label="Betreff", name="subject", type="text", required=true) }}
<div class="">
<label for="content" class=" text-sm text-gray-600 dark:text-white ">Inhalt</label>
<textarea id="content" name="body" rows="4" cols="50" class="input rounded-md"></textarea>
</div>
<input type="file" name="files" multiple />
<input type="submit" class="btn btn-primary" value="Abschicken" />
</form>
</div>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,38 @@
{% import "includes/macros" as macros %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">Schnupper Verwaltung</h1>
<div class="grid gap-3">
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5">
<h2 class="h2">Angemeldete Personen: {{ schnupperanten | length }}</h2>
<ol>
{% for user in schnupperanten %}
<li class="border-t border-gray-200 dark:border-primary-600 px-3 py-1">
<span class="flex items-center justify-between">
<span>
<span class="status-damage status-damage-{% if "paid" in user.roles %}none {% else %}locked {% endif %}"></span>&nbsp;{{ user.name }} ({{ user.mail }}
{%- if user.notes %} | {{ user.notes }}
{% endif -%}
)
</span>
<a class="btn btn-primary"
href="/admin/user/move/schnupperant/{{ user.id }}/to/scheckbuch"
onclick="return confirm('Willst du wirklich ein Scheckbuch erstellen? Die Person erhält ein Mail mit allen Infos.')">Zu Scheckbuch umwandeln</a>
</span>
</li>
{% endfor %}
</ol>
</div>
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5">
<h2 class="h2">Legende</h2>
<div class="px-3 py-1">
<span class="status-damage status-damage-none"></span> Bezahlt - Juhuuu!
</div>
<div class="px-3 py-1">
<span class="status-damage status-damage-locked"></span> Noch nicht bezahlt
</div>
</div>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,43 @@
{% import "includes/macros" as macros %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">Gebühren</h1>
<div class="search-wrapper">
<label for="name" class="sr-only">Suche</label>
<input type="search"
name="name"
id="filter-js"
class="search-bar"
placeholder="Suchen nach Namen..." />
</div>
<div id="filter-result-js" class="search-result"></div>
<div class="border-r border-l border-gray-200 dark:border-primary-600">
{% for fee in fees | sort(attribute="name") %}
<div class="border-t border-gray-200 dark:border-primary-600 {% if fee.paid %}bg-[#15803d] text-white {% else %} bg-white dark:bg-primary-900 text-black dark:text-white {% endif %} flex justify-between items-center px-3 py-1 "
data-filterable="true"
data-filter="{{ fee.name }} {% if fee.paid %} has-already-paid {% else %} has-not-paid {% endif %}"
class="bg-white dark:bg-primary-900 p-3 rounded-md w-full">
<div class="grid md:grid-cols-3 gap-3 w-full py-3">
<div>
<strong>{{ fee.name }}</strong>
<span class="block">{{ fee.sum_in_cents / 100 }}€</span>
</div>
<div>
{% for p in fee.parts %}
{{ p.0 }} ({{ p.1 / 100 }}€)
{% if not loop.last %}+{% endif %}
{% endfor %}
</div>
{% if "admin" in loggedin_user.roles or "kassier" in loggedin_user.roles %}
<div class="text-end">
<a href="/admin/user/fees/paid?{{ fee.user_ids }}"
class="btn btn-primary">Zahlungsstatus ändern</a>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock content %}

View File

@ -2,13 +2,14 @@
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">Mitglieder</h1>
<h1 class="h1">Users</h1>
{% if allowed_to_edit %}
<form action="/admin/user/new"
onsubmit="return confirm('Willst du wirklich einen neuen Benutzer anlegen?');"
method="post"
class="mt-4 bg-primary-900 rounded-md text-white px-3 pb-3 pt-2 sm:flex items-end justify-between">
<div class="w-full">
<h2 class="text-md font-bold mb-2 uppercase tracking-wide">Neues Mitglied hinzufügen</h2>
<h2 class="text-md font-bold mb-2 uppercase tracking-wide">Neuen User hinzufügen</h2>
<div class="grid md:grid-cols-3">
<div>
<label for="name" class="sr-only">Name</label>
@ -33,33 +34,34 @@
name="name"
id="filter-js"
class="search-bar"
placeholder="Suchen nach (Name, [yes|no]-role:<name>" />
placeholder="Suchen nach (Name, [yes|no]-role:<name>, has-[no-]membership-pdf)" />
</div>
<!-- END filterBar -->
<div id="filter-result-js" class="search-result"></div>
{% for user in users %}
<div data-filterable="true"
data-filter="{{ user.name }} {% for role in roles %} {% if role.name in user.roles %} yes-role:{{ macros::fancy_role_name(name=role.name) }} {% else %} no-role:{{ role.name }} {% endif %} role-{{ role }} {% endfor %}"
data-filter="{{ user.name }} {% for role in roles %} {% if role.name in user.roles %} yes-role:{{ role.name }} {% else %} no-role:{{ role.name }} {% endif %} role-{{ role }} {% endfor %} {% if user.membership_pdf %}has-membership-pdf{% else %}has-no-membership-pdf{% endif %}"
class="border-t bg-white dark:bg-primary-900 py-3 px-4 relative">
<details class="block dark:text-white w-full">
<summary>
<span class="text-black dark:text-white cursor-pointer">
<span class="font-bold">
{{ user.name }}
{% if not user.last_access and "admin" in loggedin_user.roles %}
{% if not user.last_access and "admin" in loggedin_user.roles and user.mail %}
<form action="/admin/user"
method="post"
enctype="multipart/form-data"
class="inline">
&bullet; <a class="font-normal text-primary-600 dark:text-primary-200 hover:text-primary-900 dark:hover:text-primary-300 underline"
onclick="return confirm('Demo aktiv, nun würde der Benutzer eine Mail bekommen mit der Info wie er ruad.at verwendet.');">Willkommensmail verschicken</a>
href="/admin/user/{{ user.id }}/send-welcome-mail"
onclick="return confirm('Willst du wirklich das Willkommensmail an {{ user.name }} ausschicken?');">Willkommensmail verschicken</a>
</form>
{% endif %}
{% if user.last_access %}&bullet; Zuletzt eingeloggt: &nbsp;{{ user.last_access | date() }}{% endif %}
{% if user.last_access %}&bullet; ⏳&nbsp;{{ user.last_access | date }}{% endif %}
</span>
<small class="block text-gray-600 dark:text-gray-100">
{% for role in user.roles -%}
{{ macros::fancy_role_name(name=role) }}
{{ role }}
{%- if not loop.last %},
{% endif -%}
{% endfor %}
@ -73,30 +75,64 @@
{% if user.pw %}
<a class="block my-1 font-normal text-[#f43f5e] dark:text-primary-200 hover:text-primary-900 dark:hover:text-primary-300 underline"
href="/admin/user/{{ user.id }}/reset-pw"
onclick="return confirm('Willst du wirklich das Passwort von \'{{ user.name }}\'zurücksetzen?');">Passwort zurücksetzen</a>
onclick="return confirm('Willst du wirklich das Passwort zurücksetzen?');">Passwort zurücksetzen</a>
{% endif %}
<div class="w-full grid gap-3 mt-3">
<input type="hidden" name="id" value="{{ user.id }}" />
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-3">
{% for cluster, cluster_roles in roles | group_by(attribute="cluster") %}
<label for="cluster_{{ loop.index }}">{{ cluster }}</label>
{# Determine the initially selected role within the cluster #}
{% set_global selected_role_id = "none" %}
{% for role in cluster_roles %}
{% if selected_role_id == "none" and role.name in user.roles %}
{% set_global selected_role_id = role.id %}
{% endif %}
{% endfor %}
{# Set default name to the selected role ID or first role if none selected #}
<select id="cluster_{{ loop.index }}"
{% if selected_role_id == 'none' %} {% else %} name="roles[{{ selected_role_id }}]" {% endif %}
{% if allowed_to_edit == false %}disabled{% endif %}
onchange=" if (this.value === '') { this.removeAttribute('name'); } else { this.name = 'roles[' + this.options[this.selectedIndex].getAttribute('data-role-id') + ']'; }">
<option value=""
data-role-id="none"
{% if selected_role_id == 'none' %}selected="selected"{% endif %}>
None
</option>
{% for role in cluster_roles %}
<option value="on"
data-role-id="{{ role.id }}"
{% if role.id == selected_role_id %}selected="selected"{% endif %}>
{{ role.name }}
</option>
{% endfor %}
</select>
{% endfor %}
{% for role in roles %}
{% if not role.cluster %}
{% if role.name == "admin" %}
{{ macros::checkbox(label=role.name, name="roles[" ~ role.id ~ "]", id=loop.index , checked=role.name in user.roles, disabled=allowed_to_edit == false, help="Admins können Mitglieder (auf dieser Seite) verwalten") }}
{% elif role.name == "scheckbuch" %}
{{ macros::checkbox(label=role.name, name="roles[" ~ role.id ~ "]", id=loop.index , checked=role.name in user.roles, disabled=allowed_to_edit == false, help="Anfänger sehen nur Ausfahrten/Events, die explizit für sie ausgeschrieben wurden") }}
{% elif role.name == "cox" %}
{{ macros::checkbox(label=role.name, name="roles[" ~ role.id ~ "]", id=loop.index , checked=role.name in user.roles, disabled=allowed_to_edit == false, help="Steuerpersonen können selbstständig Ausfahrten ausschreiben und sich bei Events zum steuern anmelden") }}
{% elif role.name == "manage_events" %}
{{ macros::checkbox(label=role.name, name="roles[" ~ role.id ~ "]", id=loop.index , checked=role.name in user.roles, disabled=allowed_to_edit == false, help="Eventmanager können Events ausschreiben und bearbeiten") }}
{% else %}
{{ macros::checkbox(label=role.name, name="roles[" ~ role.id ~ "]", id=loop.index , checked=role.name in user.roles, disabled=allowed_to_edit == false) }}
{% endif %}
{% endif %}
{% endfor %}
<hr class="sm:col-span-2 lg:col-span-4 my-3" />
{{ macros::input(label='Name', name='name', id=loop.index, type="text", value=user.name) }}
{{ macros::input(label='Mail', name='mail', id=loop.index, type="text", value="Demo Version: Mails deaktiviert", readonly=true) }}
{% if user.membership_pdf %}
<a href="/admin/user/{{ user.id }}/membership"
class="text-black dark:text-white">Beitrittserklärung herunterladen</a>
{% else %}
{{ macros::input(label='Beitrittserklärung', name='membership_pdf', id=loop.index, type="file", readonly=allowed_to_edit == false, accept='application/pdf') }}
{% endif %}
{{ macros::input(label='DOB', name='dob', id=loop.index, type="text", value=user.dob, readonly=allowed_to_edit == false) }}
{{ macros::input(label='Weight (kg)', name='weight', id=loop.index, type="text", value=user.weight, readonly=allowed_to_edit == false) }}
{{ macros::input(label='Sex', name='sex', id=loop.index, type="text", value=user.sex, readonly=allowed_to_edit == false) }}
{{ macros::input(label='Mitglied seit', name='member_since_date', id=loop.index, type="text", value=user.member_since_date, readonly=allowed_to_edit == false) }}
{{ macros::input(label='Geburtsdatum', name='birthdate', id=loop.index, type="text", value=user.birthdate, readonly=allowed_to_edit == false) }}
{{ macros::input(label='Mail', name='mail', id=loop.index, type="text", value=user.mail, readonly=allowed_to_edit == false) }}
{{ macros::input(label='Nickname', name='nickname', id=loop.index, type="text", value=user.nickname, readonly=allowed_to_edit == false) }}
{{ macros::input(label='Notizen', name='notes', id=loop.index, type="text", value=user.notes, readonly=allowed_to_edit == false) }}
{{ macros::input(label='Telefon', name='phone', id=loop.index, type="text", value=user.phone, readonly=allowed_to_edit == false) }}
{{ macros::input(label='Adresse', name='address', id=loop.index, type="text", value=user.address, readonly=allowed_to_edit == false) }}
{% if allowed_to_edit %}
{{ macros::select(label="Familie", data=families, name='family_id', selected_id=user.family_id, display=['names'], default="Keine Familie", new_last_entry='Neue Familie anlegen') }}
{% endif %}
</div>
</div>
{% if allowed_to_edit %}

View File

@ -0,0 +1,70 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/log" as log %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5">
<h1 class="h1">Scheckbücher</h1>
<form action="/admin/user/new/scheckbuch"
method="post"
class="mt-4 bg-primary-900 rounded-md text-white px-3 pb-3 pt-2 sm:flex items-end justify-between">
<div class="w-full">
<h2 class="text-md font-bold mb-2 uppercase tracking-wide">Neues Scheckbuch hinzufügen</h2>
<div class="grid md:grid-cols-3">
<div>
<label for="name" class="sr-only">Name</label>
<input type="text"
name="name"
class="relative block rounded-md border-0 py-1.5 px-2 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary-600 sm:text-sm sm:leading-6 mb-2 md:mb-0"
placeholder="Name" />
</div>
<div>
<label for="name" class="sr-only">Mail</label>
<input type="mail"
name="mail"
class="relative block rounded-md border-0 py-1.5 px-2 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-primary-600 sm:text-sm sm:leading-6 mb-2 md:mb-0"
placeholder="Mail" />
</div>
</div>
</div>
<div class="text-right">
<input value="Hinzufügen"
type="submit"
class="w-28 mt-2 sm:mt-0 rounded-md bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 cursor-pointer" />
</div>
</form>
<!-- START filterBar -->
<div class="search-wrapper">
<label for="name" class="sr-only">Suche</label>
<input type="search"
name="name"
id="filter-js"
class="search-bar"
placeholder="Suchen nach Name" />
</div>
<!-- END filterBar -->
<div class="bg-primary-100 dark:bg-primary-950 p-3 rounded-b-md grid gap-4">
<div id="filter-result-js"
class="text-primary-950 dark:text-white text-right"></div>
{% for scheckbook in scheckbooks %}
{% set user = scheckbook.1 %}
{% set trips = scheckbook.0 %}
<div {% if "paid" in user.roles %}style="background-color: green;"{% endif %}
data-filterable="true"
data-filter="{{ user.name }} {% if "paid" in user.roles %} has-already-paid {% else %} has-not-paid {% endif %}"
class="bg-white dark:bg-primary-900 p-3 rounded-md w-full">
<div class="grid sm:grid-cols-1 gap-3">
<div style="width: 100%" class="col-span-2">
<b>{{ user.name }} - Ausfahrten: {{ trips | length }}</b>
{% for trip in trips %}
<li>{{ log::show_old(log=trip, state="completed", only_ones=false, index=loop.index) }}</li>
{% endfor %}
</div>
{% if "admin" in loggedin_user.roles or "kassier" in loggedin_user.roles %}
<a href="/admin/user/fees/paid?user_ids[]={{ user.id }}">Zahlungsstatus ändern</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock content %}

View File

@ -2,6 +2,9 @@
{% block content %}
<div class="w-full max-w-md space-y-8">
<div>
<img class="mx-auto h-16 w-auto"
src="https://rudernlinz.at/wp-content/uploads/2021/02/cropped-logo.png"
alt="Logo Ruderassistent">
<h1 class="mt-6 h1">Ruderassistent</h1>
</div>
{% if flash %}{{ macros::alert(message=flash.1, type=flash.0, class="sm:col-span-2 lg:col-span-3") }}{% endif %}

View File

@ -20,7 +20,7 @@
<link rel="manifest" href="/public/images/site.webmanifest">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<title>Ruderassistent - ruad.at</title>
<title>Ruderassistent - ASKÖ Ruderverein Donau Linz</title>
</head>
<body class="bg-gray-100 dark:bg-black">
{% if loggedin_user %}{{ macros::header(loggedin_user=loggedin_user) }}{% endif %}

View File

@ -4,13 +4,12 @@
{% extends "base" %}
{% macro show_place(aisle_name, side_name, level) %}
<li class="truncate p-2 flex relative w-full">
{% set aisle = aisle_name ~ "-aisle" %}
{% set place = boathouse[aisle][side_name] %}
{% set place = boathouse[aisle_name][side_name].boats %}
{% if place[level] %}
{{ place[level].1.name }}
{{ place[level].boat.name }}
{% if "admin" in loggedin_user.roles %}
<a class="btn btn-primary absolute end-0"
href="/board/boathouse/{{ place[level].0 }}/delete">X</a>
href="/board/boathouse/{{ place[level].boathouse_id }}/delete">X</a>
{% endif %}
{% elif boats | length > 0 %}
{% if "admin" in loggedin_user.roles %}

View File

@ -0,0 +1,106 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/log" as log %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">Bootschäden</h1>
<h2 class="text-md font-bold tracking-wide bg-primary-900 mt-3 p-3 text-white flex justify-between items-center rounded-md">
Neuen Schaden
<a href="#"
class="inline-flex justify-center rounded-md bg-primary-600 mx-1 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 cursor-pointer"
data-sidebar="true"
data-trigger="sidebar"
data-header="Neuen Schaden anlegen"
data-body="#new-damage">
{% include "includes/plus-icon" %}
<span class="sr-only">Neuen Schaden eintragen</span>
</a>
</h2>
<div class="hidden">
<div id="new-damage">
<form action="/boatdamage" method="post" class="grid gap-3">
{{ log::boat_select(only_ones=false, id='boat') }}
{% if not loggedin_user %}{{ macros::select(label='Gemeldet von', data=user, name='user_id') }}{% endif %}
{{ macros::input(label='Beschreibung des Schadens', name='desc', type='text', required=true, wrapper_class='col-span-4') }}
<div class="col-span-4">
{{ macros::checkbox(label='Boot sperren', name='lock_boat', type='text', required=true) }}
</div>
<input type="submit"
class="btn btn-primary w-full col-span-4"
value="Schaden eintragen" />
</form>
</div>
</div>
<div class="search-wrapper">
<label for="name" class="sr-only">Suche</label>
<input type="search"
name="name"
id="filter-js"
class="search-bar"
placeholder="Suchen nach Namen...">
</div>
<div id="filter-result-js" class="search-result"></div>
{% for boatdamage in boatdamages | sort(attribute="verified") %}
<div data-filterable="true"
data-filter="{{ boatdamage.boat.name }} {{ boatdamage.user_created.name }}"
class="w-full border-t bg-white dark:bg-primary-900 text-black dark:text-white p-3 {% if boatdamage.verified_at %}opacity-50{% endif %}">
<div class="w-full">
<strong>{{ boatdamage.created_at | date(format='%d.%m.%Y') }} <span class="font-normal text-gray-600 dark:text-gray-100">({{ boatdamage.boat.name }})</span></strong>
{% if boatdamage.boat.damage %}
<small class="block text-gray-600 dark:text-gray-100">(Boot gesperrt)</small>
{% endif %}
<div>{{ boatdamage.desc }}</div>
<small class="block text-gray-600 dark:text-gray-100">
Schaden eingetragen von {{ boatdamage.user_created.name }} am/um {{ boatdamage.created_at | date(format='%d.%m.%Y (%H:%M)') }}
</small>
{% if boatdamage.fixed_at %}
<small class="block text-gray-600 dark:text-gray-100">Repariert von {{ boatdamage.user_fixed.name }} am/um {{ boatdamage.fixed_at | date(format='%d.%m.%Y (%H:%M)') }}</small>
{% else %}
{% if loggedin_user and loggedin_user.allowed_to_steer %}
<form action="/boatdamage/{{ boatdamage.id }}/fixed"
method="post"
class="flex justify-between mt-3">
<input type="text"
name="desc"
value="{{ boatdamage.desc }}"
class="grow input rounded-s" />
{% if loggedin_user and "tech" in loggedin_user.roles %}
<input type="submit"
class="btn btn-primary"
style="border-top-left-radius: 0;
border-bottom-left-radius: 0"
value="Repariert und verifiziert" />
{% else %}
<input type="submit"
class="btn btn-primary"
style="border-top-left-radius: 0;
border-bottom-left-radius: 0"
value="Repariert" />
{% endif %}
</form>
{% endif %}
{% endif %}
{% if boatdamage.verified_at %}
<small class="block text-gray-600 dark:text-gray-100">Verifiziert von {{ boatdamage.user_verified.name }} am/um {{ boatdamage.verified_at | date(format='%d.%m.%Y (%H:%M)') }}</small>
{% else %}
{% if loggedin_user and "tech" in loggedin_user.roles and boatdamage.fixed_at %}
<form action="/boatdamage/{{ boatdamage.id }}/verified"
method="post"
class="flex justify-between mt-3">
<input type="text"
name="desc"
value="{{ boatdamage.desc }}"
class="grow input rounded-s" />
<input type="submit"
class="btn btn-dark"
style="border-top-left-radius: 0;
border-bottom-left-radius: 0"
value="Verifiziert" />
</form>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock content %}

View File

@ -0,0 +1,98 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/log" as log %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">Bootsreservierungen</h1>
<h2 class="text-md font-bold tracking-wide bg-primary-900 mt-3 p-3 text-white flex justify-between items-center rounded-md">
Neue Reservierung
<a href="#"
class="inline-flex justify-center rounded-md bg-primary-600 mx-1 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 cursor-pointer"
data-sidebar="true"
data-trigger="sidebar"
data-header="Neue Reservierung anlegen"
data-body="#new-reservation">
{% include "includes/plus-icon" %}
<span class="sr-only">Neue Reservierung eintragen</span>
</a>
</h2>
<div class="hidden">
<div id="new-reservation">
<form action="/boatreservation/new" method="post" class="grid gap-3">
{{ log::boat_select(only_ones=false, id='boat') }}
{% if not loggedin_user %}{{ macros::select(label='Reserviert von', data=user, name='user_id_applicant') }}{% endif %}
{{ macros::input(label='Beginn', name='start_date', type='date', required=true, wrapper_class='col-span-4') }}
{{ macros::input(label='Ende', name='end_date', type='date', required=true, wrapper_class='col-span-4') }}
{{ macros::input(label='Uhrzeit (zB ab 14:00 Uhr, ganztägig, ...)', name='time_desc', type='text', required=true, wrapper_class='col-span-4') }}
{{ macros::input(label='Zweck (Wanderfahrt, ...)', name='usage', type='text', required=true, wrapper_class='col-span-4') }}
<input type="submit"
class="btn btn-primary w-full col-span-4"
value="Reservierung eintragen" />
</form>
</div>
</div>
<div class="search-wrapper">
<label for="name" class="sr-only">Suche</label>
<input type="search"
name="name"
id="filter-js"
class="search-bar"
placeholder="Suchen nach Namen...">
</div>
<div id="filter-result-js" class="search-result"></div>
{% for reservation in boatreservations %}
{% set allowed_to_edit = false %}
{% if loggedin_user %}
{% if loggedin_user.id == reservation.user_applicant.id or "admin" in loggedin_user.roles %}
{% set allowed_to_edit = true %}
{% endif %}
{% endif %}
<div data-filterable="true"
data-filter="{{ reservation.user_applicant.name }} {{ reservation.boat.name }}"
class="w-full border-t bg-white dark:bg-primary-900 text-black dark:text-white p-3">
<div class="w-full">
<strong>Boot:</strong>
{{ reservation.boat.name }}
<br />
<strong>Reservierung:</strong>
{{ reservation.user_applicant.name }}
<br />
<strong>Datum:</strong>
{{ reservation.start_date }}
{% if reservation.end_date != reservation.start_date %}
-
{{ reservation.end_date }}
{% endif %}
<br />
{% if not allowed_to_edit %}
<strong>Uhrzeit:</strong>
{{ reservation.time_desc }}
<br />
<strong>Zweck:</strong>
{{ reservation.usage }}
{% endif %}
{% if allowed_to_edit %}
<form action="/boatreservation"
method="post"
class="bg-white dark:bg-primary-900 pt-3 rounded-md w-full">
<div class="w-full grid gap-3">
<input type="hidden" name="id" value="{{ reservation.id }}" />
{{ macros::input(label='Uhrzeit', name='time_desc', id=loop.index, type="text", value=reservation.time_desc, readonly=false) }}
{{ macros::input(label='Zweck', name='usage', id=loop.index, type="text", value=reservation.usage, readonly=false) }}
</div>
<div class="mt-3 text-right">
<a href="/boatreservation/{{ reservation.id }}/delete"
class="w-28 btn btn-alert"
onclick="return confirm('Willst du diese Reservierung wirklich löschen?');">
{% include "includes/delete-icon" %}
Löschen
</a>
<input value="Ändern" type="submit" class="w-28 btn btn-primary ml-1" />
</div>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock content %}

View File

@ -1,31 +0,0 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/log" as log %}
{% extends "base" %}
{% block content %}
<div id="notification"
class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5 mb-5"
role="alert">
<h2 class="h2">Kalender</h2>
<div class="p-5">
<p class="mt-3">
Du möchtest immer up-to-date mit den Events und Ausfahrten bleiben? Wir bieten 2 verschiedene Arten von Kalender an:
</p>
<ol class="list-decimal ml-5 my-3">
<li>
<a class="underline break-all"
href="/cal/personal/{{ loggedin_user.id }}/{{ loggedin_user.user_token }}"><strong>Alle Events und Ausfahrten</strong>, zu denen du dich angemeldet hast</a>
<br />
<small>Dieser Link enthält einen zufällig generierten Teil, damit nur du (und jene, denen du diesen Link weitergibst) Zugang zu diesen Daten hast.</small>
</li>
<li>
<a class="break-all underline" href="https://app.rudernlinz.at/cal"><strong>Alle Events</strong></a>
<br />
<small>Beachte, dass dieser Kalender keine Ausfahrten enthält, die von einzelnen Steuerpersonen augeschrieben werden. Dieser Kalender auf der Vereinswebsite verwendet werden, wo zB keine persönlichen Daten (Namen etc.) veröffentlicht werden soll.</small>
</li>
</ol>
Du kannst die Kalender einfach in deinen Kalender als "externen Kalender" synchronisieren. Die genauen Schritte hängen von deiner verwendeten Software ab.
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,39 @@
{% import "includes/macros" as macros %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">Aktuelle Woche</h1>
<details>
<summary>Dirty Thirty</summary>
<p>
<div class="border-r border-l">
<textarea style="width: 100%; height: 300px;">
{%- for stat in thirty %}
{%- set names = stat.name | split(pat=" ") %}{% set lastname_index = names | length - 1 %}{% set lastname = names[lastname_index] %}{{ lastname }}&#9;
{%- for name in names -%}
{% if loop.index != lastname_index +1 %}{{ name }}{% endif %}
{%- endfor -%}
&#9;{{ stat.dob }}&#9;{{ stat.weight }}&#9;{{ stat.sex }}&#9;&#9;DLI&#9;{{ stat.result }}&#13;&#10;
{%- endfor -%}
</textarea>
</div>
</p>
</details>
<details>
<summary>Dirty Dozen</summary>
<p>
<div class="border-r border-l">
<textarea style="width: 100%; height: 300px;">
{%- for stat in dozen -%}
{%- set names = stat.name | split(pat=" ") %}{% set lastname_index = names | length - 1 %}{% set lastname = names[lastname_index] %}{{ lastname }}&#9;
{%- for name in names -%}
{% if loop.index != lastname_index +1 %}{{ name }}{% endif %}
{%- endfor -%}
&#9;{{ stat.dob }}&#9;{{ stat.weight }}&#9;{{ stat.sex }}&#9;&#9;DLI&#9;{{ stat.result }}&#13;&#10;
{%- endfor -%}
</textarea>
</div>
</p>
</details>
</div>
{% endblock content %}

View File

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

View File

@ -0,0 +1,37 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/boat" as boat %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full dark:text-white">
<h1 class="h1">Ergo Challenge</h1>
<div class="grid ">
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<p class="px-3 pt-3">
Schön, dass du heuer bei der Ergo Challenge mitmachen willst!
Dafür benötigen wir 3 Daten: Geburtsjahr, Gewicht und Geschlecht.
{% if loggedin_user.weight %}Wir haben von dir schon Daten, bitte überprüfe (und aktualisiere) diese kurz:{% endif %}
</p>
<form action="/ergo/set-data" method="post" class="grid gap-3 p-3">
{{ macros::input(label="Geburtsjahr [YYYY]", name="birthyear", required=true, type="number", class="input rounded-md", value=loggedin_user.dob) }}
{{ macros::input(label="Gewicht [kg]", name="weight", required=true, type="number", class="input rounded-md", value=loggedin_user.weight) }}
<div>
<label for="sex" class="text-sm text-gray-600 dark:text-gray-100">Geschlecht</label>
<select name="sex" id="sex" class="input rounded-md" required>
<option disabled="disabled"
{% if loggedin_user.sex != 'f' and loggedin_user.sex != 'm' %}selected="selected"{% endif %}>
Geschlecht auswählen
</option>
<option value="f"
{% if loggedin_user.sex == 'f' %}selected="selected"{% endif %}>weiblich</option>
<option value="m"
{% if loggedin_user.sex == 'm' %}selected="selected"{% endif %}>männlich</option>
</select>
<small class="block py-1">Du fühlst dich beim Geschlecht nicht angesprochen? Dann melde dich bitte direkt beim Ergo-Christian, Kontaktmöglichkeit auf der nächsten Seite.</small>
</div>
<input type="submit" class="btn btn-primary" value="Abschicken" />
</form>
</div>
</div>
</div>
{% endblock content %}

View File

@ -1,79 +0,0 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/log" as log %}
{% extends "base" %}
{% block content %}
<div id="notification"
class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5 mb-5"
role="alert">
<h2 class="h2">FAQ</h2>
<div class="mt-8">
<p class="p-2">
Willkommen in der Testversion von ruad.at!
Hier wird nochmal <s>alles</s> vieles erklärt.
Wenn du Fragen/Wünsche/... hast, kannst du dich gerne jederzeit unter <a href="mailto:philipp@hofer.link">philipp@hofer.link</a> melden.
</p>
<details class="p-2">
<summary>Rollen: Admin, Steuerperson, Anfänger + Eventmanager</summary>
<p>
Aktuell gibt es <b>4 Rollen</b>, die jedes Mitglied haben kann:
<ol class="list-decimal p-5">
<li><emph>Admin:</emph> dürfen Mitglieder verwalten (siehe Menüeintrag rechts oben &rarr; <q>Mitgliederverwaltung</q></li>
<li><emph>Steuerperson:</emph> können selbstständig <q>Ausfahrten</q> ausschreiben/bearbeiten, und sich zum Steuern bei <q>Events</q> melden</li>
<li><emph>Anfänger:</emph> sehen nur Ausfahrten und Events, die explizit für Anfänger ausgeschrieben wurden</li>
<li><emph>Eventmanager:</emph> können <q>Events</q> ausschreiben/bearbeiten</li>
</ol>
</p>
</details>
<details class="p-2">
<summary>Rudertrips: Ausfahrten + Events</summary>
<p class="mt-3">
Es gibt 2 Arten von Rudertrips, die ausgeschrieben werden können:
<ol class="list-decimal p-5">
<li>Ausfahrten: Können jederzeit von Steuerpersonen ausgeschrieben/bearbeitet werden</li>
<li>Events: für Veranstaltungen, wo nicht nur RudererInnen gesucht werden, sondern auch Steuerpersonen (zB Anrudern, Abrudern, Sternfahrten, Wanderfahrten, ...)</li>
</ol>
</p>
</details>
<details class="p-2">
<summary>Bearbeiten</summary>
<p class="mt-3">
Details, wie zB Anmerkungen können jederzeit geändert werden.
Wichtige Infos, auf die sich Rudernde verlassen (zB Startzeit und Ausfahrtstyp) können nicht mehr geändert werden.
Wenn sich die Startzeit ändert, kann man die Ausfahrt/Event absagen und stattdessen einen neuen Trip ausschreiben.
</p>
</details>
<details class="p-2">
<summary>Absagen/Löschen</summary>
<p class="mt-3">
Ausfahrten und Events können gelöscht werden, solange keine Ruderer angemeldet sind.
Sobald jemand angemeldet ist, kann die Ausfahrt/Event nicht mehr gelöscht werden, dafür <q>abgesagt</q> werden.
In diesem Fall bekommen alle die sich angemeldet haben eine Nachricht.
Sobald alle die Nachricht gelesen haben, wird der Trip automatisch gelöscht.
</p>
</details>
<details class="p-2">
<summary>Wieviele Tage sehe ich?</summary>
<p class="mt-3">
Rudernde sehen alle Trips 10 Tage im voraus + zusätzlich alle, wo <q>Immer Anzeigen</q> ausgewählt wurde.
Steuerpersonen sehen das ganze Jahr (um im Vorhinein Ausfahrten ausschreiben zu können). Ab Dezember sehen sie auch das volle kommende Jahr.
</p>
</details>
<details class="p-2">
<summary>Mitgliederdaten einspielen</summary>
<p class="mt-3">
Wenn du auch schon Mitgliederdaten testen willst, kannst du mir gern in <q>irgendeinem</q> maschinell lesbaren Format geben (csv, Excel, txt, ...), ich brauche lediglich den Namen (eindeutig, da dieser für den Login verwendet wird) und welche Rolle(n) der User hat: Admin, Anfänger, Steuerperson, Eventmanager
</p>
</details>
<details class="p-2">
<summary>Welcher Wasserstand wird angezeigt?</summary>
<p class="mt-3">
In der Demo wirk der (prognostizierte) Wasserstand von Linz angezeigt. Solltet ihr euch im Verein für ruad.at entscheiden, können wir uns gemeinsam ansehen, welche Datenquelle wir am Besten für euren Verein verwenden.
</p>
</details>
</div>
</div>
{% endblock content %}

View File

@ -9,7 +9,7 @@
{{ macros::input(label='Startzeit', name='tripdetails.planned_starting_time', type='time', required=true) }}
{{ macros::input(label='Anzahl Steuerleute', name='planned_amount_cox', type='number', required=true, min='0') }}
{{ macros::input(label='Anzahl Ruderer (ohne Steuerperson)', name='tripdetails.max_people', type='number', required=true, min='0') }}
{{ macros::checkbox(label='Anfänger-Anmeldungen erlauben', name='tripdetails.allow_guests') }}
{{ macros::checkbox(label='Scheckbuch-Anmeldungen erlauben', name='tripdetails.allow_guests') }}
{{ macros::checkbox(label='Immer anzeigen', name='always_show') }}
{{ macros::input(label='Anmerkungen', name='tripdetails.notes', type='input') }}
{{ macros::select(label='Typ', data=trip_types, name='tripdetails.trip_type', default='Reguläre Ausfahrt') }}

View File

@ -4,7 +4,7 @@
<input class="day-js" type="hidden" name="day" value="" />
{{ macros::input(label='Startzeit (zB "10:00")', name='planned_starting_time', type='time', required=true) }}
{{ macros::input(label='Anzahl Ruderer (ohne Steuerperson)', name='max_people', type='number', required=true, min='0') }}
{{ macros::checkbox(label='Anfänger-Anmeldungen erlauben', name='allow_guests') }}
{{ macros::checkbox(label='Scheckbuch-Anmeldungen erlauben', name='allow_guests') }}
{{ macros::input(label='Anmerkungen', name='notes', type='input') }}
{% if loggedin_user.allowed_to_steer %}
{{ macros::select(label='Typ', data=trip_types, name='trip_type', default='Reguläre Ausfahrt') }}

View File

@ -7,17 +7,17 @@
role="alert">
<h2 class="h2">Allgemein</h2>
<div class="p-3">
Die Website wird von ruad.at betrieben.
Die Website wird vom ASKÖ Ruderverein Donau Linz betrieben.
<br />
<strong>Postanschrift:</strong>
<br />
Philipp Hofer
ASKÖ Ruderverein Donau Linz
<br />
Rubinweg 8
Heilhamerweg 2
<br />
4225 Luftenberg
4040 Linz
<br />
Mail: philipp@hofer.link
ZVR: 363903285
</div>
</div>
</div>
@ -55,11 +55,39 @@
<li>
<strong>Letzter Zugriff:</strong> {{ loggedin_user.last_access }}
</li>
<li>
<strong>Mitglied seit:</strong> {{ loggedin_user.member_since_date }}
</li>
<li>
<strong>Geburtsdatum:</strong> {{ loggedin_user.birthdate }}
</li>
<li>
<strong>Mail:</strong> {{ loggedin_user.mail }}
</li>
{% if loggedin_user.nickname %}
<li>
<strong>Spitzname:</strong> {{ loggedin_user.nickname }}
</li>
{% endif %}
<li>
<strong>Telefonnummer:</strong> {{ loggedin_user.phone }}
</li>
<li>
<strong>Adresse:</strong> {{ loggedin_user.address }}
</li>
<li>(Beitrittserklärung)</li>
{% if loggedin_user.family_id %}
<li>Verbindung zu Familienmitglied (gespeichert um Familientarif anstatt Vollmitglied zu haben)</li>
{% endif %}
<li>
<strong>Rollen:</strong> {{ loggedin_user.roles }} (werden für verschiedene Funktionen im Ruderassistenten verwendet)
</li>
<li>Anmeldungen zu Ausfahrten</li>
<li>Anmeldungen zu Events (zB Fetzenfahrt, Anrudern, USI-Rudern, ...)</li>
<li>Logbucheinträge</li>
<li>Selber eingetragene Bootsschäden, solange sie nicht > 1 Monat verifiziert und repariert wurden</li>
<li>Selber eingetragene Bootsreservierung</li>
<li>Boote, sofern es welche im Privatbesitz gibt</li>
</ul>
</div>
</div>
@ -75,7 +103,7 @@
Die <strong>Wetterdaten</strong> werden von <a class="underline" href="https://openweathermap.org">OpenWeather</a> bereitgestellt.
</li>
<li>
<strong>Wasserstandsvorhersagen:</strong> Die Vorhersagen werden stündlich vom <a class="underline" href="https://hydro.ooe.gv.at">Hydrographischen Dienstes Oberösterreich</a> geladen und zwischengespeichert, der höchste Tages-Mittelwert wird gemeinsam mit der Schwankungsbreite bei den geplanten Ausfahrten angezeigt. Es handelt sich hierbei um ungeprüfte Rohdaten. Rohdatenfehler können durch betriebliche Störungen an den Messgeräten, Fernübertragungseinrichtungen u. dgl. entstehen. Die Vorhersagen sind daher mit Unsicherheiten behaftet! Mit der Länge des Vorhersagezeitraumeszeitraumes werden diese Unsicherheiten größer! Es wird keine Gewähr für die Vollständigkeit, Richtigkeit und Genauigkeit der dargestellten Daten übernommen. Gewährleistungs- und Haftungsansprüche werden ausdrücklich ausgeschlossen (sowohl vom Hydrographischen Dienstes Oberösterreich als auch von ruad.at).
<strong>Wasserstandsvorhersagen:</strong> Die Vorhersagen werden stündlich vom <a class="underline" href="https://hydro.ooe.gv.at">Hydrographischen Dienstes Oberösterreich</a> geladen und zwischengespeichert, der höchste Tages-Mittelwert wird gemeinsam mit der Schwankungsbreite bei den geplanten Ausfahrten angezeigt. Es handelt sich hierbei um ungeprüfte Rohdaten. Rohdatenfehler können durch betriebliche Störungen an den Messgeräten, Fernübertragungseinrichtungen u. dgl. entstehen. Die Vorhersagen sind daher mit Unsicherheiten behaftet! Mit der Länge des Vorhersagezeitraumeszeitraumes werden diese Unsicherheiten größer! Es wird keine Gewähr für die Vollständigkeit, Richtigkeit und Genauigkeit der dargestellten Daten übernommen. Gewährleistungs- und Haftungsansprüche werden ausdrücklich ausgeschlossen (sowohl vom Hydrographischen Dienstes Oberösterreich als auch vom ASKÖ Ruderverein Donau Linz).
</li>
</ul>
</div>

View File

@ -3,7 +3,8 @@
<div class="w-full flex justify-between items-center">
<div>
<span class="text-[#ff0000]">&hearts;</span>
Erstellt von <a class="underline" href="https://ruad.at" target="_blank">ruad.at</a>
Erstellt vom ASKÖ Ruderverein Donau Linz <a class="underline"
onclick="alert('Wir suchen kreative und motivierte Köpfe, die diesen Ruderassistenten mitgestalten möchten. Das Backend ist in Rust (Rocket), das Frontend in TypeScript und Teraform, wobei wir mit dem Gedanken spielen, zu Svelte(Kit) zu wechseln.\n\nWenn du Lust hast, deine Skills in ein Projekt zu stecken, das Wellen schlagen wird, dann komm an Bord! Wir sind offen für frische Ideen, haben jedoch auch selber noch genügend; langweilig wird uns bestimmt nicht.\n\nWirf den Anker bei uns ausi und melde dich bei Marie oder Philipp oder it@rudernlinz.at für eine Zukunft ohne optische Kenterung in Form von hässlichen Alerts ;)');">... und dir?</a>
</div>
<div>
<button id="theme-toggle-js"

View File

@ -73,16 +73,13 @@ function setChoiceByLabel(choicesInstance, label) {
<div class="max-w-screen-xl w-full flex justify-between items-center">
<div class="w-1/3 truncate">
<a href="/">
Ahoi
{{ loggedin_user.name }}
</a>
</div>
<div class="w-1/3 truncate">
<a href="/faq">💡</a>
</div>
<div class="flex items-center">
{% if loggedin_user.amount_unread_notifications > 0 %}
<a href="/notifications"
<a href="/#notification"
class="relative inline-flex items-end ms-2 me-3">
<svg height="20"
width="24"
@ -109,38 +106,31 @@ function setChoiceByLabel(choicesInstance, label) {
</a>
<div class="hidden">
<div id="mobile-menu">
<a href="/" class="block w-100 py-2 hover:text-primary-600">Geplante Ausfahrten</a>
<a href="/kalender" class="block w-100 py-2 hover:text-primary-600 border-t">Kalender</a>
{% if "admin" in loggedin_user.roles %}
<div class="block w-100 py-2 border-t ">
{% if "Donau Linz" in loggedin_user.roles %}
<a href="/planned" class="block w-100 py-2 hover:text-primary-600">Geplante Ausfahrten</a>
<a href="/log" class="block w-100 py-2 hover:text-primary-600 border-t">Ausfahrt eintragen</a>
<a href="/log/show"
class="block w-100 py-2 hover:text-primary-600 border-t">Logbuch</a>
<a href="/stat" class="block w-100 py-2 hover:text-primary-600 border-t">Statistik</a>
<a href="/stat/boats"
class="block w-100 py-2 hover:text-primary-600 border-t">Bootsauswertung</a>
<a href="/boatdamage"
class="block w-100 py-2 hover:text-primary-600 border-t">Bootsschaden</a>
<a href="/boatreservation"
class="block w-100 py-2 hover:text-primary-600 border-t">Bootsreservierung</a>
<a href="/trailerreservation"
class="block w-100 py-2 hover:text-primary-600 border-t">Hängerreservierung</a>
{% endif %}
{% if loggedin_user.weight and loggedin_user.sex and loggedin_user.dob %}
<a href="/ergo" class="block w-100 py-2 hover:text-primary-600 border-t">Ergo</a>
{% endif %}
{% if "admin" in loggedin_user.roles or "Vorstand" in loggedin_user.roles %}
<a href="/admin/user"
class="hover:text-primary-600">Mitgliederverwaltung
</a>
<span class=""
onclick="event.preventDefault(); event.stopPropagation();this.nextElementSibling.showModal()">🛡️</span>
<dialog
class="max-w-screen-sm dark:bg-primary-600 dark:text-white rounded-md"
onclick="this.close()">
<div onclick="event.stopPropagation();" class="p-3">
<button type="button"
onclick="this.parentNode.parentNode.close()"
title="Schließen"
class="sidebar-close border-0 bg-primary-100 focus:bg-primary-50 text-black flex items-center justify-center transform rotate-45 absolute right-0 mr-3">
<svg class="inline h-5 w-5"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"></path>
</svg>
</button>
<div class="mt-8">
<p>
Diesen Punkt sehen nur Mitglieder mit der Rolle <q>admin</q>
</p>
</div>
</dialog>
</div>
class="block w-100 py-2 hover:text-primary-600 border-t">Userverwaltung</a>
{% endif %}
{% if "admin" in loggedin_user.roles %}
<a href="/admin/boat"
class="block w-100 py-2 hover:text-primary-600 border-t">Boote</a>
{% endif %}
<a href="/auth/logout"
class="block w-100 py-2 hover:text-primary-600 border-t">Ausloggen
@ -164,7 +154,6 @@ function setChoiceByLabel(choicesInstance, label) {
</div>
</div>
</header>
<div class="h-8"></div>
{% endmacro header %}
{% macro input(label, name, type, required=false, class='rounded-md', value='', min='', hide_label=false, id='', autofocus=false, wrapper_class='', pattern='', readonly=false, accept='') %}
@ -188,20 +177,7 @@ function setChoiceByLabel(choicesInstance, label) {
{% if readonly %}readonly{% endif %}>
</div>
{% endmacro input %}
{% macro fancy_role_name(name) %}
{%- if name == "cox" -%}
Steuerperson
{%- elif name == "manage_events" -%}
Eventmanager
{%- elif name == "admin" -%}
Admin
{%- elif name == "scheckbuch" -%}
Anfänger
{%- else -%}
{{name}}
{%- endif -%}
{% endmacro fancy_role_name %}
{% macro checkbox(label, name, id='', checked=false, class='', disabled=false, readonly=false, help=false) %}
{% macro checkbox(label, name, id='', checked=false, class='', disabled=false, readonly=false) %}
<label for="{{ name }}{{ id }}"
class="flex items-center cursor-pointer text-black dark:text-white hover:text-gray-900 dark:hover:text-gray-100 {{ class }}">
<input type="checkbox"
@ -211,34 +187,7 @@ function setChoiceByLabel(choicesInstance, label) {
{% if disabled %}disabled{% endif %}
{% if readonly %}readonly="readonly"{% endif %}
class="h-4 w-4 accent-primary-600 dark:accent-primary-200 mr-2" />
{{ self::fancy_role_name(name=label) }}
{% if help %}
<span class=""
onclick="this.nextElementSibling.showModal()">❓</span>
<dialog
class="max-w-screen-sm dark:bg-primary-600 dark:text-white rounded-md"
onclick="this.close()">
<div onclick="event.stopPropagation();" class="p-3">
<button type="button"
onclick="this.parentNode.parentNode.close()"
title="Schließen"
class="sidebar-close border-0 bg-primary-100 focus:bg-primary-50 text-black flex items-center justify-center transform rotate-45 absolute right-0 mr-3">
<svg class="inline h-5 w-5"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"></path>
</svg>
</button>
<div class="mt-8">
<p>
{{help}}
</p>
</div>
</dialog>
{% endif %}
{{ label }}
</label>
{% endmacro checkbox %}
{% macro select(label, data, name='trip_type', default='', id='', selected_id='', display='', extras='', class='', wrapper_class='', required=false, show_seats=false, new_last_entry='', nonSelectableDefault=false, only_ergo=false) %}
@ -281,11 +230,11 @@ function setChoiceByLabel(choicesInstance, label) {
{% for rower in participants %}
<div class="relative">
{{ rower.name }}
{% if rower.is_guest %}<small class="text-gray-600 dark:text-gray-100">(Anfänger)</small>{% endif %}
{% if rower.is_guest %}<small class="text-gray-600 dark:text-gray-100">(Scheckbuch)</small>{% endif %}
{% if rower.is_real_guest %}
<small class="text-gray-600 dark:text-gray-100">(Gast)</small>
{% if allow_removing %}
<a href="/remove/{{ trip_details_id }}/{{ rower.name | urlencode }}"
<a href="/planned/remove/{{ trip_details_id }}/{{ rower.name | urlencode }}"
class="absolute r-0 bg-red-500 w-5 h-5 text-white rounded-full flex items-center justify-center transform rotate-45 top-0 right-0">
<svg class="inline h-5 w-5"
width="16"

View File

@ -2,416 +2,458 @@
{% import "includes/forms/log" as log %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-xl w-full grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<h1 class="h1 sm:col-span-2 lg:col-span-3">Ausfahrten</h1>
{% include "includes/buttons" %}
{% for day in days %}
{% set amount_trips = day.events | length + day.trips | length %}
{% set_global day_cox_needed = false %}
{% if day.events | length > 0 %}
{% for event in day.events %}
{% if event.cox_needed %}
{% set_global day_cox_needed = true %}
{% endif %}
{% endfor %}
{% endif %}
<div id="{{ day.day| date(format="%Y-%m-%d") }}"
class="bg-white dark:bg-primary-900 rounded-md flex justify-between flex-col shadow reset-js"
style="min-height: 10rem"
data-trips="{{ amount_trips }}"
data-month="{{ day.day| date(format='%m') }}"
data-coxneeded="{{ day_cox_needed }}">
<div>
<h2 class="font-bold uppercase tracking-wide text-center rounded-t-md {% if day.is_pinned %} text-white bg-primary-950 {% else %} text-primary-950 dark:text-white bg-gray-200 dark:bg-primary-950 bg-opacity-80 {% endif %} text-lg px-3 py-3 ">
{{ day.day| date(format="%d.%m.%Y") }}
<small class="inline-block ml-1 text-xs {% if day.is_pinned %} text-gray-200 {% else %} text-gray-500 dark:text-gray-100 {% endif %}">{{ day.day | date(format="%A", locale="de_AT") }}
{% if day.max_waterlevel %}
&bullet; <a href="https://hydro.ooe.gv.at/#/overview/Wasserstand/station/16668/Linz/Wasserstand"
target="_blank"
title="Prognostizierter maximaler Wasserstand am {{ day.day | date(format="%A", locale="de_AT") }}: {{ day.max_waterlevel.avg }} ± {{ day.max_waterlevel.fluctuation }} cm (ungeprüfte Rohdaten, für Details siehe die Infos dazu im Impressum)">🌊{{ day.max_waterlevel.avg }} ± {{ day.max_waterlevel.fluctuation }} cm</a>
{% endif %}
</small>
{% if day.weather %}
<small class="inline-block text-xs {% if day.is_pinned %} text-gray-200 {% else %} text-gray-500 dark:text-gray-100 {% endif %}">
Temp:&nbsp;{{ day.weather.max_temp | round }}° &bullet; Windböe:&nbsp;{{ day.weather.wind_gust | round }}&nbsp;km/h &bullet; Regen:&nbsp;{{ day.weather.rain_mm | round }}&nbsp;mm
</small>
{% endif %}
</h2>
{% if day.events | length > 0 or day.trips | length > 0 %}
<div class="grid grid-cols-1 gap-3 mb-3">
{# --- START Events --- #}
{% if day.events | length > 0 %}
{% for event in day.events | sort(attribute="planned_starting_time") %}
{% set amount_cur_cox = event.cox | length %}
{% set amount_cox_missing = event.planned_amount_cox - amount_cur_cox %}
<div class="pt-2 px-3 border-t border-gray-200"
style="order: {{ event.planned_starting_time | replace(from=":", to="") }}">
<div class="flex justify-between items-center">
<div class="mr-1">
{% if event.always_show and not day.regular_sees_this_day %}
<span title="Du siehst diese Ausfahrt schon, obwohl sie mehr als {{ amount_days_to_show_trips_ahead }} Tage in der Zukunft liegt. Du Magier!">🔮</span>
{% endif -%}
{%- if event.max_people == 0 %}
<strong class="text-[#f43f5e]">&#9888; Absage
{{ event.planned_starting_time }}
Uhr
</strong>
<small class="text-[#f43f5e]">({{ event.name }}
{%- if event.trip_type %}
- {{ event.trip_type.icon | safe }}&nbsp;{{ event.trip_type.name }}
{%- endif -%}
)</small>
{% else %}
<strong class="text-primary-900 dark:text-white">
{{ event.planned_starting_time }}
Uhr
</strong>
<small class="text-gray-600 dark:text-gray-100">({{ event.name }}
{%- if event.trip_type %}
- {{ event.trip_type.icon | safe }}&nbsp;{{ event.trip_type.name }}
{%- endif -%}
)</small>
{% endif %}
<br />
<a href="#" data-sidebar="true" data-trigger="sidebar" data-header="<strong>{{ event.planned_starting_time }} Uhr</strong> ({{ event.name }})
{% if event.trip_type %}<small class='block'>{{ event.trip_type.desc }}</small>{% endif %}
{% if event.notes %}<small class='block'>{{ event.notes }}</small>{% endif %}
" data-body="#event{{ event.trip_details_id }}" class="inline-block link-primary mr-3">
Details
</a>
</div>
<div class="text-right grid gap-2">
{# --- START Row Buttons --- #}
{% set_global cur_user_participates = false %}
{% for rower in event.rower %}
{% if rower.name == loggedin_user.name %}
{% set_global cur_user_participates = true %}
{% endif %}
{% endfor %}
{% if cur_user_participates %}
<a href="/remove/{{ event.trip_details_id }}"
class="btn btn-attention btn-fw">Abmelden</a>
{% endif %}
{% if event.max_people > event.rower | length and cur_user_participates == false %}
<a href="/join/{{ event.trip_details_id }}"
class="btn btn-primary btn-fw"
{% if event.trip_type %}onclick="return confirm('{{ event.trip_type.question }}');"{% endif %}>Mitrudern</a>
{% endif %}
{# --- END Row Buttons --- #}
{# --- START Cox Buttons --- #}
{% if loggedin_user.allowed_to_steer %}
{% set_global cur_user_participates = false %}
{% for cox in event.cox %}
{% if cox.name == loggedin_user.name %}
{% set_global cur_user_participates = true %}
{% endif %}
{% endfor %}
{% if cur_user_participates %}
<a href="/cox/remove/{{ event.id }}"
class="block btn btn-attention btn-fw">
{% include "includes/cox-icon" %}
Abmelden
</a>
{% elif event.planned_amount_cox > 0 %}
<a href="/cox/join/{{ event.id }}"
class="block btn {% if amount_cox_missing > 0 %} btn-dark {% else %} btn-gray {% endif %} btn-fw"
{% if event.trip_type %}onclick="return confirm('{{ event.trip_type.question }}');"{% endif %}>
{% include "includes/cox-icon" %}
Steuern
</a>
{% endif %}
{% endif %}
{# --- END Cox Buttons --- #}
</div>
</div>
{# --- START Sidebar Content --- #}
<div class="hidden">
<div id="event{{ event.trip_details_id }}">
{# --- START List Coxes --- #}
{% if event.planned_amount_cox > 0 %}
{% if event.max_people == 0 %}
{{ macros::box(participants=event.cox, empty_seats="", header='Absage', bg='[#f43f5e]') }}
{% else %}
{% if amount_cox_missing > 0 %}
{{ macros::box(participants=event.cox, empty_seats=event.planned_amount_cox - amount_cur_cox, header='Noch benötigte Steuerleute:', text='Keine Steuerleute angemeldet') }}
{% else %}
{{ macros::box(participants=event.cox, empty_seats="", header='Genügend Steuerleute haben sich angemeldet :-)', text='Keine Steuerleute angemeldet') }}
{% endif %}
{% endif %}
{% endif %}
{# --- END List Coxes --- #}
{# --- START List Rowers --- #}
{% set amount_cur_rower = event.rower | length %}
{% if event.max_people == 0 %}
{{ macros::box(header='Absage', bg='[#f43f5e]', participants=event.rower, trip_details_id=event.trip_details_id, allow_removing="manage_events" in loggedin_user.roles) }}
{% else %}
{{ macros::box(participants=event.rower, empty_seats=event.max_people - amount_cur_rower, bg='primary-100', color='black', trip_details_id=event.trip_details_id, allow_removing="manage_events" in loggedin_user.roles) }}
{% endif %}
{# --- END List Rowers --- #}
{% if "manage_events" in loggedin_user.roles %}
<form action="/join/{{ event.trip_details_id }}" method="get" />
{{ macros::input(label='Gast', class="input rounded-t", name='user_note', type='text', required=true) }}
<input value="Gast hinzufügen"
class="btn btn-primary w-full rounded-t-none-important"
type="submit" />
</form>
{% endif %}
{% if event.allow_guests %}
<div class="text-primary-900 bg-primary-50 text-center p-1 mb-4">Gäste willkommen!</div>
{% endif %}
{% if "manage_events" in loggedin_user.roles %}
{# --- START Edit Form --- #}
<div class="bg-gray-100 dark:bg-primary-900 p-3 mt-4 rounded-md">
<h3 class="text-primary-950 dark:text-white font-bold uppercase tracking-wide mb-2">Ausfahrt bearbeiten</h3>
<form action="/admin/planned-event" method="post" class="grid gap-3">
<input type="hidden" name="_method" value="put" />
<input type="hidden" name="id" value="{{ event.id }}" />
{{ macros::input(label='Titel', name='name', type='input', value=event.name) }}
{{ macros::input(label='Anzahl Ruderer', name='max_people', type='number', required=true, value=event.max_people, min='1') }}
{{ macros::input(label='Anzahl Steuerleute', name='planned_amount_cox', type='number', value=event.planned_amount_cox, required=true, min='0') }}
{{ macros::checkbox(label='Immer anzeigen', name='always_show', id=event.id,checked=event.always_show, help="Grundsätzlich sehen Rudernde Ausfahrten 10 Tage im vorhinein. Wenn du diese Option aktivierst, ist diese Ausfahrt sofort allen ersichtlich.") }}
{{ macros::checkbox(label='Gesperrt', name='is_locked', id=event.id,checked=event.is_locked, help="Wenn diese Option aktiviert ist, kann sich keiner mehr an- und abmelden. Sinnvoll, wenn zB bereits die Bootseinteilung vorgenommen wurde") }}
{{ macros::select(label='Typ', name='trip_type', data=trip_types, default='Reguläre Ausfahrt', selected_id=event.trip_type_id) }}
{{ macros::input(label='Anmerkungen', name='notes', type='input', value=event.notes) }}
<input value="Speichern" class="btn btn-primary" type="submit" />
</form>
</div>
{# --- END Edit Form --- #}
{# --- START Delete Btn --- #}
{% if event.rower | length == 0 and amount_cur_cox == 0 %}
<div class="text-right mt-6">
<a href="/admin/planned-event/{{ event.id }}/delete"
class="inline-block btn btn-alert">
{% include "includes/delete-icon" %}
Termin löschen
</a>
</div>
{% else %}
{% if event.max_people == 0 %}
Wenn du deine Absage absagen (:^)) willst, einfach entsprechende Anzahl an Ruderer oben eintragen.
{% else %}
<div class="bg-gray-100 dark:bg-primary-900 p-3 mt-4 rounded-md">
<h3 class="text-primary-950 dark:text-white font-bold uppercase tracking-wide mb-2">Event absagen</h3>
<form action="/admin/planned-event" method="post" class="grid">
<input type="hidden" name="_method" value="put" />
<input type="hidden" name="id" value="{{ event.id }}" />
{{ macros::input(label='Grund der Absage', name='notes', type='input', value='') }}
{{ macros::input(label='', name='max_people', type='hidden', value=0) }}
{{ macros::input(label='', name='name', type='hidden', value=event.name) }}
{{ macros::input(label='', name='max_people', type='hidden', value=event.max_people) }}
{{ macros::input(label='', name='planned_amount_cox', type='hidden', value=event.planned_amount_cox) }}
{{ macros::input(label='', name='always_show', type='hidden', value=event.always_show) }}
{{ macros::input(label='', name='is_locked', type='hidden', value=event.is_locked) }}
{{ macros::input(label='', name='trip_type', type='hidden', value=event.trip_type_id) }}
<input value="Ausfahrt absagen" class="btn btn-alert" type="submit" />
</form>
</div>
{% endif %}
{% endif %}
{% endif %}
{# --- END Delete Btn --- #}
</div>
</div>
{# --- END Sidebar Content --- #}
</div>
{% endfor %}
{% endif %}
{# --- END Events --- #}
{# --- START Trips --- #}
{% if day.trips | length > 0 %}
{% for trip in day.trips | sort(attribute="planned_starting_time") %}
<div class="pt-2 px-3 reset-js border-t border-gray-200"
style="order: {{ trip.planned_starting_time | replace(from=":", to="") }}"
data-coxneeded="false">
<div class="flex justify-between items-center">
<div class="mr-1">
{% if trip.always_show and not day.regular_sees_this_day %}
<span title="Du siehst diese Ausfahrt schon, obwohl sie mehr als {{ amount_days_to_show_trips_ahead }} Tage in der Zukunft liegt. Du Magier!">🔮</span>
{% endif -%}
{% if trip.max_people == 0 %}
<strong class="text-[#f43f5e]">&#9888;
{{ trip.planned_starting_time }}
Uhr</strong>
<small class="text-[#f43f5e]">(Absage
{{ trip.cox_name -}}
{% if trip.trip_type %}
-
{{ trip.trip_type.icon | safe }}&nbsp;{{ trip.trip_type.name }}
{%- endif -%}
)</small>
{% else %}
<strong class="text-primary-900 dark:text-white">{{ trip.planned_starting_time }}
Uhr</strong>
<small class="text-gray-600 dark:text-gray-100">({{ trip.cox_name -}}
{% if trip.trip_type %}
- {{ trip.trip_type.icon | safe }}&nbsp;{{ trip.trip_type.name }}
{%- endif -%}
)</small>
{% endif %}
<br />
<a href="#" data-sidebar="true" data-trigger="sidebar" data-header="<strong>
{% if trip.max_people == 0 %}&#9888;{% endif %}
{{ trip.planned_starting_time }} Uhr</strong> ({{ trip.cox_name }})
{% if trip.trip_type %}<small class='block'>{{ trip.trip_type.desc }}</small>{% endif %}
{% if trip.notes %}<small class='block'>{{ trip.notes }}</small>{% endif %}
" data-body="#trip{{ trip.trip_details_id }}" class="inline-block link-primary mr-3">
Details
</a>
</div>
<div>
{% set_global cur_user_participates = false %}
{% for rower in trip.rower %}
{% if rower.name == loggedin_user.name %}
{% set_global cur_user_participates = true %}
{% endif %}
{% endfor %}
{% if cur_user_participates %}
<a href="/remove/{{ trip.trip_details_id }}"
class="btn btn-attention btn-fw">Abmelden</a>
{% endif %}
{% if trip.max_people > trip.rower | length and trip.cox_id != loggedin_user.id and cur_user_participates == false %}
<a href="/join/{{ trip.trip_details_id }}"
class="btn btn-primary btn-fw"
{% if trip.trip_type %}onclick="return confirm('{{ trip.trip_type.question }}');"{% endif %}>Mitrudern</a>
{% endif %}
</div>
</div>
{# --- START Sidebar Content --- #}
<div class="hidden">
<div id="trip{{ trip.trip_details_id }}">
{% if trip.max_people == 0 %}
{# --- border-[#f43f5e] bg-[#f43f5e] --- #}
{{ macros::box(participants=trip.rower,bg='[#f43f5e]',header='Absage', trip_details_id=trip.trip_details_id, allow_removing=loggedin_user.id == trip.cox_id) }}
{% else %}
{% set amount_cur_rower = trip.rower | length %}
{{ macros::box(participants=trip.rower, empty_seats=trip.max_people - amount_cur_rower, bg='primary-100', color='black', trip_details_id=trip.trip_details_id, allow_removing=loggedin_user.id == trip.cox_id) }}
{% if trip.cox_id == loggedin_user.id %}
<form action="/join/{{ trip.trip_details_id }}" method="get" />
{{ macros::input(label='Gast', class="input rounded-t", name='user_note', type='text', required=true) }}
<input value="Gast hinzufügen"
class="btn btn-primary w-full rounded-t-none-important"
type="submit" />
</form>
{% endif %}
{% endif %}
{# --- START Edit Form --- #}
{% if trip.cox_id == loggedin_user.id %}
<div class="bg-gray-100 dark:bg-primary-900 p-3 mt-4 rounded-md">
<h3 class="text-primary-950 dark:text-white font-bold uppercase tracking-wide mb-2">Ausfahrt bearbeiten</h3>
<form action="/cox/trip/{{ trip.id }}" method="post" class="grid gap-3">
{{ macros::input(label='Anzahl Ruderer', name='max_people', type='number', required=true, value=trip.max_people, min=trip.rower | length) }}
{{ macros::input(label='Anmerkungen', name='notes', type='input', value=trip.notes) }}
{{ macros::checkbox(label='Gesperrt', name='is_locked', id=trip.id,checked=trip.is_locked, help="Wenn diese Option aktiviert ist, kann sich keiner mehr an- und abmelden. Sinnvoll, wenn zB bereits die Bootseinteilung vorgenommen wurde") }}
{% if loggedin_user.allowed_to_steer %}
{{ macros::select(label='Typ', name='trip_type', data=trip_types, default='Reguläre Ausfahrt', selected_id=trip.trip_type_id, only_ergo=not loggedin_user.allowed_to_steer) }}
{% else %}
{{ macros::select(label='Typ', name='trip_type', data=trip_types, selected_id=trip.trip_type_id, only_ergo=not loggedin_user.allowed_to_steer, only_ergos=true) }}
{% endif %}
<input value="Speichern" class="btn btn-primary" type="submit" />
</form>
</div>
{% if trip.rower | length == 0 %}
<div class="text-right mt-6">
<a href="/cox/remove/trip/{{ trip.id }}"
class="inline-block btn btn-alert">
{% include "includes/delete-icon" %}
Termin löschen
</a>
</div>
{% else %}
{% if trip.max_people == 0 %}
Wenn du deine Absage absagen (:^)) willst, einfach entsprechende Anzahl an Ruderer oben eintragen.
{% else %}
<div class="bg-gray-100 dark:bg-primary-900 p-3 mt-4 rounded-md">
<h3 class="text-primary-950 dark:text-white font-bold uppercase tracking-wide mb-2">Ausfahrt absagen</h3>
<form action="/cox/trip/{{ trip.id }}" method="post" class="grid">
{{ macros::input(label='', name='max_people', type='hidden', value=0) }}
{{ macros::input(label='Grund der Absage', name='notes', type='input', value='') }}
{{ macros::input(label='', name='is_locked', type='hidden', value=trip.is_locked) }}
{{ macros::input(label='', name='trip_type', type='hidden', value=trip.trip_type_id) }}
<input value="Ausfahrt absagen" class="btn btn-alert" type="submit" />
</form>
</div>
{% endif %}
{% endif %}
{% endif %}
{# --- END Edit Form --- #}
{# --- START Admin Form --- #}
{% if allowed_to_update_always_show_trip %}
<div class="bg-gray-100 dark:bg-primary-900 p-3 mt-4 rounded-md">
<h3 class="text-primary-950 dark:text-white font-bold uppercase tracking-wide mb-2">Admin-Modus</h3>
{% if not day.regular_sees_this_day %}
<form action="/cox/trip/{{ trip.id }}/toggle-always-show"
method="get"
class="grid gap-3">
{% if not trip.always_show %}
<small>Diese Ausfahrt sehen aktuell nur Steuerleute (und Admins). {{ amount_days_to_show_trips_ahead }} Tage vorher sehen sie dann alle.</small>
{% else %}
<small>Diese Ausfahrt sehen alle Mitglieder.</small>
{% endif %}
<input value="{% if trip.always_show %}Ausfahrt nur Steuerleute (und Admins) anzeigen{% else %}Ausfahrt allen anzeigen{% endif %}"
class="btn btn-primary"
type="submit" />
</form>
{% endif %}
<a href="/cox/remove/trip/{{ trip.id }}"
class="inline-block btn btn-alert mt-5 w-full">
{% include "includes/delete-icon" %}
Termin löschen
</a>
</div>
{% endif %}
{# --- END Admin Form --- #}
</div>
</div>
{# --- END Sidebar Content --- #}
</div>
{% endfor %}
{% endif %}
{# --- END Trips --- #}
<div class="max-w-screen-lg w-full">
<h1 class="h1">Ruder&shy;assistent</h1>
<div class="grid gap-3 my-5">
<div class="m-auto">
<a href="/planned"
style="display:inline-flex"
class="btn btn-primary flex items-center justify-between w-80 max-w-full">
<span class="text-2xl">🚣‍♀️</span>
<span class="text-xl px-3">Geplante Ausfahrten</span>
<span class="text-2xl">🚣‍♂️</span>
</a>
</div>
{% if show_quick_ergo_button %}
<div class="m-auto">
<a href="/ergo"
style="display:inline-flex"
class="btn btn-primary flex items-center justify-between w-80 max-w-full">
<span class="text-2xl">💪</span>
<span class="text-xl px-3">Ergo Challenge</span>
<span class="text-2xl">💪🏿</span>
</a>
</div>
{% endif %}
</div>
{# --- START Add Buttons --- #}
{% if "manage_events" in loggedin_user.roles or loggedin_user.allowed_to_steer or "ergo" in loggedin_user.roles %}
<div class="grid {% if "manage_events" in loggedin_user.roles and (loggedin_user.allowed_to_steer or "ergo" in loggedin_user.roles) %}grid-cols-2{% endif %} text-center">
{% if "manage_events" in loggedin_user.roles %}
<a href="#" data-sidebar="true" data-trigger="sidebar" data-header="<strong>Event</strong> am {{ day.day| date(format='%d.%m.%Y') }} erstellen" data-day="{{ day.day }}" data-body="#addEventForm" class="relative inline-block w-full bg-primary-900 hover:bg-primary-950 focus:bg-primary-950 dark:bg-primary-950 text-white py-2 text-sm font-semibold
{% if loggedin_user.allowed_to_steer or "ergo" in loggedin_user.roles %}
rounded-bl-md
{% else %}
rounded-b-md
{% if notifications %}
<div id="notification"
class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Nachrichten</h2>
{% if loggedin_user.amount_unread_notifications > 10 %}
<div class="text-primary-950 dark:text-white bg-gray-200 dark:bg-primary-950 bg-opacity-80 text-center pb-3 px-3">
Du hast viele ungelesene Benachrichtigungen. Um deine Oberfläche übersichtlich zu halten und wichtige Updates nicht zu verpassen, nimm dir bitte in Zukunft einen kurzen Moment Zeit sie zu überprüfen und als gelesen zu markieren (&#10003;).<br /><a href="/notification/read/all" class="underline">Du kannst hier ausnahmsweise alle als gelesen markieren.</a>
</div>
{% endif %}
<div class="divide-y">
{% for notification in notifications %}
{% if not notification.read_at %}
<div class="relative flex justify-between items-center p-3">
<div class="grow me-4">
<small class="uppercase text-gray-600 dark:text-gray-100">
<strong>{{ notification.category }}</strong> &bullet; {{ notification.created_at | date(format="%d.%m.%Y %H:%M",) }}
</small>
<div class="mt-1">{{ notification.message | safe }}</div>
</div>
<div>
{% if notification.link %}
<a href="{{ notification.link }}" class="inline-block">
<button class="btn btn-primary" type="button">🔗</button>
</a>
{% endif %}
{% if not notification.read_at %}
<a href="/notification/{{ notification.id }}/read" class="inline-block">
<button class="btn btn-primary" type="button">
&#10003;
<span class="sr-only">Notification gelesen</span>
</button>
</a>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
<details class="py-3 border-t rounded-b-md">
<summary class="px-3 cursor-pointer">Vergangene Nachrichten (14 Tage)</summary>
<div class="divide-y text-sm">
{% for notification in notifications %}
{% if notification.read_at %}
<div class="p-3 relative">
<small class="uppercase text-gray-600 dark:text-gray-100">
<strong>{{ notification.category }}</strong> &bullet; {{ notification.created_at | date(format="%d.%m.%Y %H:%M") }}
</small>
<div class="mt-1">{{ notification.message | safe }}</div>
{% if notification.link %}
<a href="{{ notification.link }}" class="inline-block">
<button class="btn btn-primary" type="button">🔗</button>
</a>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</details>
</div>
{% endif %}
{% if "Donau Linz" in loggedin_user.roles and "Unterstützend" not in loggedin_user.roles and "Förderndes Mitglied" not in loggedin_user.roles %}
<dialog id="call-for-action"
class="max-w-screen-sm dark:bg-primary-600 dark:text-white rounded-md"
onclick="document.getElementById('call-for-action').close()">
<div onclick="event.stopPropagation();" class="p-3">
<button type="button"
onclick="document.getElementById('call-for-action').close()"
title="Schließen"
class="sidebar-close border-0 bg-primary-100 focus:bg-primary-50 text-black flex items-center justify-center transform rotate-45 absolute right-0 mr-3">
<svg class="inline h-5 w-5"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"></path>
</svg>
</button>
<div class="mt-8">
<p>
Du hast Ideen für sinnvolle neue Funktionen für diese Ruderapp? Melde dich bei Philipp, Marie oder <a href="mailto:it@rudernlinz.at" class="underline">it@rudernlinz.at</a>.
</p>
<p class="mt-3">
Wenn du darüber hinaus Lust hast, deine Skills in ein Projekt zu stecken, das Wellen schlagen wird (😀), dann komm an Bord! Wir sind offen für frische Ideen, haben jedoch auch selber noch genügend; langweilig wird uns bestimmt nicht.
</p>
</div>
</div>
</dialog>
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">
Deine Ruderkarriere
<span class="text-xl"
onclick="document.getElementById('call-for-action').showModal()">💡</span>
</h2>
<div class="mx-2 divide-y divide-gray-200 dark:divide-primary-600">
<div class="py-3">
<details>
<summary>
<h3 class="inline">
<span class="text-xl">
{% if achievements.rowingbadge and achievements.rowingbadge.achieved %}
&#127881;
{% else %}
📋
{% endif %}
</span>
Fahrtenabzeichen
{% if achievements.rowingbadge %}{{ achievements.rowingbadge.year }}{% endif %}
<span><a href="http://www.rudern.at/OFFICE/Downloads/Ausschreibungen/2022/Wanderfahrten//Fahrtenabzeichen%20%C3%84quatorpreis%20und%20Danubius%202022.pdf"
target="_blank"
class="w-6 h-6 inline-flex align-center justify-center rounded-full bg-primary-500 ml-2 text-white">?</a></span>
</h3>
</summary>
{% if achievements.rowingbadge %}
{% set badge = achievements.rowingbadge %}
<div class="mb-3">{{ badge.category }}</div>
<label for="rowingbadge" class="label">Kilometer ({{ badge.rowed_km }} / {{ badge.required_km }} km)</label>
<progress id="rowingbadge"
class="w-full block my-3"
value="{{ badge.rowed_km }}"
max="{{ badge.required_km }}"></progress>
<h4 class="font-bold mt-4">Wanderfahrten</h4>
<div>Nur eine der folgenden Optionen muss erreicht werden:</div>
<ol class="list-decimal ml-4 my-3">
<li>
{% if badge.multi_day_trips_over_required_distance | length >= 1 %}
&#9989;
{% else %}
&#10060;
{% endif %}
1 mehrtägige Wanderfahrt > {{ badge.multi_day_trips_required_distance }} km
</li>
<li>
{% if badge.single_day_trips_over_required_distance | length >= 2 %}
&#9989;
{% else %}
&#10060;
{% endif %}
2 eintägige Wanderfahrten > {{ badge.single_day_trips_required_distance }} km
</li>
</ol>
<details>
<summary>Details zu den Wanderfahrten</summary>
<div class="mt-3">
{% for log in badge.single_day_trips_over_required_distance %}
{{ log::show_old(log=log, state="completed", only_ones=false, index=loop.index) }}
{% endfor %}
{% for log in badge.multi_day_trips_over_required_distance %}
{{ log::show_old(log=log, state="completed", only_ones=false, index=loop.index) }}
{% endfor %}
</div>
</details>
{% else %}
<div class="mt-3">
Wir haben leider kein Geburtsdatum von dir und können dir leider deinen heurigen Status für das Fahrtenabzeichen nicht anzeigen. Wenn du dein Geburtsdatum an <a href="mailto:it@rudernlinz.at" class="underline">it@rudernlinz.at</a> schreibst, lässt sich das ändern :-)
</div>
{% endif %}
</details>
</div>
<div class="py-3">
<details>
<summary>
<h3 class="mb-3 inline">
{% set price = achievements.equatorprice %}
<span class="text-xl">
{% if achievements.curr_equatorprice_name == "-" %}
📋
{% elif achievements.curr_equatorprice_name == "Bronze" %}
🥉
{% elif achievements.curr_equatorprice_name == "Silber" %}
🥈
{% elif achievements.curr_equatorprice_name == "Gold" %}
🥇
{% elif achievements.curr_equatorprice_name == "Diamant" %}
💍
{% endif %}
</span>
Äquatorpreis
<span><a href="http://www.rudern.at/OFFICE/Downloads/Ausschreibungen/2022/Wanderfahrten//Fahrtenabzeichen%20%C3%84quatorpreis%20und%20Danubius%202022.pdf"
target="_blank"
class="w-6 h-6 inline-flex align-center justify-center rounded-full bg-primary-500 ml-2 text-white">?</a></span>
</h3>
</summary>
<div class="mt-3">
{% if price.level == "DONE" %}
Gratuliere, du hast alles in deinem Rudererleben erreicht, was es (beim Äquatorpreis) zu erreichen gibt.
{% else %}
<label for="equatorprice" class="label">{{ price.desc }} ({{ price.rowed_km }} / {{ price.required_km }} km)</label>
<progress id="equatorprice"
class="w-full block my-3"
value="{{ price.rowed_km }}"
max="{{ price.required_km }}"></progress>
<details>
<summary>Details</summary>
Du bist insgesamt {{ price.rowed_km }} km gerudert. Um den Äquatorpreis in {{ price.desc }} zu erhalten, benötigst du noch {{ price.missing_km }} km um die notwendigen {{ price.required_km }} km zu erreichen.
</details>
{% endif %}
</div>
</details>
</div>
</div>
</div>
{% endif %}
{% if "Donau Linz" in loggedin_user.roles and "Unterstützend" not in loggedin_user.roles and "Förderndes Mitglied" not in loggedin_user.roles %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Vereinsmitglied</h2>
<ul class="list-none ms-2 divide-y divide-gray-200 dark:divide-primary-600">
{% if "Unterstützend" not in loggedin_user.roles and "Förderndes Mitglied" not in loggedin_user.roles %}
<li class="py-1">
<a href="/planned" class="block w-100 py-2 hover:text-primary-600">Geplante Ausfahrten</a>
</li>
<li class="py-1">
<a href="/log" class="block w-100 py-2 hover:text-primary-600">Ausfahrt eintragen</a>
</li>
<li class="py-1">
<a href="/log/show" class="block w-100 py-2 hover:text-primary-600">Logbuch</a>
</li>
<li class="py-1">
<a href="/stat" class="block w-100 py-2 hover:text-primary-600">Statistik</a>
</li>
<li class="py-1">
<a href="/stat/boats" class="block w-100 py-2 hover:text-primary-600">Bootsauswertung</a>
</li>
<li class="py-1">
<a href="/boatdamage" class="block w-100 py-2 hover:text-primary-600">Bootsschaden</a>
</li>
<li class="py-1">
<a href="/boatreservation"
class="block w-100 py-2 hover:text-primary-600">Bootsreservierung</a>
</li>
<li class="py-1">
<a href="/trailerreservation"
class="block w-100 py-2 hover:text-primary-600">Hängerreservierung</a>
</li>
<li class="py-1">
<a href="/steering" class="block w-100 py-2 hover:text-primary-600">Steuerleute & Co</a>
</li>
<div class="py-3">
<p>
<details>
<summary>
Kalender
</summary>
<p class="mt-3">
Du möchtest immer up-to-date mit den Events und Ausfahrten bleiben? Wir bieten 3 verschiedene Arten von Kalender an:
</p>
<ol class="list-decimal ml-5 my-3">
<li>
<strong>Alle Events und Ausfahrten</strong>, zu denen du dich angemeldet hast: <a class="underline break-all"
href="https://app.rudernlinz.at/cal/personal/{{ loggedin_user.id }}/{{ loggedin_user.user_token }}">https://app.rudernlinz.at/cal/personal/{{ loggedin_user.id }}/{{ loggedin_user.user_token }}</a>
<br />
<small>Dieser Link enthält einen zufällig generierten Teil, damit nur du (und jene, denen du diesen Link weitergibst) Zugang zu diesen Daten hast.</small>
</li>
<li>
<strong>Allgemeiner Kalender</strong>, zB save-the-dates (Wanderfahrten, ...): <a href="https://rudernlinz.at/cal" class="break-all underline">https://rudernlinz.at/cal</a>
</li>
<li>
<strong>Alle Events</strong>: <a class="break-all underline" href="https://app.rudernlinz.at/cal">https://app.rudernlinz.at/cal</a>
<br />
<small>Beachte, dass dieser Kalender keine Ausfahrten enthält, die von einzelnen Steuerpersonen augeschrieben werden. Dieser Kalender wird zB auf <a href="https://rudernlinz.at/termine" class="underline">https://rudernlinz.at/termine</a> verwendet und wir möchten keine persönlichen Daten (Namen etc.) leaken.</small>
</li>
</ol>
Du kannst die Kalender einfach in deinen Kalender als "externen Kalender" synchronisieren. Die genauen Schritte hängen von deiner verwendeten Software ab.
</details>
</p>
</div>
<div class="py-3">
<p>
<details>
<summary>
Signal-Gruppenchat Donau Linz
</summary>
<p class="mt-3">
Mit diesem Link kannst du unserer Signal Gruppe beitreten: <a class="break-all underline" href="https://signal.group/#CjQKICFrq6zSsRHxrucS3jEcQn6lknEXacAykwwLV3vNLKxPEhA17jxz7cpjfu3JZokLq1TH">https://signal.group/#CjQKICFrq6zSsRHxrucS3jEcQn6lknEXacAykwwLV3vNLKxPEhA17jxz7cpjfu3JZokLq1TH</a>
</p>
</details>
</p>
</div>
{% endif %}
<div class="py-3">
<p>
<details>
<summary>
WLAN-Passwort
</summary>
<p class="mt-3">
Das Passwort für unser "ASKÖ Ruderverein Donau Linz" WLAN ist <q>donau1921</q> (ohne Anführungszeichen). Bitte an keine vereinsfremden Personen weitergeben.
</p>
</details>
</p>
</div>
</ul>
</div>
{% endif %}
{% if "cox" in loggedin_user.roles or "Bootsführer" in loggedin_user.roles %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Steuerperson</h2>
<ul class="list-none ms-2 divide-y divide-gray-200 dark:divide-primary-600">
<div class="py-3">
<p>
<details>
<summary>
Signal-Gruppenchat Steuerpersonen Donau Linz
</summary>
<p class="mt-3">
Mit diesem Link kannst du unserer Signal Gruppe beitreten: <a class="underline" href="https://signal.group/#CjQKIHJInNb3zSVW7ipLo7_ygIqVxhxUaaNYx4sy2jdklLsIEhBHJNM2KZM1UnBdQxWy_Gdp">https://signal.group/#CjQKIHJInNb3zSVW7ipLo7_ygIqVxhxUaaNYx4sy2jdklLsIEhBHJNM2KZM1UnBdQxWy_Gdp</a>
</p>
</details>
</p>
</div>
</ul>
</div>
{% endif %}
{% if "scheckbuch" in loggedin_user.roles %}
<div class="grid gap-3 mb-4">
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Scheckbuch</h2>
{% if "paid" not in loggedin_user.roles %}
<div class="p-3 dark:text-white bg-white dark:bg-primary-900">
Bitte nimm zur nächsten Ausfahrt die {{ costs_scheckbuch / 100 }}&nbsp;€ für das Scheckbuch mit. Falls du das bereits gemacht hast, gibt uns bitte kurz Bescheid, dass dies noch nicht eingetragen wurde.
</div>
{% endif %}
">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">{% include "includes/plus-icon" %}</span>
Event
</a>
{% endif %}
{% if loggedin_user.allowed_to_steer or "ergo" in loggedin_user.roles %}
<a href="#" data-sidebar="true" data-trigger="sidebar" data-header="<strong>Ausfahrt</strong> am {{ day.day| date(format='%d.%m.%Y') }} erstellen" data-day="{{ day.day }}" data-body="#sidebarForm" class="relative inline-block w-full py-2 text-primary-900 hover:text-primary-950 dark:bg-primary-600 dark:text-white dark:hover:bg-primary-500 dark:hover:text-white focus:text-primary-950 text-sm font-semibold bg-gray-100 hover:bg-gray-200 focus:bg-gray-200
{% if "manage_events" in loggedin_user.roles %}
rounded-br-md
{% else %}
rounded-b-md
{% endif %}
">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">{% include "includes/plus-icon" %}</span>
{% if not loggedin_user.allowed_to_steer %}Ergo-Session
{%- else -%}
Ausfahrt{%endif%}
</a>
{% endif %}
<div class="text-sm p-3 bg-gray-200 bg-opacity-80 dark:bg-primary-950 dark:text-white text-primary-950">
<h3>Du hast bisher an {{ last_trips | length }} deiner 5 Scheckbuch-Ausfahrten teilgenommen.</h3>
{% if last_trips %}
<ol class="mt-3">
{% for last_trip in last_trips %}
<li>{{ log::show_old(log=last_trip, state="completed", only_ones=false, index=loop.index) }}</li>
{% endfor %}
</ol>
{% endif %}
</div>
</div>
</div>
{% endif %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Ergo</h2>
<div class="p-3">
<ul class="list-none ms-2">
<li class="py-1">
<a href="/ergo" class="block w-100 py-2 hover:text-primary-600">Ergo</a>
</li>
</ul>
</div>
</div>
{% endif %}
{# --- END Add Buttons --- #}
{% if "schnupper-betreuer" in loggedin_user.roles %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Schnupper-Betreuer</h2>
<ul class="list-none ms-2 divide-y divide-gray-200 dark:divide-primary-600">
<li class="py-1">
<a href="/admin/schnupper"
class="block w-100 py-2 hover:text-primary-600">Schnuppern</a>
</li>
</ul>
</div>
{% endif %}
{% if "Vorstand" in loggedin_user.roles %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Vorstand</h2>
<ul class="list-none ms-2 divide-y divide-gray-200 dark:divide-primary-600">
<li class="py-1">
<a href="/admin/user/fees"
class="block w-100 py-2 hover:text-primary-600">Übersicht User Gebühren</a>
</li>
<li class="py-1">
<a href="/admin/user/scheckbuch"
class="block w-100 py-2 hover:text-primary-600">Scheckbuch</a>
</li>
<li class="py-1">
<a href="/admin/user" class="block w-100 py-2 hover:text-primary-600">User</a>
</li>
<li class="py-1">
<a href="/board/boathouse"
class="block w-100 py-2 hover:text-primary-600">Bootshaus</a>
</li>
<li class="py-1">
<a href="/admin/mail" class="block w-100 py-2 hover:text-primary-600">Mail ausschicken</a>
</li>
<li class="py-1">
<a href="/admin/notification"
class="block w-100 py-2 hover:text-primary-600">Nachricht ausschreiben</a>
</li>
<li class="py-1">
<a href="/board/achievement"
class="block w-100 py-2 hover:text-primary-600">Abzeichen</a>
</li>
</ul>
</div>
{% endif %}
{% if "admin" in loggedin_user.roles %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Admin</h2>
<ul class="list-none ms-2 divide-y divide-gray-200 dark:divide-primary-600">
<li class="py-1">
<a href="/admin/boat" class="block w-100 py-2 hover:text-primary-600">Boote</a>
</li>
<li class="py-1">
<a href="/admin/user" class="block w-100 py-2 hover:text-primary-600">User</a>
</li>
<li class="py-1">
<a href="/admin/rss" class="block w-100 py-2 hover:text-primary-600">Logs</a>
</li>
<li class="py-1">
<a href="/admin/list" class="block w-100 py-2 hover:text-primary-600">Fingerabdruck-Liste überprüfen</a>
</li>
</ul>
</div>
{% endif %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Allgemein</h2>
<div class="p-3">
<ul class="list-none ms-2">
<li class="py-1">
<a href="https://wiki.rudernlinz.at/ruderassistent#faq"
target="_blank"
class="block w-100 py-2 hover:text-primary-600">FAQ (extern)</a>
</li>
</ul>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% if loggedin_user.allowed_to_steer or "ergo" in loggedin_user.roles %}
{% include "forms/trip" %}
{% endif %}
{% if "manage_events" in loggedin_user.roles %}
{% include "forms/event" %}
{% endif %}
{% endblock content %}

61
templates/kiosk.html.tera Normal file
View File

@ -0,0 +1,61 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/log" as log %}
{% extends "base" %}
{% block content %}
<div class="w-full">
<h1 class="h1">Logbuch</h1>
{% if flash and not loggedin_user %}
<div class="pt-3 max-w-lg m-auto">
{{ macros::alert(message=flash.1, type=flash.0, class="sm:col-span-2 lg:col-span-3") }}
</div>
{% endif %}
<div class="w-full grid lg:grid-cols-5 gap-3 mt-5">
<div>
<div class="bg-white dark:bg-primary-900 rounded-md hidden md:block shadow">
<h2 class="h2">Boote</h2>
<div>{{ log::show_boats(only_ones=false) }}</div>
</div>
<div class="bg-white dark:bg-primary-900 rounded-md hidden lg:block shadow mt-3">
<h2 class="h2">Schnellauswahl</h2>
<div>
{% for boat in boats | reverse %}
{% if boat.id in [42, 36] %}
<div class="p-3 boats-js text-black dark:text-white border-t {% if boat.damage != 'locked' and not boat.on_water %} cursor-pointer hover:text-primary-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-primary-950 {% endif %}"
{% if boat.damage != 'locked' and not boat.on_water %} data-seats="{{ boat.amount_seats }}" data-default_shipmaster_only_steering="{{ boat.default_shipmaster_only_steering }}" data-default-destination="{{ boat.default_destination }}" data-onclick="true" {% endif %}
data-id="{{ boat.id }}">
<span class="status-damage status-damage-{{ boat.damage }}"></span>
<span {% if boat.damage == 'locked' or boat.on_water %}class="opacity-50"{% endif %}>{{ boat.name }}
{% if boat.owner %}<span class="opacity-50">(privat)</span>{% endif %}
</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
<div class="lg:col-span-3">
<div class="bg-white dark:bg-primary-900 rounded-md shadow">
<h2 class="h2">Neue Ausfahrt</h2>
<div class="p-3">{{ log::new(only_ones=false, shipmaster=-1) }}</div>
</div>
</div>
<div>
<div class="bg-white dark:bg-primary-900 rounded-md shadow pb-2">
<h2 class="h2">Am Wasser</h2>
<div>
{% if on_water | length > 0 %}
{% for log in on_water %}
{{ log::show(log=log, state="on_water", allowed_to_close=true, only_ones=false) }}
{% endfor %}
{% else %}
<p class="p-3 text-center text-black dark:text-white">Kein Boot am Wasser</p>
{% endif %}
</div>
</div>
{{ macros::boatreservation() }}
{{ macros::plannedtrips() }}
</div>
</div>
</div>
<script src="/public/logbook.js"></script>
{% endblock content %}

View File

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

44
templates/log.html.tera Normal file
View File

@ -0,0 +1,44 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/log" as log %}
{% extends "base" %}
{% block content %}
<div class="w-full">
<h1 class="h1">Logbuch</h1>
<div class="w-full grid lg:grid-cols-5 gap-3 mt-5">
<div>
<div class="bg-white dark:bg-primary-900 rounded-md hidden lg:block shadow">
<h2 class="h2">Boote</h2>
<div>{{ log::show_boats(only_ones=false) }}</div>
</div>
</div>
<div class="lg:col-span-3">
<div class="bg-white dark:bg-primary-900 rounded-md shadow">
<h2 class="h2">Neue Ausfahrt</h2>
<div class="p-3">{{ log::new(shipmaster=loggedin_user.id) }}</div>
</div>
</div>
<div>
<div class="bg-white dark:bg-primary-900 rounded-md shadow pb-2">
<h2 class="h2">Am Wasser</h2>
{% if on_water | length > 0 %}
{% for log in on_water %}
{% if log.shipmaster == loggedin_user.id %}
{{ log::show(log=log, state="on_water", allowed_to_close=true, only_ones=not loggedin_user.allowed_to_steer) }}
{% elif "Vorstand" in loggedin_user.roles %}
{{ log::show(log=log, state="on_water", allowed_to_close=true, only_ones=not loggedin_user.allowed_to_steer) }}
{% else %}
{{ log::show(log=log, state="on_water", only_ones=true) }}
{% endif %}
{% endfor %}
{% else %}
<p class="p-3 text-center text-black dark:text-white">Kein Boot am Wasser</p>
{% endif %}
</div>
{{ macros::boatreservation() }}
{{ macros::plannedtrips() }}
</div>
</div>
</div>
{% if loggedin_user %}<script>var loggedin_user_id = "{{ loggedin_user.id }}";</script>{% endif %}
<script src="/public/logbook.js"></script>
{% endblock content %}

View File

@ -1,67 +0,0 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/log" as log %}
{% extends "base" %}
{% block content %}
<div id="notification"
class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5 mb-5"
role="alert">
<h2 class="h2">Nachrichten</h2>
{% if notifications %}
{% if loggedin_user.amount_unread_notifications > 10 %}
<div class="text-primary-950 dark:text-white bg-gray-200 dark:bg-primary-950 bg-opacity-80 text-center pb-3 px-3">
Du hast viele ungelesene Benachrichtigungen. Um deine Oberfläche übersichtlich zu halten und wichtige Updates nicht zu verpassen, nimm dir bitte einen Moment Zeit sie zu überprüfen und als gelesen zu markieren (&#10003;).
</div>
{% endif %}
<div class="divide-y">
{% for notification in notifications %}
{% if not notification.read_at %}
<div class="relative flex justify-between items-center p-3">
<div class="grow me-4">
<small class="uppercase text-gray-600 dark:text-gray-100">
<strong>{{ notification.category }}</strong> &bullet; {{ notification.created_at | date(format="%d.%m.%Y %H:%M",) }}
</small>
<div class="mt-1">{{ notification.message | safe }}</div>
</div>
<div>
{% if notification.link %}
<a href="{{ notification.link }}" class="inline-block">
<button class="btn btn-primary" type="button">🔗</button>
</a>
{% endif %}
{% if not notification.read_at %}
<a href="/notification/{{ notification.id }}/read" class="inline-block">
<button class="btn btn-primary" type="button">
&#10003;
<span class="sr-only">Notification gelesen</span>
</button>
</a>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
<details class="py-3 border-t rounded-b-md">
<summary class="px-3 cursor-pointer">Vergangene Nachrichten (14 Tage)</summary>
<div class="divide-y text-sm">
{% for notification in notifications %}
{% if notification.read_at %}
<div class="p-3 relative">
<small class="uppercase text-gray-600 dark:text-gray-100">
<strong>{{ notification.category }}</strong> &bullet; {{ notification.created_at | date(format="%d.%m.%Y %H:%M") }}
</small>
<div class="mt-1">{{ notification.message | safe }}</div>
{% if notification.link %}
<a href="{{ notification.link }}" class="inline-block">
<button class="btn btn-primary" type="button">🔗</button>
</a>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</details>
{% endif %}
</div>
{% endblock content %}

490
templates/planned.html.tera Normal file
View File

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

View File

@ -0,0 +1,42 @@
{% import "includes/macros" as macros %}
{% extends "base" %}
{% block content %}
<link rel="stylesheet" href="/public/table.css" />
<div class="max-w-screen-lg w-full">
<h1 class="h1">Bootsauswertung</h1>
<div class="text-black dark:text-white">
<table id="basic">
<thead>
<tr>
<th>Name</th>
<th>Art</th>
<th>Eigentümer</th>
<th>Ort</th>
{% for year in stat.pot_years | sort | reverse %}<th>{{ year }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for boat in stat.boats %}
<tr>
<td>{{ boat.name }}</td>
<td>{{ boat.cat }}</td>
<td>{{ boat.owner }}</td>
<td>{{ boat.location }}</td>
{% for year in stat.pot_years | sort | reverse %}
<td>
{% if year~'' in boat.years %}
{{ boat.years[year] }}
{% else %}
0
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script src="/public/jstable.min.js"></script>
<script src="/public/table.js"></script>
{% endblock content %}

View File

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

View File

@ -0,0 +1,98 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/log" as log %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">Hängerreservierungen</h1>
<h2 class="text-md font-bold tracking-wide bg-primary-900 mt-3 p-3 text-white flex justify-between items-center rounded-md">
Neue Reservierung
<a href="#"
class="inline-flex justify-center rounded-md bg-primary-600 mx-1 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 cursor-pointer"
data-sidebar="true"
data-trigger="sidebar"
data-header="Neue Reservierung anlegen"
data-body="#new-reservation">
{% include "includes/plus-icon" %}
<span class="sr-only">Neue Reservierung eintragen</span>
</a>
</h2>
<div class="hidden">
<div id="new-reservation">
<form action="/trailerreservation/new" method="post" class="grid gap-3">
{{ macros::select(label="Anhänger", data=trailers, name="trailer_id", id="trailer_id", display=["name"], wrapper_class="col-span-4", nonSelectableDefault=" -- Wähle einen Hänger aus ---", required=true) }}
{% if not loggedin_user %}{{ macros::select(label='Reserviert von', data=user, name='user_id_applicant') }}{% endif %}
{{ macros::input(label='Beginn', name='start_date', type='date', required=true, wrapper_class='col-span-4') }}
{{ macros::input(label='Ende', name='end_date', type='date', required=true, wrapper_class='col-span-4') }}
{{ macros::input(label='Uhrzeit (zB ab 14:00 Uhr, ganztägig, ...)', name='time_desc', type='text', required=true, wrapper_class='col-span-4') }}
{{ macros::input(label='Zweck (Wanderfahrt, ...)', name='usage', type='text', required=true, wrapper_class='col-span-4') }}
<input type="submit"
class="btn btn-primary w-full col-span-4"
value="Reservierung eintragen" />
</form>
</div>
</div>
<div class="search-wrapper">
<label for="name" class="sr-only">Suche</label>
<input type="search"
name="name"
id="filter-js"
class="search-bar"
placeholder="Suchen nach Namen...">
</div>
<div id="filter-result-js" class="search-result"></div>
{% for reservation in trailerreservations %}
{% set allowed_to_edit = false %}
{% if loggedin_user %}
{% if loggedin_user.id == reservation.user_applicant.id or "admin" in loggedin_user.roles %}
{% set allowed_to_edit = true %}
{% endif %}
{% endif %}
<div data-filterable="true"
data-filter="{{ reservation.user_applicant.name }} {{ reservation.trailer.name }}"
class="w-full border-t bg-white dark:bg-primary-900 text-black dark:text-white p-3">
<div class="w-full">
<strong>Hänger:</strong>
{{ reservation.trailer.name }}
<br />
<strong>Reservierung:</strong>
{{ reservation.user_applicant.name }}
<br />
<strong>Datum:</strong>
{{ reservation.start_date }}
{% if reservation.end_date != reservation.start_date %}
-
{{ reservation.end_date }}
{% endif %}
<br />
{% if not allowed_to_edit %}
<strong>Uhrzeit:</strong>
{{ reservation.time_desc }}
<br />
<strong>Zweck:</strong>
{{ reservation.usage }}
{% endif %}
{% if allowed_to_edit %}
<form action="/trailerreservation"
method="post"
class="bg-white dark:bg-primary-900 pt-3 rounded-md w-full">
<div class="w-full grid gap-3">
<input type="hidden" name="id" value="{{ reservation.id }}" />
{{ macros::input(label='Uhrzeit', name='time_desc', id=loop.index, type="text", value=reservation.time_desc, readonly=false) }}
{{ macros::input(label='Zweck', name='usage', id=loop.index, type="text", value=reservation.usage, readonly=false) }}
</div>
<div class="mt-3 text-right">
<a href="/trailerreservation/{{ reservation.id }}/delete"
class="w-28 btn btn-alert"
onclick="return confirm('Willst du diese Reservierung wirklich löschen?');">
{% include "includes/delete-icon" %}
Löschen
</a>
<input value="Ändern" type="submit" class="w-28 btn btn-primary ml-1" />
</div>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock content %}