staging #169

Merged
philipp merged 83 commits from staging into main 2024-01-19 07:43:06 +01:00
35 changed files with 1501 additions and 665 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
target/
db.sqlite
.history/
frontend/node_modules/*
/static/
/data-ergo/

View File

@ -11,59 +11,62 @@ env:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: rust:latest container: git.hofer.link/ruderverein-donau-linz/rowing-ci:20240118
steps: steps:
- name: Setup Environment - uses: actions/checkout@v3
run: |
apt-get update -qq && apt-get install -y -qq sshpass musl musl-tools sqlite3 curl gnupg && mkdir -p /etc/apt/keyrings | curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_16.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && apt-get update && apt-get install nodejs -y && apt-get install npm -y
- name: Checkout
uses: actions/checkout@v3
- name: Run Test DB Script - name: Run Test DB Script
run: ./test_db.sh run: ./test_db.sh
- name: Set up cargo cache
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-debug-
- name: Build - name: Build
run: | run: |
cargo build cargo build
cd frontend && npm install && npm run build cd frontend && npm install && npm run build
- name: Frontend tests
- name: Run Tests run: cd frontend && npx playwright test --workers 1
- name: Backend tests
run: cargo test --verbose run: cargo test --verbose
#- uses: actions/upload-artifact@v3
# if: always()
# with:
# name: playwright-report
# path: frontend/playwright-report/
# retention-days: 30
deploy-staging: deploy-staging:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: rust:latest container: git.hofer.link/ruderverein-donau-linz/rowing-ci:20240118
needs: [test] needs: [test]
if: github.ref == 'refs/heads/staging' if: github.ref == 'refs/heads/staging'
steps: steps:
- name: Setup Environment
run: |
rustup target add $CARGO_TARGET
apt-get update -qq && apt-get install -y -qq pkg-config sshpass musl musl-tools sqlite3 curl gnupg libssl-dev
# Handling NodeSource GPG key
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o nodesource.gpg.key
if [ -f /etc/apt/keyrings/nodesource.gpg ]; then
rm /etc/apt/keyrings/nodesource.gpg
fi
gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg nodesource.gpg.key
# Adding NodeSource repository
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_16.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
# Installing Node.js and npm
apt-get update
apt-get install nodejs -y
apt-get install npm -y
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Run Test DB Script - name: Run Test DB Script
run: ./test_db.sh run: ./test_db.sh
- name: Set up cargo cache
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-release-
- name: Build - name: Build
run: | run: |
cargo build --release --target $CARGO_TARGET cargo build --release --target $CARGO_TARGET
@ -72,7 +75,7 @@ jobs:
- name: Deploy to Staging - name: Deploy to Staging
run: | run: |
mkdir ~/.ssh mkdir -p ~/.ssh
ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa
@ -94,30 +97,37 @@ jobs:
deploy-main: deploy-main:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: rust:latest container: git.hofer.link/ruderverein-donau-linz/rowing-ci:20240118
needs: [test] needs: [test]
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
steps: steps:
- name: Setup Environment
run: |
rustup target add $CARGO_TARGET
apt-get update -qq && apt-get install -y -qq pkg-config sshpass musl musl-tools sqlite3 curl gnupg libssl-dev && mkdir -p /etc/apt/keyrings | curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_16.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && apt-get update && apt-get install nodejs -y && apt-get install npm -y
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Run Test DB Script - name: Run Test DB Script
run: ./test_db.sh run: ./test_db.sh
- name: Set up cargo cache
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-release-
- name: Build - name: Build
run: | run: |
cargo build --release --target $CARGO_TARGET cargo build --release --target $CARGO_TARGET
strip target/$CARGO_TARGET/release/rot strip target/$CARGO_TARGET/release/rot
cd frontend && npm install && npm run build cd frontend && npm install && npm run build
- name: Deploy to Main - name: Deploy to production
run: | run: |
mkdir ~/.ssh mkdir -p ~/.ssh
ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts ssh-keyscan -H $SSH_HOST >> ~/.ssh/known_hosts
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
# This dockerfile is used as basis for the CI jobs.
# Process to renew it:
# 0. Login to gitea docker registry: `docker login git.hofer.link`
# 1. Build the image `docker build .`
# 2. Tag the image: `docker tag <id> git.hofer.link/ruderverein-donau-linz/rowing-ci:<date>`
# 3. Push the image: `docker push git.hofer.link/ruderverein-donau-linz/rowing-ci:<date>`
FROM rust:1.75.0
RUN apt-get update && apt-get install -y sqlite3
# nodejs
RUN apt-get install -y curl && \
curl -sL https://deb.nodesource.com/setup_21.x | bash - && \
apt-get install -y nodejs
# playwright
RUN npx playwright install --with-deps
# deployment
RUN rustup target add x86_64-unknown-linux-musl
RUN apt-get install -y -qq pkg-config sshpass musl musl-tools curl gnupg libssl-dev
# TEMPORARY act workaround (otherwise gitea cache is not working)
RUN apt-get install -y zstd

View File

@ -1,15 +1,18 @@
# Frontend Process # Build
´cd frontend´
´npm install´
´npm run (watch/build)´
# Notes / Bugfixes
## Frontend ## Frontend
- [] support esc to close sidebar 1. `cd frontend`
- [] reload page -> don't throw input away! 2. `npm install`
3. `npm run (watch/build)`
# Run
## Backend ## Backend
1. `cargo r`
# Nice to have # Test
## Frontend ## Frontend
- [] my trips for cox - `npx playwright test --workers 1 --project firefox`
- Nice UI: `--ui`
- Generate tests: `npx playwright codegen`
## Backend (Unit + Integration)
`cargo t`

5
frontend/.gitignore vendored
View File

@ -1 +1,6 @@
package-lock.json package-lock.json
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@ -9,7 +9,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.40.1",
"@types/d3": "^7.4.1", "@types/d3": "^7.4.1",
"@types/node": "^20.11.4",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"sass": "^1.60.0", "sass": "^1.60.0",

View File

@ -0,0 +1,75 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
//{
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
//},
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
//{
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
//},
/* Test against branded browsers. */
//{
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
//},
//{
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
//},
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'cd .. && ./test_db.sh && cargo r',
},
});

120
frontend/tests/cox.spec.ts Normal file
View File

@ -0,0 +1,120 @@
import { test, expect, Page } from '@playwright/test';
test('cox can create and delete trip', async ({ page }) => {
await page.goto('http://localhost:8000/auth');
await page.getByPlaceholder('Name').click();
await page.getByPlaceholder('Name').fill('cox');
await page.getByPlaceholder('Name').press('Tab');
await page.getByPlaceholder('Passwort').fill('cox');
await page.getByPlaceholder('Passwort').press('Enter');
await page.getByRole('link', { name: 'Geplante Ausfahrten' }).click();
await page.locator('.relative').first().click();
await page.locator('#sidebar #planned_starting_time').click();
await page.locator('#sidebar #planned_starting_time').fill('18:00');
await page.locator('#sidebar #planned_starting_time').press('Tab');
await page.locator('#sidebar #planned_starting_time').press('Tab');
await page.getByRole('spinbutton').fill('5');
await page.getByRole('button', { name: 'Erstellen', exact: true }).click();
await page.getByRole('link', { name: 'Geplante Ausfahrten' }).click();
await expect(page.locator('body')).toContainText('18:00 Uhr (cox) Details');
await page.goto('http://localhost:8000/planned');
await page.getByRole('link', { name: 'Details' }).click();
await page.getByRole('link', { name: 'Termin löschen' }).click();
await expect(page.locator('body')).toContainText('Erfolgreich gelöscht!');
});
// TODO: group -> cox can create trips
// TODO: cox can help/register at trips/events
test.describe('cox can edit trips', () => {
let sharedPage: Page;
test.beforeEach(async ({ browser }) => {
const page = await browser.newPage();
await page.goto('http://localhost:8000/auth');
await page.getByPlaceholder('Name').click();
await page.getByPlaceholder('Name').fill('cox');
await page.getByPlaceholder('Name').press('Tab');
await page.getByPlaceholder('Passwort').fill('cox');
await page.getByPlaceholder('Passwort').press('Enter');
await page.getByRole('link', { name: 'Geplante Ausfahrten' }).click();
await page.locator('.relative').first().click();
await page.locator('#sidebar #planned_starting_time').click();
await page.locator('#sidebar #planned_starting_time').fill('18:00');
await page.locator('#sidebar #planned_starting_time').press('Tab');
await page.locator('#sidebar #planned_starting_time').press('Tab');
await page.getByRole('spinbutton').fill('5');
await page.getByRole('button', { name: 'Erstellen', exact: true }).click();
sharedPage = page;
});
test('edit remarks', async () => {
await sharedPage.goto('http://localhost:8000/planned');
await sharedPage.getByRole('link', { name: 'Details' }).click();
await sharedPage.locator('#sidebar #notes').click();
await sharedPage.locator('#sidebar #notes').fill('Meine Anmerkung');
await sharedPage.getByRole('button', { name: 'Speichern' }).click();
await sharedPage.getByRole('link', { name: 'Geplante Ausfahrten' }).click();
await sharedPage.getByRole('link', { name: 'Details' }).click();
await expect(sharedPage.locator('#sidebar')).toContainText('Meine Anmerkung');
await sharedPage.getByRole('button', { name: 'Ausfahrt erstellen schließen' }).click();
});
test('add and remove guest', async () => {
await sharedPage.goto('http://localhost:8000/planned');
await sharedPage.getByRole('link', { name: 'Details' }).click();
await sharedPage.locator('#sidebar #user_note').click();
await sharedPage.locator('#sidebar #user_note').fill('Mein Gast');
await sharedPage.getByRole('button', { name: 'Gast hinzufügen' }).click();
await expect(sharedPage.locator('body')).toContainText('Erfolgreich angemeldet!');
await sharedPage.getByRole('link', { name: 'Details' }).click();
await expect(sharedPage.locator('#sidebar')).toContainText('Freie Plätze: 4');
await expect(sharedPage.locator('#sidebar')).toContainText('Mein Gast (Gast) Abmelden');
await expect(sharedPage.getByRole('link', { name: 'Termin löschen' })).not.toBeVisible();
await sharedPage.getByRole('link', { name: 'Abmelden' }).click();
await expect(sharedPage.locator('body')).toContainText('Erfolgreich abgemeldet!');
await sharedPage.getByRole('link', { name: 'Details' }).click();
await expect(sharedPage.locator('#sidebar')).toContainText('Freie Plätze: 5');
await expect(sharedPage.locator('#sidebar')).toContainText('Keine Ruderer angemeldet');
await expect(sharedPage.getByRole('link', { name: 'Termin löschen' })).toBeVisible();
await sharedPage.getByRole('button', { name: 'Ausfahrt erstellen schließen' }).click();
});
test('change amount rower', async () => {
await sharedPage.goto('http://localhost:8000/planned');
await sharedPage.getByRole('link', { name: 'Details' }).click();
await expect(sharedPage.locator('#sidebar')).toContainText('Freie Plätze: 5');
await sharedPage.getByRole('spinbutton').click();
await sharedPage.getByRole('spinbutton').fill('3');
await sharedPage.getByRole('button', { name: 'Speichern' }).click();
await expect(sharedPage.locator('body')).toContainText('Ausfahrt erfolgreich aktualisiert.');
await sharedPage.getByRole('link', { name: 'Geplante Ausfahrten' }).click();
});
test('call off trip', async () => {
await sharedPage.goto('http://localhost:8000/planned');
await sharedPage.getByRole('link', { name: 'Details' }).click();
await expect(sharedPage.locator('#sidebar')).toContainText('Freie Plätze: 5');
await sharedPage.getByRole('spinbutton').click();
await sharedPage.getByRole('spinbutton').fill('0');
await sharedPage.getByRole('button', { name: 'Speichern' }).click();
await expect(sharedPage.locator('body')).toContainText('Ausfahrt erfolgreich aktualisiert.');
await sharedPage.getByRole('link', { name: 'Geplante Ausfahrten' }).click();
await expect(sharedPage.locator('body')).toContainText('(Absage cox )');
});
test.afterEach(async () => {
await sharedPage.goto('http://localhost:8000/planned');
await sharedPage.getByRole('link', { name: 'Details' }).click();
await sharedPage.getByRole('link', { name: 'Termin löschen' }).click();
await sharedPage.close();
});
// TODO: 'Immer anzeigen' (also verify the functionality), 'Gesperrt' + type
});

View File

@ -15,7 +15,12 @@ CREATE TABLE IF NOT EXISTS "user" (
"nickname" text, "nickname" text,
"notes" text, "notes" text,
"phone" text, "phone" text,
"address" text "address" text,
"family_id" INTEGER REFERENCES family(id)
);
CREATE TABLE IF NOT EXISTS "family" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT
); );
CREATE TABLE IF NOT EXISTS "role" ( CREATE TABLE IF NOT EXISTS "role" (

73
notes.md Normal file
View File

@ -0,0 +1,73 @@
# Wordpress auth
Add the following code to `wp-content/themes/bravada/functions.php`:
```
function rot_auth( $user, $username, $password ){
// Make sure a username and password are present for us to work with
if($username == '' || $password == '') return;
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://app.rudernlinz.at/wikiauth');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, "name=$username&password=$password");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// Execute the cURL session and get the response
$response = curl_exec($ch);
// Check for cURL errors
if(curl_errno($ch)){
$user = new WP_Error( 'denied', __('Curl error: ' . curl_error($ch)) );
}
// Close the cURL session
curl_close($ch);
if (strpos($response, 'SUCC') !== false) {
$user = get_user_by('login', $username);
if (!$user) {
// User does not exist, create a new one
$userdata = array(
'user_email' => $username,
'user_login' => $username,
'first_name' => $username,
'last_name' => ''
);
$new_user_id = wp_insert_user($userdata);
if (!is_wp_error($new_user_id)) {
// Load the new user info
$user = new WP_User($new_user_id);
// Set role based on username
if ($username == 'Philipp Hofer' || $username == 'Marie Birner') {
$user->set_role('administrator');
} else {
$user->set_role('editor');
}
} else {
// Handle error in user creation
return $new_user_id;
}
} else {
}
} else {
$user = new WP_Error( 'denied', __("Falscher Benutzername/Passwort. Verwendest du deine Accountdaten vom Ruderassistenten?") );
}
return $user;
}
// Comment this line if you wish to fall back on WordPress authentication
// Useful for times when the external service is offline
remove_action('authenticate', 'wp_authenticate_username_password', 20);
add_filter( 'authenticate', 'rot_auth', 10, 3 );
```

10
package-lock.json generated
View File

@ -1,10 +0,0 @@
{
"name": "rot",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "rot"
}
}
}

View File

@ -2,18 +2,26 @@ INSERT INTO "role" (name) VALUES ('admin');
INSERT INTO "role" (name) VALUES ('cox'); INSERT INTO "role" (name) VALUES ('cox');
INSERT INTO "role" (name) VALUES ('scheckbuch'); INSERT INTO "role" (name) VALUES ('scheckbuch');
INSERT INTO "role" (name) VALUES ('tech'); INSERT INTO "role" (name) VALUES ('tech');
INSERT INTO "role" (name) VALUES ('Donau Linz');
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" (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,1);
INSERT INTO "user_role" (user_id, role_id) VALUES(1,2); 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" (name, pw) VALUES('rower', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$jWKzDmI0jqT2dqINFt6/1NjVF4Dx15n07PL1ZMBmFsY'); 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" (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_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" (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,2);
INSERT INTO "user" (name) VALUES('new'); 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" (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_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" (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 "trip_details" (planned_starting_time, max_people, day, notes) VALUES('10:00', 2, '1970-01-01', 'trip_details for a planned event'); INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('10:00', 2, '1970-01-01', 'trip_details for a planned event');
INSERT INTO "planned_event" (name, planned_amount_cox, trip_details_id) VALUES('test-planned-event', 2, 1); INSERT INTO "planned_event" (name, planned_amount_cox, trip_details_id) VALUES('test-planned-event', 2, 1);

View File

@ -1 +0,0 @@
2023-06-06: Phil Baillon um 19:10 für 18 Uhr Fahrt abgemeldet

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

@ -0,0 +1,83 @@
use serde::Serialize;
use sqlx::{sqlite::SqliteQueryResult, FromRow, SqlitePool};
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 new(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) -> i32 {
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 FROM user WHERE family_id = ?", self.id)
.fetch_all(db)
.await
.unwrap()
}
}

View File

@ -264,6 +264,10 @@ ORDER BY departure DESC
return Err(LogbookCreateError::BoatNotFound); return Err(LogbookCreateError::BoatNotFound);
}; };
if boat.amount_seats == 1 && log.rowers.is_empty() {
log.rowers = vec![created_by_user.id];
}
if boat.amount_seats == 1 { if boat.amount_seats == 1 {
log.shipmaster = Some(log.rowers[0]); log.shipmaster = Some(log.rowers[0]);
log.steering_person = Some(log.rowers[0]); log.steering_person = Some(log.rowers[0]);

View File

@ -9,6 +9,7 @@ use self::{
pub mod boat; pub mod boat;
pub mod boatdamage; pub mod boatdamage;
pub mod family;
pub mod location; pub mod location;
pub mod log; pub mod log;
pub mod logbook; pub mod logbook;

View File

@ -16,7 +16,7 @@ impl Rower {
sqlx::query_as!( sqlx::query_as!(
User, User,
" "
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
FROM user FROM user
WHERE id in (SELECT rower_id FROM rower WHERE logbook_id=?) WHERE id in (SELECT rower_id FROM rower WHERE logbook_id=?)
", ",

View File

@ -2,6 +2,7 @@ use std::ops::{Deref, DerefMut};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher}; use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use chrono::{Datelike, Local, NaiveDate}; use chrono::{Datelike, Local, NaiveDate};
use chrono_tz::Etc::UTC;
use log::info; use log::info;
use rocket::{ use rocket::{
async_trait, async_trait,
@ -13,9 +14,18 @@ use rocket::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
use super::{log::Log, tripdetails::TripDetails, Day}; use super::{family::Family, log::Log, tripdetails::TripDetails, Day};
use crate::tera::admin::user::UserEditForm; use crate::tera::admin::user::UserEditForm;
const RENNRUDERBEITRAG: i32 = 11000;
const BOAT_STORAGE: i32 = 4500;
const FAMILY_TWO: i32 = 30000;
const FAMILY_THREE_OR_MORE: i32 = 35000;
const STUDENT_OR_PUPIL: i32 = 8000;
const REGULAR: i32 = 22000;
const UNTERSTUETZEND: i32 = 2500;
const FOERDERND: i32 = 8500;
#[derive(FromRow, Debug, Serialize, Deserialize)] #[derive(FromRow, Debug, Serialize, Deserialize)]
pub struct User { pub struct User {
pub id: i64, pub id: i64,
@ -33,6 +43,7 @@ pub struct User {
pub notes: Option<String>, pub notes: Option<String>,
pub phone: Option<String>, pub phone: Option<String>,
pub address: Option<String>, pub address: Option<String>,
pub family_id: Option<i64>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -89,7 +100,109 @@ pub enum LoginError {
DeserializationError, DeserializationError,
} }
#[derive(Debug, Serialize)]
pub(crate) struct Fee {
pub(crate) sum_in_cents: i32,
pub(crate) parts: Vec<(String, i32)>,
pub(crate) name: String,
}
impl Fee {
pub fn new() -> Self {
Self {
sum_in_cents: 0,
name: "".into(),
parts: Vec::new(),
}
}
pub fn add(&mut self, desc: String, price_in_cents: i32) {
self.sum_in_cents += price_in_cents;
self.parts.push((desc, price_in_cents));
}
pub fn name(&mut self, name: String) {
self.name = name;
}
pub fn merge(&mut self, fee: Fee) {
for (desc, price_in_cents) in fee.parts {
self.add(desc, price_in_cents);
}
}
}
impl User { impl User {
pub async fn fee(&self, db: &SqlitePool) -> Option<Fee> {
if !self.has_role(db, "Donau Linz").await {
return None;
}
let mut fee = Fee::new();
if let Some(family) = Family::find_by_opt_id(db, self.family_id).await {
fee.name(format!("{} + Familie", self.name));
for member in family.members(db).await {
fee.merge(member.fee_without_families(db).await);
}
if family.amount_family_members(db).await > 2 {
fee.add("Familie 3+ Personen".into(), FAMILY_THREE_OR_MORE);
} else {
fee.add("Familie 2 Personen".into(), FAMILY_TWO);
}
} else {
fee.name(self.name.clone());
fee.merge(self.fee_without_families(db).await);
}
Some(fee)
}
async fn fee_without_families(&self, db: &SqlitePool) -> Fee {
let mut fee = Fee::new();
if !self.has_role(db, "Donau Linz").await {
return fee;
}
if self.has_role(db, "Rennrudern").await {
fee.add("Rennruderbeitrag".into(), RENNRUDERBEITRAG);
}
let amount_boats = self.amount_boats(db).await;
if amount_boats > 0 {
fee.add(
format!("{}x Bootsplatz", amount_boats),
amount_boats * BOAT_STORAGE,
);
}
if self.has_role(db, "Unterstützend").await {
fee.add("Unterstützendes Mitglied".into(), UNTERSTUETZEND);
} else if self.has_role(db, "Förderndes Mitglied").await {
fee.add("Förderndes Mitglied".into(), FOERDERND);
} else if Family::find_by_opt_id(db, self.family_id).await.is_none() {
if self.has_role(db, "Student").await || self.has_role(db, "Schüler").await {
fee.add("Schüler/Student".into(), STUDENT_OR_PUPIL);
} else {
fee.add("Mitgliedsbeitrag".into(), REGULAR);
}
}
fee
}
pub async fn amount_boats(&self, db: &SqlitePool) -> i32 {
sqlx::query!(
"SELECT COUNT(*) as count FROM boat WHERE owner = ?",
self.id
)
.fetch_one(db)
.await
.unwrap()
.count
}
pub async fn rowed_km(&self, db: &SqlitePool) -> i32 { pub async fn rowed_km(&self, db: &SqlitePool) -> i32 {
sqlx::query!( sqlx::query!(
"SELECT COALESCE(SUM(distance_in_km),0) as rowed_km "SELECT COALESCE(SUM(distance_in_km),0) as rowed_km
@ -161,7 +274,7 @@ impl User {
sqlx::query_as!( sqlx::query_as!(
Self, Self,
" "
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
FROM user FROM user
WHERE id like ? WHERE id like ?
", ",
@ -176,7 +289,7 @@ WHERE id like ?
sqlx::query_as!( sqlx::query_as!(
Self, Self,
" "
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
FROM user FROM user
WHERE id like ? WHERE id like ?
", ",
@ -191,7 +304,7 @@ WHERE id like ?
sqlx::query_as!( sqlx::query_as!(
Self, Self,
" "
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
FROM user FROM user
WHERE name like ? WHERE name like ?
", ",
@ -233,7 +346,7 @@ WHERE name like ?
sqlx::query_as!( sqlx::query_as!(
Self, Self,
" "
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
FROM user FROM user
WHERE deleted = 0 WHERE deleted = 0
ORDER BY last_access DESC ORDER BY last_access DESC
@ -248,7 +361,7 @@ ORDER BY last_access DESC
sqlx::query_as!( sqlx::query_as!(
Self, Self,
" "
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
FROM user FROM user
WHERE deleted = 0 AND dob != '' and weight != '' and sex != '' WHERE deleted = 0 AND dob != '' and weight != '' and sex != ''
ORDER BY name ORDER BY name
@ -263,7 +376,7 @@ ORDER BY name
sqlx::query_as!( sqlx::query_as!(
Self, Self,
" "
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id
FROM user 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 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 ORDER BY last_access DESC
@ -282,8 +395,14 @@ ORDER BY last_access DESC
} }
pub async fn update(&self, db: &SqlitePool, data: UserEditForm) { pub async fn update(&self, db: &SqlitePool, data: UserEditForm) {
let mut family_id = data.family_id;
if family_id.is_some_and(|x| x == -1) {
family_id = Some(Family::new(db).await)
}
sqlx::query!( sqlx::query!(
"UPDATE user SET dob = ?, weight = ?, sex = ?, member_since_date=?, birthdate=?, mail=?, nickname=?, notes=?, phone=?, address=? where id = ?", "UPDATE user SET dob = ?, weight = ?, sex = ?, member_since_date=?, birthdate=?, mail=?, nickname=?, notes=?, phone=?, address=?, family_id = ? where id = ?",
data.dob, data.dob,
data.weight, data.weight,
data.sex, data.sex,
@ -294,6 +413,7 @@ ORDER BY last_access DESC
data.notes, data.notes,
data.phone, data.phone,
data.address, data.address,
family_id,
self.id self.id
) )
.execute(db) .execute(db)
@ -440,23 +560,20 @@ impl<'r> FromRequest<'r> for User {
Ok(user_id) => { Ok(user_id) => {
let db = req.rocket().state::<SqlitePool>().unwrap(); let db = req.rocket().state::<SqlitePool>().unwrap();
let Some(user) = User::find_by_id(db, user_id).await else { let Some(user) = User::find_by_id(db, user_id).await else {
return Outcome::Error((Status::Unauthorized, LoginError::UserNotFound)); return Outcome::Error((Status::Forbidden, LoginError::UserNotFound));
}; };
if user.deleted { if user.deleted {
return Outcome::Error((Status::Unauthorized, LoginError::UserDeleted)); return Outcome::Error((Status::Forbidden, LoginError::UserDeleted));
} }
user.logged_in(db).await; user.logged_in(db).await;
let mut cookie = Cookie::new("loggedin_user", format!("{}", user.id)); let mut cookie = Cookie::new("loggedin_user", format!("{}", user.id));
cookie.set_expires(OffsetDateTime::now_utc() + Duration::weeks(12)); cookie.set_expires(OffsetDateTime::now_utc() + Duration::weeks(2));
req.cookies().add_private(cookie); req.cookies().add_private(cookie);
Outcome::Success(user) Outcome::Success(user)
} }
Err(_) => { Err(_) => Outcome::Error((Status::Unauthorized, LoginError::DeserializationError)),
println!("{:?}", user_id.value());
Outcome::Error((Status::Unauthorized, LoginError::DeserializationError))
}
}, },
None => Outcome::Error((Status::Unauthorized, LoginError::NotLoggedIn)), None => Outcome::Error((Status::Unauthorized, LoginError::NotLoggedIn)),
} }
@ -487,7 +604,7 @@ impl<'r> FromRequest<'r> for TechUser {
if user.has_role(db, "tech").await { if user.has_role(db, "tech").await {
Outcome::Success(TechUser { user }) Outcome::Success(TechUser { user })
} else { } else {
Outcome::Error((Status::Unauthorized, LoginError::NotACox)) Outcome::Error((Status::Forbidden, LoginError::NotACox))
} }
} }
Outcome::Error(f) => Outcome::Error(f), Outcome::Error(f) => Outcome::Error(f),
@ -530,7 +647,7 @@ impl<'r> FromRequest<'r> for CoxUser {
if user.has_role(db, "cox").await { if user.has_role(db, "cox").await {
Outcome::Success(CoxUser { user }) Outcome::Success(CoxUser { user })
} else { } else {
Outcome::Error((Status::Unauthorized, LoginError::NotACox)) Outcome::Error((Status::Forbidden, LoginError::NotACox))
} }
} }
Outcome::Error(f) => Outcome::Error(f), Outcome::Error(f) => Outcome::Error(f),
@ -555,7 +672,7 @@ impl<'r> FromRequest<'r> for AdminUser {
if user.has_role(db, "admin").await { if user.has_role(db, "admin").await {
Outcome::Success(AdminUser { user }) Outcome::Success(AdminUser { user })
} else { } else {
Outcome::Error((Status::Unauthorized, LoginError::NotACox)) Outcome::Error((Status::Forbidden, LoginError::NotACox))
} }
} }
Outcome::Error(f) => Outcome::Error(f), Outcome::Error(f) => Outcome::Error(f),
@ -565,22 +682,22 @@ impl<'r> FromRequest<'r> for AdminUser {
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct NonGuestUser { pub struct AllowedForPlannedTripsUser(pub(crate) User);
pub(crate) user: User,
}
#[async_trait] #[async_trait]
impl<'r> FromRequest<'r> for NonGuestUser { impl<'r> FromRequest<'r> for AllowedForPlannedTripsUser {
type Error = LoginError; type Error = LoginError;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> { async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let db = req.rocket().state::<SqlitePool>().unwrap(); let db = req.rocket().state::<SqlitePool>().unwrap();
match User::from_request(req).await { match User::from_request(req).await {
Outcome::Success(user) => { Outcome::Success(user) => {
if !user.has_role(db, "scheckbuch").await { if user.has_role(db, "Donau Linz").await {
Outcome::Success(NonGuestUser { user }) Outcome::Success(AllowedForPlannedTripsUser(user))
} else if user.has_role(db, "scheckbuch").await {
Outcome::Success(AllowedForPlannedTripsUser(user))
} else { } else {
Outcome::Error((Status::Unauthorized, LoginError::NotACox)) Outcome::Error((Status::Forbidden, LoginError::NotACox))
} }
} }
Outcome::Error(f) => Outcome::Error(f), Outcome::Error(f) => Outcome::Error(f),
@ -589,6 +706,88 @@ impl<'r> FromRequest<'r> for NonGuestUser {
} }
} }
impl Into<User> for AllowedForPlannedTripsUser {
fn into(self) -> User {
self.0
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DonauLinzUser(pub(crate) User);
impl Into<User> for DonauLinzUser {
fn into(self) -> User {
self.0
}
}
impl Deref for DonauLinzUser {
type Target = User;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[async_trait]
impl<'r> FromRequest<'r> for DonauLinzUser {
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 user.has_role(db, "Donau Linz").await
&& !user.has_role(db, "Unterstützend").await
&& !user.has_role(db, "Förderndes Mitglied").await
{
Outcome::Success(DonauLinzUser(user))
} else {
Outcome::Error((Status::Forbidden, LoginError::NotACox))
}
}
Outcome::Error(f) => Outcome::Error(f),
Outcome::Forward(f) => Outcome::Forward(f),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct VorstandUser(pub(crate) User);
impl Into<User> for VorstandUser {
fn into(self) -> User {
self.0
}
}
impl Deref for VorstandUser {
type Target = User;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[async_trait]
impl<'r> FromRequest<'r> for VorstandUser {
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 user.has_role(db, "Vorstand").await {
Outcome::Success(VorstandUser(user))
} else {
Outcome::Error((Status::Forbidden, LoginError::NotACox))
}
}
Outcome::Error(f) => Outcome::Error(f),
Outcome::Forward(f) => Outcome::Forward(f),
}
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use std::collections::HashMap; use std::collections::HashMap;
@ -674,6 +873,7 @@ mod test {
notes: None, notes: None,
phone: None, phone: None,
address: None, address: None,
family_id: None,
}, },
) )
.await; .await;

View File

@ -27,7 +27,7 @@ async fn login(login: Form<LoginForm<'_>>, db: &State<SqlitePool>) -> String {
pub fn config(rocket: Rocket<Build>) -> Rocket<Build> { pub fn config(rocket: Rocket<Build>) -> Rocket<Build> {
rocket rocket
.mount("/", FileServer::from("svelte/build").rank(0)) //.mount("/", FileServer::from("svelte/build").rank(0))
.mount("/api/login", routes![login]) .mount("/api/login", routes![login])
} }

View File

@ -50,9 +50,9 @@ async fn update(
) -> Flash<Redirect> { ) -> Flash<Redirect> {
let d = data.into_inner(); let d = data.into_inner();
if Mail::send(db, d, config.smtp_pw.clone()).await { if Mail::send(db, d, config.smtp_pw.clone()).await {
return Flash::success(Redirect::to("/admin/mail"), "Mail versendet"); Flash::success(Redirect::to("/admin/mail"), "Mail versendet")
} else { } else {
return Flash::error(Redirect::to("/admin/mail"), "Fehler"); Flash::error(Redirect::to("/admin/mail"), "Fehler")
} }
} }

View File

@ -1,10 +1,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::model::{ use crate::model::{
family::Family,
role::Role, role::Role,
user::{AdminUser, User, UserWithRoles}, user::{AdminUser, Fee, User, UserWithRoles, VorstandUser},
}; };
use futures::future::join_all; use futures::future::{self, join_all};
use rocket::{ use rocket::{
form::Form, form::Form,
get, post, get, post,
@ -30,6 +31,7 @@ async fn index(
let users: Vec<UserWithRoles> = join_all(user_futures).await; let users: Vec<UserWithRoles> = join_all(user_futures).await;
let roles = Role::all(db).await; let roles = Role::all(db).await;
let families = Family::all_with_members(db).await;
let mut context = Context::new(); let mut context = Context::new();
if let Some(msg) = flash { if let Some(msg) = flash {
@ -37,6 +39,7 @@ async fn index(
} }
context.insert("users", &users); context.insert("users", &users);
context.insert("roles", &roles); context.insert("roles", &roles);
context.insert("families", &families);
context.insert( context.insert(
"loggedin_user", "loggedin_user",
&UserWithRoles::from_user(admin.user, db).await, &UserWithRoles::from_user(admin.user, db).await,
@ -45,6 +48,35 @@ async fn index(
Template::render("admin/user/index", context.into_json()) Template::render("admin/user/index", context.into_json())
} }
#[get("/user/fees")]
async fn fees(
db: &State<SqlitePool>,
admin: VorstandUser,
flash: Option<FlashMessage<'_>>,
) -> Template {
let mut context = Context::new();
let users = User::all(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",
&UserWithRoles::from_user(admin.into(), db).await,
);
Template::render("admin/user/fees", context.into_json())
}
#[get("/user/<user>/reset-pw")] #[get("/user/<user>/reset-pw")]
async fn resetpw(db: &State<SqlitePool>, _admin: AdminUser, user: i32) -> Flash<Redirect> { async fn resetpw(db: &State<SqlitePool>, _admin: AdminUser, user: i32) -> Flash<Redirect> {
let user = User::find_by_id(db, user).await; let user = User::find_by_id(db, user).await;
@ -89,6 +121,7 @@ pub struct UserEditForm {
pub(crate) notes: Option<String>, pub(crate) notes: Option<String>,
pub(crate) phone: Option<String>, pub(crate) phone: Option<String>,
pub(crate) address: Option<String>, pub(crate) address: Option<String>,
pub(crate) family_id: Option<i64>,
} }
#[post("/user", data = "<data>")] #[post("/user", data = "<data>")]
@ -132,5 +165,5 @@ async fn create(
} }
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![index, resetpw, update, create, delete] routes![index, resetpw, update, create, delete, fees]
} }

View File

@ -13,7 +13,7 @@ use crate::{
model::{ model::{
boat::Boat, boat::Boat,
boatdamage::{BoatDamage, BoatDamageFixed, BoatDamageToAdd, BoatDamageVerified}, boatdamage::{BoatDamage, BoatDamageFixed, BoatDamageToAdd, BoatDamageVerified},
user::{CoxUser, NonGuestUser, TechUser, User, UserWithRoles}, user::{CoxUser, DonauLinzUser, TechUser, User, UserWithRoles},
}, },
tera::log::KioskCookie, tera::log::KioskCookie,
}; };
@ -45,7 +45,7 @@ async fn index_kiosk(
async fn index( async fn index(
db: &State<SqlitePool>, db: &State<SqlitePool>,
flash: Option<FlashMessage<'_>>, flash: Option<FlashMessage<'_>>,
user: NonGuestUser, user: DonauLinzUser,
) -> Template { ) -> Template {
let boatdamages = BoatDamage::all(db).await; let boatdamages = BoatDamage::all(db).await;
let boats = Boat::all(db).await; let boats = Boat::all(db).await;
@ -59,7 +59,7 @@ async fn index(
context.insert("boats", &boats); context.insert("boats", &boats);
context.insert( context.insert(
"loggedin_user", "loggedin_user",
&UserWithRoles::from_user(user.user, db).await, &UserWithRoles::from_user(user.into(), db).await,
); );
Template::render("boatdamages", context.into_json()) Template::render("boatdamages", context.into_json())
@ -76,13 +76,14 @@ pub struct FormBoatDamageToAdd<'r> {
async fn create<'r>( async fn create<'r>(
db: &State<SqlitePool>, db: &State<SqlitePool>,
data: Form<FormBoatDamageToAdd<'r>>, data: Form<FormBoatDamageToAdd<'r>>,
user: NonGuestUser, user: DonauLinzUser,
) -> Flash<Redirect> { ) -> Flash<Redirect> {
let user: User = user.into();
let boatdamage_to_add = BoatDamageToAdd { let boatdamage_to_add = BoatDamageToAdd {
boat_id: data.boat_id, boat_id: data.boat_id,
desc: data.desc, desc: data.desc,
lock_boat: data.lock_boat, lock_boat: data.lock_boat,
user_id_created: user.user.id as i32, user_id_created: user.id as i32,
}; };
match BoatDamage::create(db, boatdamage_to_add).await { match BoatDamage::create(db, boatdamage_to_add).await {
Ok(_) => Flash::success( Ok(_) => Flash::success(

View File

@ -391,7 +391,7 @@ mod test {
.body("name=cox&password=cox"); // Add the form data to the request body; .body("name=cox&password=cox"); // Add the form data to the request body;
login.dispatch().await; login.dispatch().await;
let req = client.get("/join/1"); let req = client.get("/planned/join/1");
let _ = req.dispatch().await; let _ = req.dispatch().await;
let req = client.get("/cox/join/1"); let req = client.get("/cox/join/1");

View File

@ -23,7 +23,7 @@ use crate::model::{
LogbookUpdateError, LogbookUpdateError,
}, },
logtype::LogType, logtype::LogType,
user::{NonGuestUser, User, UserWithRoles, UserWithWaterStatus}, user::{DonauLinzUser, User, UserWithRoles, UserWithWaterStatus},
}; };
pub struct KioskCookie(String); pub struct KioskCookie(String);
@ -44,9 +44,9 @@ impl<'r> FromRequest<'r> for KioskCookie {
async fn index( async fn index(
db: &State<SqlitePool>, db: &State<SqlitePool>,
flash: Option<FlashMessage<'_>>, flash: Option<FlashMessage<'_>>,
user: NonGuestUser, user: DonauLinzUser,
) -> Template { ) -> Template {
let boats = Boat::for_user(db, &user.user).await; let boats = Boat::for_user(db, &user).await;
let coxes: Vec<UserWithWaterStatus> = futures::future::join_all( let coxes: Vec<UserWithWaterStatus> = futures::future::join_all(
User::cox(db) User::cox(db)
@ -78,7 +78,7 @@ async fn index(
context.insert("logtypes", &logtypes); context.insert("logtypes", &logtypes);
context.insert( context.insert(
"loggedin_user", "loggedin_user",
&UserWithRoles::from_user(user.user, db).await, &UserWithRoles::from_user(user.into(), db).await,
); );
context.insert("on_water", &on_water); context.insert("on_water", &on_water);
context.insert("distances", &distances); context.insert("distances", &distances);
@ -87,12 +87,12 @@ async fn index(
} }
#[get("/show", rank = 2)] #[get("/show", rank = 2)]
async fn show(db: &State<SqlitePool>, user: NonGuestUser) -> Template { async fn show(db: &State<SqlitePool>, user: DonauLinzUser) -> Template {
let logs = Logbook::completed(db).await; let logs = Logbook::completed(db).await;
Template::render( Template::render(
"log.completed", "log.completed",
context!(logs, loggedin_user: &UserWithRoles::from_user(user.user, db).await), context!(logs, loggedin_user: &UserWithRoles::from_user(user.into(), db).await),
) )
} }
@ -166,12 +166,12 @@ async fn kiosk(
async fn create_logbook( async fn create_logbook(
db: &SqlitePool, db: &SqlitePool,
data: Form<LogToAdd>, data: Form<LogToAdd>,
user: &NonGuestUser, user: &DonauLinzUser,
) -> Flash<Redirect> { ) -> Flash<Redirect> {
match Logbook::create( match Logbook::create(
db, db,
data.into_inner(), data.into_inner(),
&user.user &user
) )
.await .await
{ {
@ -197,14 +197,11 @@ async fn create_logbook(
async fn create( async fn create(
db: &State<SqlitePool>, db: &State<SqlitePool>,
data: Form<LogToAdd>, data: Form<LogToAdd>,
user: NonGuestUser, user: DonauLinzUser,
) -> Flash<Redirect> { ) -> Flash<Redirect> {
Log::create( Log::create(
db, db,
format!( format!("User {} tries to create log entry={:?}", &user.name, data),
"User {} tries to create log entry={:?}",
user.user.name, data
),
) )
.await; .await;
@ -238,14 +235,14 @@ async fn create_kiosk(
) )
.await; .await;
create_logbook(db, data, &NonGuestUser { user: creator }).await //TODO: fixme create_logbook(db, data, &DonauLinzUser(creator)).await //TODO: fixme
} }
async fn home_logbook( async fn home_logbook(
db: &SqlitePool, db: &SqlitePool,
data: Form<LogToFinalize>, data: Form<LogToFinalize>,
logbook_id: i32, logbook_id: i32,
user: &NonGuestUser, user: &DonauLinzUser,
) -> Flash<Redirect> { ) -> Flash<Redirect> {
let logbook: Option<Logbook> = Logbook::find_by_id(db, logbook_id).await; let logbook: Option<Logbook> = Logbook::find_by_id(db, logbook_id).await;
let Some(logbook) = logbook else { let Some(logbook) = logbook else {
@ -255,7 +252,7 @@ async fn home_logbook(
); );
}; };
match logbook.home(db, &user.user, data.into_inner()).await { match logbook.home(db,user, data.into_inner()).await {
Ok(_) => Flash::success(Redirect::to("/log"), "Ausfahrt korrekt eingetragen"), Ok(_) => Flash::success(Redirect::to("/log"), "Ausfahrt korrekt eingetragen"),
Err(LogbookUpdateError::TooManyRowers(expected, actual)) => Flash::error(Redirect::to("/log"), format!("Zu viele Ruderer (Boot fasst maximal {expected}, es wurden jedoch {actual} Ruderer ausgewählt)")), Err(LogbookUpdateError::TooManyRowers(expected, actual)) => Flash::error(Redirect::to("/log"), format!("Zu viele Ruderer (Boot fasst maximal {expected}, es wurden jedoch {actual} Ruderer ausgewählt)")),
Err(LogbookUpdateError::OnlyAllowedToEndTripsEndingToday) => Flash::error(Redirect::to("/log"), "Nur Ausfahrten, die heute enden dürfen eingetragen werden. Für einen Nachtrag schreibe alle Daten Philipp (Tel. nr. siehe Signal oder it@rudernlinz.at)."), Err(LogbookUpdateError::OnlyAllowedToEndTripsEndingToday) => Flash::error(Redirect::to("/log"), "Nur Ausfahrten, die heute enden dürfen eingetragen werden. Für einen Nachtrag schreibe alle Daten Philipp (Tel. nr. siehe Signal oder it@rudernlinz.at)."),
@ -285,11 +282,11 @@ async fn home_kiosk(
db, db,
data, data,
logbook_id, logbook_id,
&NonGuestUser { &DonauLinzUser(
user: User::find_by_id(db, logbook.shipmaster as i32) User::find_by_id(db, logbook.shipmaster as i32)
.await .await
.unwrap(), //TODO: fixme .unwrap(),
}, ), //TODO: fixme
) )
.await .await
} }
@ -299,13 +296,13 @@ async fn home(
db: &State<SqlitePool>, db: &State<SqlitePool>,
data: Form<LogToFinalize>, data: Form<LogToFinalize>,
logbook_id: i32, logbook_id: i32,
user: NonGuestUser, user: DonauLinzUser,
) -> Flash<Redirect> { ) -> Flash<Redirect> {
Log::create( Log::create(
db, db,
format!( format!(
"User {} tries to finish log entry {logbook_id} {data:?}", "User {} tries to finish log entry {logbook_id} {data:?}",
user.user.name &user.name
), ),
) )
.await; .await;
@ -314,12 +311,12 @@ async fn home(
} }
#[get("/<logbook_id>/delete", rank = 2)] #[get("/<logbook_id>/delete", rank = 2)]
async fn delete(db: &State<SqlitePool>, logbook_id: i32, user: User) -> Flash<Redirect> { async fn delete(db: &State<SqlitePool>, logbook_id: i32, user: DonauLinzUser) -> Flash<Redirect> {
let logbook = Logbook::find_by_id(db, logbook_id).await; let logbook = Logbook::find_by_id(db, logbook_id).await;
if let Some(logbook) = logbook { if let Some(logbook) = logbook {
Log::create( Log::create(
db, db,
format!("User {} tries to delete log entry {logbook_id}", user.name), format!("User {} tries to delete log entry {logbook_id}", &user.name),
) )
.await; .await;
match logbook.delete(db, &user).await { match logbook.delete(db, &user).await {

View File

@ -8,17 +8,12 @@ use rocket::{
response::{Flash, Redirect}, response::{Flash, Redirect},
routes, Build, FromForm, Rocket, State, routes, Build, FromForm, Rocket, State,
}; };
use rocket_dyn_templates::{tera::Context, Template}; use rocket_dyn_templates::Template;
use serde::Deserialize; use serde::Deserialize;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tera::Context;
use crate::model::{ use crate::model::user::{User, UserWithRoles};
log::Log,
tripdetails::TripDetails,
triptype::TripType,
user::{User, UserWithRoles},
usertrip::{UserTrip, UserTripDeleteError, UserTripError},
};
pub(crate) mod admin; pub(crate) mod admin;
mod auth; mod auth;
@ -27,6 +22,7 @@ mod cox;
mod ergo; mod ergo;
mod log; mod log;
mod misc; mod misc;
mod planned;
mod stat; mod stat;
#[derive(FromForm, Debug)] #[derive(FromForm, Debug)]
@ -35,6 +31,16 @@ struct LoginForm<'r> {
password: &'r str, 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());
}
context.insert("loggedin_user", &UserWithRoles::from_user(user, db).await);
Template::render("index", context.into_json())
}
#[post("/", data = "<login>")] #[post("/", data = "<login>")]
async fn wikiauth(db: &State<SqlitePool>, login: Form<LoginForm<'_>>) -> String { async fn wikiauth(db: &State<SqlitePool>, login: Form<LoginForm<'_>>) -> String {
match User::login(db, login.name, login.password).await { match User::login(db, login.name, login.password).await {
@ -43,164 +49,16 @@ async fn wikiauth(db: &State<SqlitePool>, login: Form<LoginForm<'_>>) -> String
} }
} }
#[get("/")] #[catch(401)] //Unauthorized
async fn index(db: &State<SqlitePool>, user: User, flash: Option<FlashMessage<'_>>) -> Template {
let mut context = Context::new();
if user.has_role(db, "cox").await || user.has_role(db, "admin").await {
let triptypes = TripType::all(db).await;
context.insert("trip_types", &triptypes);
}
let days = user.get_days(db).await;
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("loggedin_user", &UserWithRoles::from_user(user, db).await);
context.insert("days", &days);
Template::render("index", context.into_json())
}
#[get("/join/<trip_details_id>?<user_note>")]
async fn join(
db: &State<SqlitePool>,
trip_details_id: i64,
user: User,
user_note: Option<String>,
) -> Flash<Redirect> {
let Some(trip_details) = TripDetails::find_by_id(db, trip_details_id).await else {
return Flash::error(Redirect::to("/"), "Trip_details do not exist.");
};
match UserTrip::create(db, &user, &trip_details, user_note).await {
Ok(_) => {
Log::create(
db,
format!(
"User {} registered for trip_details.id={}",
user.name, trip_details_id
),
)
.await;
Flash::success(Redirect::to("/"), "Erfolgreich angemeldet!")
}
Err(UserTripError::EventAlreadyFull) => {
Flash::error(Redirect::to("/"), "Event bereits ausgebucht!")
}
Err(UserTripError::AlreadyRegistered) => {
Flash::error(Redirect::to("/"), "Du nimmst bereits teil!")
}
Err(UserTripError::AlreadyRegisteredAsCox) => {
Flash::error(Redirect::to("/"), "Du hilfst bereits als Steuerperson aus!")
}
Err(UserTripError::CantRegisterAtOwnEvent) => Flash::error(
Redirect::to("/"),
"Du kannst bei einer selbst ausgeschriebenen Fahrt nicht mitrudern ;)",
),
Err(UserTripError::GuestNotAllowedForThisEvent) => Flash::error(
Redirect::to("/"),
"Bei dieser Ausfahrt können leider keine Gäste mitfahren.",
),
Err(UserTripError::NotAllowedToAddGuest) => Flash::error(
Redirect::to("/"),
"Du darfst keine Gäste hinzufügen.",
),
Err(UserTripError::DetailsLocked) => Flash::error(
Redirect::to("/"),
"Das Boot ist bereits eingeteilt. Bitte kontaktiere den Schiffsführer (Nummern siehe Signalgruppe) falls du dich doch abmelden willst.",
),
}
}
#[get("/remove/<trip_details_id>/<name>")]
async fn remove_guest(
db: &State<SqlitePool>,
trip_details_id: i64,
user: User,
name: String,
) -> Flash<Redirect> {
let Some(trip_details) = TripDetails::find_by_id(db, trip_details_id).await else {
return Flash::error(Redirect::to("/"), "TripDetailsId does not exist");
};
match UserTrip::delete(db, &user, &trip_details, Some(name)).await {
Ok(_) => {
Log::create(
db,
format!(
"User {} unregistered for trip_details.id={}",
user.name, trip_details_id
),
)
.await;
Flash::success(Redirect::to("/"), "Erfolgreich abgemeldet!")
}
Err(UserTripDeleteError::DetailsLocked) => {
Log::create(
db,
format!(
"User {} tried to unregister for locked trip_details.id={}",
user.name, trip_details_id
),
)
.await;
Flash::error(Redirect::to("/"), "Das Boot ist bereits eingeteilt. Bitte kontaktiere den Schiffsführer (Nummern siehe Signalgruppe) falls du dich doch abmelden willst.")
}
Err(UserTripDeleteError::GuestNotParticipating) => {
Flash::error(Redirect::to("/"), "Gast nicht angemeldet.")
}
Err(UserTripDeleteError::NotAllowedToDeleteGuest) => Flash::error(
Redirect::to("/"),
"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> {
let Some(trip_details) = TripDetails::find_by_id(db, trip_details_id).await else {
return Flash::error(Redirect::to("/"), "TripDetailsId does not exist");
};
match UserTrip::delete(db, &user, &trip_details, None).await {
Ok(_) => {
Log::create(
db,
format!(
"User {} unregistered for trip_details.id={}",
user.name, trip_details_id
),
)
.await;
Flash::success(Redirect::to("/"), "Erfolgreich abgemeldet!")
}
Err(UserTripDeleteError::DetailsLocked) => {
Log::create(
db,
format!(
"User {} tried to unregister for locked trip_details.id={}",
user.name, trip_details_id
),
)
.await;
Flash::error(Redirect::to("/"), "Das Boot ist bereits eingeteilt. Bitte kontaktiere den Schiffsführer (Nummern siehe Signalgruppe) falls du dich doch abmelden willst.")
}
Err(_) => {
panic!("Not possible to be here");
}
}
}
#[catch(401)] //unauthorized
fn unauthorized_error() -> Redirect { fn unauthorized_error() -> Redirect {
Redirect::to("/auth") Redirect::to("/auth")
} }
#[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.")
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct Config { pub struct Config {
@ -210,10 +68,11 @@ pub struct Config {
pub fn config(rocket: Rocket<Build>) -> Rocket<Build> { pub fn config(rocket: Rocket<Build>) -> Rocket<Build> {
rocket rocket
.mount("/", routes![index, join, remove, remove_guest]) .mount("/", routes![index])
.mount("/auth", auth::routes()) .mount("/auth", auth::routes())
.mount("/wikiauth", routes![wikiauth]) .mount("/wikiauth", routes![wikiauth])
.mount("/log", log::routes()) .mount("/log", log::routes())
.mount("/planned", planned::routes())
.mount("/ergo", ergo::routes()) .mount("/ergo", ergo::routes())
.mount("/stat", stat::routes()) .mount("/stat", stat::routes())
.mount("/boatdamage", boatdamage::routes()) .mount("/boatdamage", boatdamage::routes())
@ -221,7 +80,7 @@ pub fn config(rocket: Rocket<Build>) -> Rocket<Build> {
.mount("/admin", admin::routes()) .mount("/admin", admin::routes())
.mount("/", misc::routes()) .mount("/", misc::routes())
.mount("/public", FileServer::from("static/")) .mount("/public", FileServer::from("static/"))
.register("/", catchers![unauthorized_error]) .register("/", catchers![unauthorized_error, forbidden_error])
.attach(Template::fairing()) .attach(Template::fairing())
.attach(AdHoc::config::<Config>()) .attach(AdHoc::config::<Config>())
} }
@ -255,7 +114,11 @@ mod test {
assert_eq!(response.status(), Status::Ok); assert_eq!(response.status(), Status::Ok);
assert!(response.into_string().await.unwrap().contains("Ausfahrten")); assert!(response
.into_string()
.await
.unwrap()
.contains("Ruderassistent"));
} }
#[sqlx::test] #[sqlx::test]
@ -274,75 +137,6 @@ mod test {
assert_eq!(response.headers().get("Location").next(), Some("/auth")); assert_eq!(response.headers().get("Location").next(), Some("/auth"));
} }
#[sqlx::test]
fn test_join_and_remove() {
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=rower&password=rower"); // Add the form data to the request body;
login.dispatch().await;
let req = client.get("/join/1");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
let flash_cookie = response
.cookies()
.get("_flash")
.expect("Expected flash cookie");
assert_eq!(flash_cookie.value(), "7:successErfolgreich angemeldet!");
let req = client.get("/remove/1");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
let flash_cookie = response
.cookies()
.get("_flash")
.expect("Expected flash cookie");
assert_eq!(flash_cookie.value(), "7:successErfolgreich abgemeldet!");
}
#[sqlx::test]
fn test_join_invalid_event() {
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=rower&password=rower"); // Add the form data to the request body;
login.dispatch().await;
let req = client.get("/join/9999");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
let flash_cookie = response
.cookies()
.get("_flash")
.expect("Expected flash cookie");
assert_eq!(flash_cookie.value(), "5:errorTrip_details do not exist.");
}
#[sqlx::test] #[sqlx::test]
fn test_public() { fn test_public() {
let db = testdb!(); let db = testdb!();

270
src/tera/planned.rs Normal file
View File

@ -0,0 +1,270 @@
use rocket::{
get,
request::FlashMessage,
response::{Flash, Redirect},
routes, Route, State,
};
use rocket_dyn_templates::Template;
use sqlx::SqlitePool;
use tera::Context;
use crate::model::{
log::Log,
tripdetails::TripDetails,
triptype::TripType,
user::{AllowedForPlannedTripsUser, User, UserWithRoles},
usertrip::{UserTrip, UserTripDeleteError, UserTripError},
};
#[get("/")]
async fn index(
db: &State<SqlitePool>,
user: AllowedForPlannedTripsUser,
flash: Option<FlashMessage<'_>>,
) -> Template {
let user: User = user.into();
let mut context = Context::new();
if user.has_role(db, "cox").await || user.has_role(db, "admin").await {
let triptypes = TripType::all(db).await;
context.insert("trip_types", &triptypes);
}
let days = user.get_days(db).await;
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("loggedin_user", &UserWithRoles::from_user(user, db).await);
context.insert("days", &days);
Template::render("planned", context.into_json())
}
#[get("/join/<trip_details_id>?<user_note>")]
async fn join(
db: &State<SqlitePool>,
trip_details_id: i64,
user: AllowedForPlannedTripsUser,
user_note: Option<String>,
) -> Flash<Redirect> {
let user: User = user.into();
let Some(trip_details) = TripDetails::find_by_id(db, trip_details_id).await else {
return Flash::error(Redirect::to("/"), "Trip_details do not exist.");
};
match UserTrip::create(db, &user, &trip_details, user_note).await {
Ok(_) => {
Log::create(
db,
format!(
"User {} registered for trip_details.id={}",
user.name, trip_details_id
),
)
.await;
Flash::success(Redirect::to("/planned"), "Erfolgreich angemeldet!")
}
Err(UserTripError::EventAlreadyFull) => {
Flash::error(Redirect::to("/planned"), "Event bereits ausgebucht!")
}
Err(UserTripError::AlreadyRegistered) => {
Flash::error(Redirect::to("/planned"), "Du nimmst bereits teil!")
}
Err(UserTripError::AlreadyRegisteredAsCox) => {
Flash::error(Redirect::to("/planned"), "Du hilfst bereits als Steuerperson aus!")
}
Err(UserTripError::CantRegisterAtOwnEvent) => Flash::error(
Redirect::to("/planned"),
"Du kannst bei einer selbst ausgeschriebenen Fahrt nicht mitrudern ;)",
),
Err(UserTripError::GuestNotAllowedForThisEvent) => Flash::error(
Redirect::to("/planned"),
"Bei dieser Ausfahrt können leider keine Gäste mitfahren.",
),
Err(UserTripError::NotAllowedToAddGuest) => Flash::error(
Redirect::to("/planned"),
"Du darfst keine Gäste hinzufügen.",
),
Err(UserTripError::DetailsLocked) => Flash::error(
Redirect::to("/planned"),
"Das Boot ist bereits eingeteilt. Bitte kontaktiere den Schiffsführer (Nummern siehe Signalgruppe) falls du dich doch abmelden willst.",
),
}
}
#[get("/remove/<trip_details_id>/<name>")]
async fn remove_guest(
db: &State<SqlitePool>,
trip_details_id: i64,
user: AllowedForPlannedTripsUser,
name: String,
) -> Flash<Redirect> {
let user: User = user.into();
let Some(trip_details) = TripDetails::find_by_id(db, trip_details_id).await else {
return Flash::error(Redirect::to("/planned"), "TripDetailsId does not exist");
};
match UserTrip::delete(db, &user, &trip_details, Some(name)).await {
Ok(_) => {
Log::create(
db,
format!(
"User {} unregistered for trip_details.id={}",
user.name, trip_details_id
),
)
.await;
Flash::success(Redirect::to("/planned"), "Erfolgreich abgemeldet!")
}
Err(UserTripDeleteError::DetailsLocked) => {
Log::create(
db,
format!(
"User {} tried to unregister for locked trip_details.id={}",
user.name, trip_details_id
),
)
.await;
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::GuestNotParticipating) => {
Flash::error(Redirect::to("/planned"), "Gast nicht angemeldet.")
}
Err(UserTripDeleteError::NotAllowedToDeleteGuest) => Flash::error(
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: AllowedForPlannedTripsUser,
) -> Flash<Redirect> {
let user: User = user.into();
let Some(trip_details) = TripDetails::find_by_id(db, trip_details_id).await else {
return Flash::error(Redirect::to("/planned"), "TripDetailsId does not exist");
};
match UserTrip::delete(db, &user, &trip_details, None).await {
Ok(_) => {
Log::create(
db,
format!(
"User {} unregistered for trip_details.id={}",
user.name, trip_details_id
),
)
.await;
Flash::success(Redirect::to("/planned"), "Erfolgreich abgemeldet!")
}
Err(UserTripDeleteError::DetailsLocked) => {
Log::create(
db,
format!(
"User {} tried to unregister for locked trip_details.id={}",
user.name, trip_details_id
),
)
.await;
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(_) => {
panic!("Not possible to be here");
}
}
}
pub fn routes() -> Vec<Route> {
routes![index, join, remove, remove_guest]
}
#[cfg(test)]
mod test {
use rocket::{
http::{ContentType, Status},
local::asynchronous::Client,
};
use sqlx::SqlitePool;
use crate::testdb;
#[sqlx::test]
fn test_join_and_remove() {
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=rower&password=rower"); // Add the form data to the request body;
login.dispatch().await;
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("/planned"));
let flash_cookie = response
.cookies()
.get("_flash")
.expect("Expected flash cookie");
assert_eq!(flash_cookie.value(), "7:successErfolgreich angemeldet!");
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("/planned"));
let flash_cookie = response
.cookies()
.get("_flash")
.expect("Expected flash cookie");
assert_eq!(flash_cookie.value(), "7:successErfolgreich abgemeldet!");
}
#[sqlx::test]
fn test_join_invalid_event() {
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=rower&password=rower"); // Add the form data to the request body;
login.dispatch().await;
let req = client.get("/planned/join/9999");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
let flash_cookie = response
.cookies()
.get("_flash")
.expect("Expected flash cookie");
assert_eq!(flash_cookie.value(), "5:errorTrip_details do not exist.");
}
}

View File

@ -4,19 +4,19 @@ use sqlx::SqlitePool;
use crate::model::{ use crate::model::{
stat::{self, Stat}, stat::{self, Stat},
user::{NonGuestUser, UserWithRoles}, user::{DonauLinzUser, UserWithRoles},
}; };
use super::log::KioskCookie; use super::log::KioskCookie;
#[get("/boats?<year>", rank = 2)] #[get("/boats?<year>", rank = 2)]
async fn index_boat(db: &State<SqlitePool>, user: NonGuestUser, year: Option<i32>) -> Template { async fn index_boat(db: &State<SqlitePool>, user: DonauLinzUser, year: Option<i32>) -> Template {
let stat = Stat::boats(db, year).await; let stat = Stat::boats(db, year).await;
let kiosk = false; let kiosk = false;
Template::render( Template::render(
"stat.boats", "stat.boats",
context!(loggedin_user: &UserWithRoles::from_user(user.user, db).await, stat, kiosk), context!(loggedin_user: &UserWithRoles::from_user(user.into(), db).await, stat, kiosk),
) )
} }
@ -33,15 +33,15 @@ async fn index_boat_kiosk(
} }
#[get("/?<year>", rank = 2)] #[get("/?<year>", rank = 2)]
async fn index(db: &State<SqlitePool>, user: NonGuestUser, year: Option<i32>) -> Template { async fn index(db: &State<SqlitePool>, user: DonauLinzUser, year: Option<i32>) -> Template {
let stat = Stat::people(db, year).await; let stat = Stat::people(db, year).await;
let guest_km = Stat::guest(db, year).await; let guest_km = Stat::guest(db, year).await;
let personal = stat::get_personal(db, &user.user).await; let personal = stat::get_personal(db, &user).await;
let kiosk = false; let kiosk = false;
Template::render( Template::render(
"stat.people", "stat.people",
context!(loggedin_user: &UserWithRoles::from_user(user.user, db).await, stat, personal, kiosk, guest_km), context!(loggedin_user: &UserWithRoles::from_user(user.into(), db).await, stat, personal, kiosk, guest_km),
) )
} }

View File

@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS "family" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT
);
ALTER TABLE "user" ADD COLUMN "family_id" INTEGER REFERENCES family(id);

1
svelte/.gitignore vendored
View File

@ -1,6 +1,5 @@
.DS_Store .DS_Store
node_modules node_modules
/build
/.svelte-kit /.svelte-kit
/package /package
.env .env

View File

@ -0,0 +1,32 @@
{% import "includes/macros" as macros %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full">
<h1 class="h1">Ergo Challenges</h1>
{% if flash %}
{{ macros::alert(message=flash.1, type=flash.0, class="my-3") }}
{% endif %}
<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">Gebühren</h2>
<div class="text-sm p-3">
{% for fee in fees | sort(attribute="name") %}
<b>{{ fee.name }}: {{ fee.sum_in_cents / 100 }}€</b><br />
{% for p in fee.parts %}
{{ p.0 }} ({{ p.1 / 100 }}€) {% if not loop.last %} + {% endif %}
{% endfor %}
<hr />
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock content%}

View File

@ -72,6 +72,7 @@
{{ macros::input(label='Notizen', name='notes', id=loop.index, type="text", value=user.notes) }} {{ macros::input(label='Notizen', name='notes', id=loop.index, type="text", value=user.notes) }}
{{ macros::input(label='Telefon', name='phone', id=loop.index, type="text", value=user.phone) }} {{ macros::input(label='Telefon', name='phone', id=loop.index, type="text", value=user.phone) }}
{{ macros::input(label='Adresse', name='address', id=loop.index, type="text", value=user.address) }} {{ macros::input(label='Adresse', name='address', id=loop.index, type="text", value=user.address) }}
{{ 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') }}
</div> </div>
</div> </div>
<div class="mt-3 text-right"> <div class="mt-3 text-right">

View File

@ -4,7 +4,11 @@
> >
<div class="max-w-screen-xl w-full flex justify-between items-center"> <div class="max-w-screen-xl w-full flex justify-between items-center">
<div class="w-1/3 truncate"> <div class="w-1/3 truncate">
{% if "Donau Linz" in loggedin_user.roles %}
<a href="/planned">
{% else %}
<a href="/"> <a href="/">
{% endif %}
{{ loggedin_user.name }} {{ loggedin_user.name }}
</a> </a>
@ -48,7 +52,7 @@
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" 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-sidebar="true"
data-trigger="sidebar" data-trigger="sidebar"
data-header="Logbuch" data-header="Menü"
data-body="#mobile-menu" data-body="#mobile-menu"
> >
{% include "includes/book" %} {% include "includes/book" %}
@ -164,7 +168,7 @@
</label> </label>
{% endmacro checkbox %} {% endmacro checkbox %}
{% macro select(label, data, name='trip_type', default='', id='', selected_id='', display='', extras='', class='', wrapper_class='', required=false, show_seats=false) %} {% macro select(label, data, name='trip_type', default='', id='', selected_id='', display='', extras='', class='', wrapper_class='', required=false, show_seats=false, new_last_entry='') %}
<div class="{{wrapper_class}}"> <div class="{{wrapper_class}}">
<label for="{{ name }}" class="text-sm text-gray-600 dark:text-gray-100">{{ label }}</label> <label for="{{ name }}" class="text-sm text-gray-600 dark:text-gray-100">{{ label }}</label>
{% if display == '' %} {% if display == '' %}
@ -185,11 +189,13 @@
{%- endfor %} {%- endfor %}
</option> </option>
{% endfor %} {% endfor %}
{% if new_last_entry %}
<option value="-1">{{ new_last_entry }}</option>
{% endif %}
</select> </select>
</div> </div>
{% endmacro select %} {% endmacro select %}
{% macro alert(message, type, class='') %} {% macro alert(message, type, class='') %}
<div class="{{ class }} alert-{{ type }} text-white px-3 py-1 rounded-md text-center"> <div class="{{ class }} alert-{{ type }} text-white px-3 py-1 rounded-md text-center">
{{ message }} {{ message }}
@ -209,7 +215,7 @@
{% if rower.is_real_guest %} {% if rower.is_real_guest %}
<small class="text-gray-600 dark:text-gray-100">(Gast)</small> <small class="text-gray-600 dark:text-gray-100">(Gast)</small>
{% if allow_removing %} {% if allow_removing %}
<a href="/remove/{{ trip_details_id }}/{{ rower.name }}" class="btn btn-attention btn-fw">Abmelden</a> <a href="/planned/remove/{{ trip_details_id }}/{{ rower.name }}" class="btn btn-attention btn-fw">Abmelden</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
<span class="hidden">(angemeldet seit <span class="hidden">(angemeldet seit

View File

@ -3,292 +3,100 @@
{% extends "base" %} {% extends "base" %}
{% block content %} {% block content %}
<div class="max-w-screen-xl w-full grid sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="max-w-screen-lg w-full">
{% if flash %} {% if flash %}
{{ macros::alert(message=flash.1, type=flash.0, class="sm:col-span-2 lg:col-span-3") }} {{ macros::alert(message=flash.1, type=flash.0, class="sm:col-span-2 lg:col-span-3") }}
{% endif %} {% endif %}
<h1 class="h1">Ruderassistent</h1>
<h1 class="h1 sm:col-span-2 lg:col-span-3">Ausfahrten</h1>
{% include "includes/buttons" %} <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">
{% for day in days %} <h2 class="h2">Allgemein</h2>
{% set amount_trips = day.planned_events | length + day.trips | length %} <div class="text-sm p-3">
{% set_global day_cox_needed = false %} <ul class="list-disc ms-2">
{% if day.planned_events | length > 0 %} <li class="py-1"><a href="https://rudernlinz.at/termin" target="_blank" class="link-primary">FAQ (extern)</a></li>
{% for planned_event in day.planned_events %} </ul>
{% if planned_event.cox_needed %}
{% set_global day_cox_needed = true %}
{% endif %}
{% endfor %}
{% endif %}
<div 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") }}</small>
</h2>
{% if day.planned_events | length > 0 or day.trips | length > 0 %}
<div
class="grid grid-cols-1 gap-3 mb-3">
{# --- START Events --- #}
{% if day.planned_events | length > 0 %}
{% for planned_event in day.planned_events | sort(attribute="planned_starting_time") %}
{% set amount_cur_cox = planned_event.cox | length %}
{% set amount_cox_missing = planned_event.planned_amount_cox - amount_cur_cox %}
<div class="pt-2 px-3 border-t border-gray-200" style="order: {{ planned_event.planned_starting_time | replace(from=":", to="") }}">
<div class="flex justify-between items-center">
<div class="mr-1">
<strong class="text-primary-900 dark:text-white">
{{ planned_event.planned_starting_time }}
Uhr
</strong>
<small class="text-gray-600 dark:text-gray-100">({{ planned_event.name }}{% if planned_event.trip_type %} - {{ planned_event.trip_type.icon | safe }} {{ planned_event.trip_type.name }}{% endif %})</small><br/>
<a href="#" data-sidebar="true" data-trigger="sidebar" data-header="<strong>{{ planned_event.planned_starting_time }} Uhr</strong> ({{ planned_event.name }}){% if planned_event.trip_type %}<small class='block'>{{ planned_event.trip_type.desc }}</small>{% endif %}{% if planned_event.notes %}<small class='block'>{{ planned_event.notes }}</small>{% endif %}" data-body="#event{{ planned_event.trip_details_id }}" class="inline-block link-primary mr-3">
Details
</a>
</div> </div>
<div
class="text-right grid gap-2">
{# --- START Row Buttons --- #}
{% set_global cur_user_participates = false %}
{% for rower in planned_event.rower%}
{% if rower.name == loggedin_user.name %}
{% set_global cur_user_participates = true %}
{% endif %}
{% endfor %}
{% if cur_user_participates %}
<a href="/remove/{{ planned_event.trip_details_id }}" class="btn btn-attention btn-fw">Abmelden</a>
{% endif %}
{% if planned_event.max_people > planned_event.rower | length %}
{% if cur_user_participates == false %}
<a href="/join/{{ planned_event.trip_details_id }}" class="btn btn-primary btn-fw" {% if planned_event.trip_type %} onclick="return confirm('{{ planned_event.trip_type.question }}');" {% endif %}>Mitrudern</a>
{% endif %}
{% endif %}
{# --- END Row Buttons --- #}
{# --- START Cox Buttons --- #}
{% if "cox" in loggedin_user.roles %}
{% set_global cur_user_participates = false %}
{% for cox in planned_event.cox %}
{% if cox.name == loggedin_user.name %}
{% set_global cur_user_participates = true %}
{% endif %}
{% endfor %}
{% if cur_user_participates %}
<a href="/cox/remove/{{ planned_event.id }}" class="block btn btn-attention btn-fw">
{% include "includes/cox-icon" %}
Abmelden
</a>
{% else %}
<a href="/cox/join/{{ planned_event.id }}" class="block btn {% if amount_cox_missing > 0 %} btn-dark {% else %} btn-gray {% endif %} btn-fw" {% if planned_event.trip_type %} onclick="return confirm('{{ planned_event.trip_type.question }}');" {% endif %}>
{% include "includes/cox-icon" %}
Steuern
</a>
{% endif %}
{% endif %}
{# --- END Cox Buttons --- #}
</div> </div>
</div> </div>
{# --- START Sidebar Content --- #} {% if loggedin_user.weight and loggedin_user.sex and loggedin_user.dob %}
<div class="hidden"> <div class="grid gap-3">
<div <div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5" role="alert">
id="event{{ planned_event.trip_details_id }}"> <h2 class="h2">Ergo</h2>
{# --- START List Coxes --- #} <div class="text-sm p-3">
{% if planned_event.planned_amount_cox > 0 %} <ul class="list-disc ms-2">
{% if amount_cox_missing > 0 %} <li class="py-1"><a href="/ergo" class="link-primary">Ergo</a></li>
{{ macros::box(participants=planned_event.cox, empty_seats=planned_event.planned_amount_cox - amount_cur_cox, header='Noch benötigte Steuerleute:', text='Keine Steuerleute angemeldet') }} </ul>
{% else %} </div>
{{ macros::box(participants=planned_event.cox, empty_seats="", header='Genügend Steuerleute haben sich angemeldet :-)', text='Keine Steuerleute angemeldet') }} </div>
</div>
{% endif %} {% endif %}
{% endif %}
{# --- END List Coxes --- #}
{# --- START List Rowers --- #} {% if "Donau Linz" in loggedin_user.roles and "Unterstützend" not in loggedin_user.roles and "Förderndes Mitglied" not in loggedin_user.roles %}
{% if planned_event.max_people > 0 %} <div class="grid gap-3">
{% set amount_cur_rower = planned_event.rower | length %} <div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5" role="alert">
{{ macros::box(participants=planned_event.rower, empty_seats=planned_event.max_people - amount_cur_rower, bg='primary-100', color='black', trip_details_id=planned_event.trip_details_id, allow_removing="admin" in loggedin_user.roles) }} <h2 class="h2">Aktives Vereinsmitglied</h2>
{% endif %} <div class="text-sm p-3">
{# --- END List Rowers --- #} <ul class="list-disc ms-2">
<li class="py-1"><a href="/planned" class="link-primary">Geplante Ausfahrten</a></li>
{% if "admin" in loggedin_user.roles %} <li class="py-1"><a href="/log" class="link-primary">Ausfahrt eintragen</a></li>
<form action="/join/{{ planned_event.trip_details_id }}" method="get" /> <li class="py-1"><a href="/log/show" class="link-primary">Logbuch</a></li>
{{ macros::input(label='Gast', class="input rounded-t", name='user_note', type='text', required=true) }} <li class="py-1"><a href="/stat" class="link-primary">Statistik</a></li>
<input value="Gast hinzufügen" class="btn btn-primary w-full rounded-t-none-important" type="submit"/> <li class="py-1"><a href="/stat/boats" class="link-primary">Bootsauswertung</a></li>
</form> <li class="py-1"><a href="/boatdamage" class="link-primary">Bootsschaden</a></li>
</ul>
</div>
</div>
</div>
{% endif %} {% endif %}
{% if planned_event.allow_guests %} {% if "scheckbuch" in loggedin_user.roles %}
<div class="text-primary-900 bg-primary-50 text-center p-1 mb-4">Gäste willkommen!</div> <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">Scheckbuch</h2>
<div class="text-sm p-3">
<ul class="list-disc ms-2">
<li class="py-1"><a href="/planned" class="link-primary">Geplante Ausfahrten</a></li>
</ul>
</div>
</div>
</div>
{% endif %}
{% if "Vorstand" in loggedin_user.roles %}
<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">Vorstand</h2>
<div class="text-sm p-3">
<ul class="list-disc ms-2">
<li class="py-1"><a href="/admin/user/fees" class="link-primary">Übersicht User Gebühren</a></li>
</ul>
</div>
</div>
</div>
{% endif %} {% endif %}
{% if "admin" in loggedin_user.roles %} {% if "admin" in loggedin_user.roles %}
<div class="grid gap-3">
{# --- START Edit Form --- #} <div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5" role="alert">
<div class="bg-gray-100 dark:bg-primary-900 p-3 mt-4 rounded-md"> <h2 class="h2">Admin</h2>
<h3 class="text-primary-950 dark:text-white font-bold uppercase tracking-wide mb-2">Ausfahrt bearbeiten</h3> <div class="text-sm p-3">
<form action="/admin/planned-event" method="post" class="grid gap-3"> <ul class="list-disc ms-2">
<input type="hidden" name="_method" value="put"/> <li class="py-1"><a href="/admin/boat" class="link-primary">Boote</a></li>
<input type="hidden" name="id" value="{{ planned_event.id }}"/> <li class="py-1"><a href="/admin/user" class="link-primary">User</a></li>
{{ macros::input(label='Anzahl Ruderer', name='max_people', type='number', required=true, value=planned_event.max_people, min='0') }} <li class="py-1"><a href="/admin/mail" class="link-primary">Mail (beautifully layouted)</a></li>
{{ macros::input(label='Anzahl Steuerleute', name='planned_amount_cox', type='number', value=planned_event.planned_amount_cox, required=true, min='0') }} <li class="py-1"><a href="/admin/rss" class="link-primary">Logs</a></li>
{{ macros::checkbox(label='Immer anzeigen', name='always_show', id=planned_event.id,checked=planned_event.always_show) }} </ul>
{{ macros::checkbox(label='Gesperrt', name='is_locked', id=planned_event.id,checked=planned_event.is_locked) }}
{{ macros::input(label='Anmerkungen', name='notes', type='input', value=planned_event.notes) }}
<input value="Speichern" class="btn btn-primary" type="submit"/>
</form>
</div>
{# --- END Edit Form --- #}
{# --- START Delete Btn --- #}
<div class="text-right">
<a href="/admin/planned-event/{{ planned_event.id }}/delete" class="inline-block btn btn-alert">
{% include "includes/delete-icon" %}
Termin löschen
</a>
</div>
{% endif %}
{# --- END Delete Btn --- #}
</div> </div>
</div> </div>
{# --- END Sidebar Content --- #}
</div> </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.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 }}{{ 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 }} {{ 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') }}
{% 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 %} {% 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='0') }}
{{ macros::input(label='Anmerkungen', name='notes', type='input', value=trip.notes) }}
{{ macros::checkbox(label='Immer anzeigen', name='always_show', id=trip.id,checked=trip.always_show) }}
{{ macros::checkbox(label='Gesperrt', name='is_locked', id=trip.id,checked=trip.is_locked) }}
{{ macros::select(label='Typ', name='trip_type', data=trip_types, default='Reguläre Ausfahrt', selected_id=trip.trip_type_id) }}
<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>
{% endif %}
{% endif %}
{# --- END Edit Form --- #}
</div>
</div>
{# --- END Sidebar Content --- #}
</div>
{% endfor %}
{% endif %}
{# --- END Trips --- #}
</div>
{% endif %}
</div> </div>
{# --- START Add Buttons --- #}
{% if "admin" in loggedin_user.roles or "cox" in loggedin_user.roles %}
<div class="grid {% if "admin" in loggedin_user.roles %} grid-cols-2 {% endif %} text-center">
{% if "admin" 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 rounded-bl-md text-sm font-semibold">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
{% include "includes/plus-icon" %}
</span>
Event
</a>
{% endif %}
{% if "cox" in loggedin_user.roles %} {% endblock content%}
<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 "admin" 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>
Ausfahrt
</a>
{% endif %}
</div>
{% endif %}
{# --- END Add Buttons --- #}
</div>
{% endfor %}
</div>
</div>
{% if "cox" in loggedin_user.roles %}
{% include "forms/trip" %}
{% endif %}
{% if "admin" in loggedin_user.roles %}
{% include "forms/event" %}
{% endif %}{% endblock content %}

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

@ -0,0 +1,294 @@
{% import "includes/macros" as macros %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-xl w-full grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% if flash %}
{{ macros::alert(message=flash.1, type=flash.0, class="sm:col-span-2 lg:col-span-3") }}
{% 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.planned_events | length + day.trips | length %}
{% set_global day_cox_needed = false %}
{% if day.planned_events | length > 0 %}
{% for planned_event in day.planned_events %}
{% if planned_event.cox_needed %}
{% set_global day_cox_needed = true %}
{% endif %}
{% endfor %}
{% endif %}
<div 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") }}</small>
</h2>
{% if day.planned_events | length > 0 or day.trips | length > 0 %}
<div
class="grid grid-cols-1 gap-3 mb-3">
{# --- START Events --- #}
{% if day.planned_events | length > 0 %}
{% for planned_event in day.planned_events | sort(attribute="planned_starting_time") %}
{% set amount_cur_cox = planned_event.cox | length %}
{% set amount_cox_missing = planned_event.planned_amount_cox - amount_cur_cox %}
<div class="pt-2 px-3 border-t border-gray-200" style="order: {{ planned_event.planned_starting_time | replace(from=":", to="") }}">
<div class="flex justify-between items-center">
<div class="mr-1">
<strong class="text-primary-900 dark:text-white">
{{ planned_event.planned_starting_time }}
Uhr
</strong>
<small class="text-gray-600 dark:text-gray-100">({{ planned_event.name }}{% if planned_event.trip_type %} - {{ planned_event.trip_type.icon | safe }} {{ planned_event.trip_type.name }}{% endif %})</small><br/>
<a href="#" data-sidebar="true" data-trigger="sidebar" data-header="<strong>{{ planned_event.planned_starting_time }} Uhr</strong> ({{ planned_event.name }}){% if planned_event.trip_type %}<small class='block'>{{ planned_event.trip_type.desc }}</small>{% endif %}{% if planned_event.notes %}<small class='block'>{{ planned_event.notes }}</small>{% endif %}" data-body="#event{{ planned_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 planned_event.rower%}
{% if rower.name == loggedin_user.name %}
{% set_global cur_user_participates = true %}
{% endif %}
{% endfor %}
{% if cur_user_participates %}
<a href="/planned/remove/{{ planned_event.trip_details_id }}" class="btn btn-attention btn-fw">Abmelden</a>
{% endif %}
{% if planned_event.max_people > planned_event.rower | length %}
{% if cur_user_participates == false %}
<a href="/planned/join/{{ planned_event.trip_details_id }}" class="btn btn-primary btn-fw" {% if planned_event.trip_type %} onclick="return confirm('{{ planned_event.trip_type.question }}');" {% endif %}>Mitrudern</a>
{% endif %}
{% endif %}
{# --- END Row Buttons --- #}
{# --- START Cox Buttons --- #}
{% if "cox" in loggedin_user.roles %}
{% set_global cur_user_participates = false %}
{% for cox in planned_event.cox %}
{% if cox.name == loggedin_user.name %}
{% set_global cur_user_participates = true %}
{% endif %}
{% endfor %}
{% if cur_user_participates %}
<a href="/cox/remove/{{ planned_event.id }}" class="block btn btn-attention btn-fw">
{% include "includes/cox-icon" %}
Abmelden
</a>
{% else %}
<a href="/cox/join/{{ planned_event.id }}" class="block btn {% if amount_cox_missing > 0 %} btn-dark {% else %} btn-gray {% endif %} btn-fw" {% if planned_event.trip_type %} onclick="return confirm('{{ planned_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{{ planned_event.trip_details_id }}">
{# --- START List Coxes --- #}
{% if planned_event.planned_amount_cox > 0 %}
{% if amount_cox_missing > 0 %}
{{ macros::box(participants=planned_event.cox, empty_seats=planned_event.planned_amount_cox - amount_cur_cox, header='Noch benötigte Steuerleute:', text='Keine Steuerleute angemeldet') }}
{% else %}
{{ macros::box(participants=planned_event.cox, empty_seats="", header='Genügend Steuerleute haben sich angemeldet :-)', text='Keine Steuerleute angemeldet') }}
{% endif %}
{% endif %}
{# --- END List Coxes --- #}
{# --- START List Rowers --- #}
{% if planned_event.max_people > 0 %}
{% set amount_cur_rower = planned_event.rower | length %}
{{ macros::box(participants=planned_event.rower, empty_seats=planned_event.max_people - amount_cur_rower, bg='primary-100', color='black', trip_details_id=planned_event.trip_details_id, allow_removing="admin" in loggedin_user.roles) }}
{% endif %}
{# --- END List Rowers --- #}
{% if "admin" in loggedin_user.roles %}
<form action="/planned/join/{{ planned_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 planned_event.allow_guests %}
<div class="text-primary-900 bg-primary-50 text-center p-1 mb-4">Gäste willkommen!</div>
{% endif %}
{% if "admin" 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="{{ planned_event.id }}"/>
{{ macros::input(label='Anzahl Ruderer', name='max_people', type='number', required=true, value=planned_event.max_people, min='0') }}
{{ macros::input(label='Anzahl Steuerleute', name='planned_amount_cox', type='number', value=planned_event.planned_amount_cox, required=true, min='0') }}
{{ macros::checkbox(label='Immer anzeigen', name='always_show', id=planned_event.id,checked=planned_event.always_show) }}
{{ macros::checkbox(label='Gesperrt', name='is_locked', id=planned_event.id,checked=planned_event.is_locked) }}
{{ macros::input(label='Anmerkungen', name='notes', type='input', value=planned_event.notes) }}
<input value="Speichern" class="btn btn-primary" type="submit"/>
</form>
</div>
{# --- END Edit Form --- #}
{# --- START Delete Btn --- #}
<div class="text-right">
<a href="/admin/planned-event/{{ planned_event.id }}/delete" class="inline-block btn btn-alert">
{% include "includes/delete-icon" %}
Termin löschen
</a>
</div>
{% 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.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 }}{{ 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 }} {{ 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') }}
{% 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='0') }}
{{ macros::input(label='Anmerkungen', name='notes', type='input', value=trip.notes) }}
{{ macros::checkbox(label='Immer anzeigen', name='always_show', id=trip.id,checked=trip.always_show) }}
{{ macros::checkbox(label='Gesperrt', name='is_locked', id=trip.id,checked=trip.is_locked) }}
{{ macros::select(label='Typ', name='trip_type', data=trip_types, default='Reguläre Ausfahrt', selected_id=trip.trip_type_id) }}
<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>
{% endif %}
{% endif %}
{# --- END Edit Form --- #}
</div>
</div>
{# --- END Sidebar Content --- #}
</div>
{% endfor %}
{% endif %}
{# --- END Trips --- #}
</div>
{% endif %}
</div>
{# --- START Add Buttons --- #}
{% if "admin" in loggedin_user.roles or "cox" in loggedin_user.roles %}
<div class="grid {% if "admin" in loggedin_user.roles %} grid-cols-2 {% endif %} text-center">
{% if "admin" 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 rounded-bl-md text-sm font-semibold">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
{% include "includes/plus-icon" %}
</span>
Event
</a>
{% endif %}
{% if "cox" 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 "admin" 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>
Ausfahrt
</a>
{% endif %}
</div>
{% endif %}
{# --- END Add Buttons --- #}
</div>
{% endfor %}
</div>
</div>
{% if "cox" in loggedin_user.roles %}
{% include "forms/trip" %}
{% endif %}
{% if "admin" in loggedin_user.roles %}
{% include "forms/event" %}
{% endif %}{% endblock content %}

View File

@ -1,13 +0,0 @@
#!/bin/bash
git pull
cargo b -r
cd frontend
npm install
npm run build
cd ..
cd svelte
npm install
npm run build
sudo systemctl restart rot
sudo systemctl restart rot-svelte