243 Commits

Author SHA1 Message Date
Marie Birner
397092bff5 [NPM] update choices.js to 11.1.0 to set searchResultLimit -1 2025-07-20 11:46:59 +02:00
627a515a42 Merge pull request 'upda' (#1111) from upda into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1111
2025-07-20 11:33:56 +02:00
1c6421139d fördernde user can 1x row 2025-07-20 11:31:33 +02:00
f4509b8504 new server 2025-07-20 11:31:12 +02:00
b53b8b6f0b Merge pull request 'fix path' (#1108) from new-server into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1108
2025-07-19 13:24:12 +02:00
3f76e5be78 fix path 2025-07-19 13:23:14 +02:00
a14a76399e Merge pull request 'it's okay to not have this already' (#1106) from new-server into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1106
2025-07-19 10:54:02 +02:00
b15050cd63 it's okay to not have this already 2025-07-19 10:52:19 +02:00
2907ed5caf Merge pull request 'fix path' (#1104) from new-server into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1104
2025-07-19 07:04:41 +02:00
bc8cd88af4 fix path 2025-07-19 07:03:12 +02:00
539d299c1a Merge pull request 'move to new server' (#1102) from new-server into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1102
2025-07-18 18:58:14 +02:00
f0936c7784 move to new server 2025-07-18 18:57:09 +02:00
59478a5ee1 Merge pull request 'fix tests' (#1099) from fix-tetss into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1099
2025-07-17 21:49:20 +02:00
2f5d483bff fix tests 2025-07-17 21:48:31 +02:00
7be9339645 Merge pull request 'merge functionality of kiosk + logged in -> allow kiosk to have fördernde people as rower in logbook' (#1097) from kiosk-allow-foerdernde into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1097
2025-07-13 19:52:33 +02:00
837d0febdf merge functionality of kiosk + logged in -> allow kiosk to have fördernde people as rower in logbook 2025-07-13 19:49:46 +02:00
51c7cf28f8 Merge pull request 'foerderne user can do trips on the water' (#1094) from foerderne-user-can-do-trips into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1094
2025-07-13 09:26:57 +02:00
80eca1a3b2 foerderne user can do trips on the water 2025-07-13 09:22:47 +02:00
d1341006f7 Merge pull request 'allow to change from 'bootsfuehrer' to 'cox'' (#1090) from bootsman-to-cox into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1090
2025-07-03 11:03:50 +02:00
a534568a39 allow to change from 'bootsfuehrer' to 'cox' 2025-07-03 11:02:59 +02:00
b4c04cbdd8 Merge pull request 'updates-meeting' (#1085) from updates-meeting into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1085
2025-06-17 22:53:40 +02:00
1f0bfb04e4 more updates after meeting 2025-06-17 22:52:04 +02:00
86b8d3a30d make clear where note is displayed 2025-06-17 22:29:08 +02:00
da7a303efb Merge pull request 'fix(?) auto ci' (#1080) from fix-ci into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1080
2025-06-13 11:08:03 +02:00
0a31410ca5 fix(?) auto ci 2025-06-13 11:07:00 +02:00
f793cb4a9a Merge pull request 'log if a cox creates a trip' (#1076) from log-trip-creation into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1076
2025-06-09 08:36:49 +02:00
d3b2d78f9f log if a cox creates a trip 2025-06-09 08:33:50 +02:00
155adce2e9 Merge pull request 'add license' Fixes #1064' (#1071) from upd into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1071
2025-05-29 16:06:34 +02:00
9548cb4f0b add license' Fixes #1064 2025-05-29 16:05:44 +02:00
c42713b86e Merge pull request 'promote calendar in welcome-mail; even more robust name checking' (#1069) from upd into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1069
2025-05-29 16:02:59 +02:00
d5a92d8f79 promote calendar in welcome-mail; even more robust name checking 2025-05-29 16:01:22 +02:00
aa3df2a294 Merge pull request 'use proper role instead of manully validating role' (#1066) from use-proper-role into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1066
2025-05-29 12:12:54 +02:00
7a2743046d fix tests 2025-05-29 12:11:48 +02:00
7027145a9a use proper role instead of manully validating role 2025-05-29 12:10:35 +02:00
782d68cd03 Merge pull request 'allow users name to start with ÄÜÖ' (#1063) from fix-uppercase-non-ascii-name into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1063
2025-05-27 08:54:50 +02:00
c6a2b529c3 allow users name to start with ÄÜÖ 2025-05-27 08:54:15 +02:00
b0b2ad2148 Merge pull request 'improve text' (#1061) from nicer-text into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1061
2025-05-26 23:42:53 +02:00
09e06017c2 improve text 2025-05-26 23:41:08 +02:00
34ade37f2d Merge pull request 'add docs' (#1057) from docs into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1057
2025-05-22 13:26:01 +02:00
138c0598e6 add docs 2025-05-22 13:25:14 +02:00
5b75ff5d38 Merge pull request 'add planned mod' (#1054) from restructure into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1054
2025-05-22 13:08:03 +02:00
a42e0b3ed3 add planned mod 2025-05-22 13:06:26 +02:00
743359904a Merge pull request '[BUGFIX] add border top reservation' (#1052) from reservation-border into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1052
2025-05-22 12:43:20 +02:00
f46ddf249a Merge pull request 'slight restructure of trip' (#1051) from restructure into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1051
2025-05-22 12:41:54 +02:00
bc6244bc03 slight restructure of trip 2025-05-22 12:41:01 +02:00
Marie Birner
47e3d1b5b3 [BUGFIX] add border top reservation 2025-05-22 12:34:05 +02:00
d6b9a2f11b Merge pull request 'board members can delete trips, proper notification + succ message is created' (#1048) from board-can-delete-trips into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1048
2025-05-21 09:58:20 +02:00
4e04b2b082 board members can delete trips, proper notification + succ message is created 2025-05-21 09:56:45 +02:00
73a7abd418 Merge pull request 'allow scheckbuch finances editing via /user/fees' (#1046) from consistent-user-management into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1046
2025-05-17 16:36:04 +02:00
abd58766d8 allow scheckbuch finances editing via /user/fees 2025-05-17 16:34:50 +02:00
58a357fdb5 Merge pull request 'ernst + board got rowingbadge notification on every trip of ernst, bc last 'wanderfahrt' was removed' (#1044) from dont-repeatedly-get-price into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1044
2025-05-17 16:24:22 +02:00
5202060e2f ernst + board got rowingbadge notification on every trip of ernst, bc last 'wanderfahrt' was removed 2025-05-17 16:23:24 +02:00
129c90f1aa Merge pull request 'format & lanaguage improv' (#1042) from language-improvement into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1042
2025-05-17 15:59:58 +02:00
64b3e63e15 format & lanaguage improv 2025-05-17 15:59:15 +02:00
e631ee67b5 Merge pull request 'default duration in cal of trips with type Lange Ausfahrt -> 6 hrs (instead of 3); Fixes #939' (#1039) from longer-long-trips into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1039
2025-05-17 10:09:09 +02:00
63edc3d249 default duration in cal of trips with type Lange Ausfahrt -> 6 hrs (instead of 3); Fixes #939 2025-05-17 10:08:07 +02:00
61016f284c Merge pull request 'add nx link' (#1038) from nx-link into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1038
2025-05-17 09:55:27 +02:00
18348e68f3 add nx link 2025-05-17 09:54:46 +02:00
7730de8ada Merge pull request 'more-activities' (#1036) from more-activities into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1036
2025-05-17 09:50:08 +02:00
066f47d99d linting 2025-05-17 09:49:11 +02:00
f7bb394236 remove more logs w/ activities 2025-05-17 09:48:45 +02:00
b3033fbc72 Merge pull request 'use formatted_names in roles' (#1034) from format-roles into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1034
2025-05-17 09:13:49 +02:00
c246e06e69 use formatted_names in roles 2025-05-17 09:13:06 +02:00
0dca843d6a Merge pull request 'already in db' (#1032) from already-in-db into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1032
2025-05-17 09:08:03 +02:00
e334cea0e2 already in db 2025-05-17 08:15:59 +02:00
7e10253e2e Merge pull request 'reflect new fee structure' (#1029) from new-fee-structure into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1029
2025-05-17 08:14:04 +02:00
dc75e0145a reflect new fee structure 2025-05-16 23:02:40 +02:00
1e2dc4ccbc Merge pull request 'auto update both branches' (#1026) from auto-update-both-branches into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1026
2025-05-15 22:26:57 +02:00
4bcba1ec47 auto update both branches 2025-05-15 22:25:27 +02:00
452a1e1b97 Merge pull request 'fix ci' (#1024) from fix/ci into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1024
2025-05-14 23:46:06 +02:00
412b733e30 fix ci 2025-05-14 23:44:53 +02:00
965cba0919 Merge pull request 'improve logging' (#1022) from improve-logging into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1022
2025-05-14 23:01:39 +02:00
dae8632a34 improve logging 2025-05-14 23:00:17 +02:00
55bdca4238 Merge pull request 'add auto-update; Progress at #503' (#1020) from auto-update into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1020
2025-05-14 08:31:49 +02:00
bf7dab235c add auto-update; Progress at #503 2025-05-14 08:31:04 +02:00
bb3e8dadb7 Merge pull request 'not so much clutter when addding notes' (#1018) from nicer-notes into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1018
2025-05-14 08:15:57 +02:00
ed6d05eb9e not so much clutter when addding notes 2025-05-14 08:04:56 +02:00
edcdc74c1c Merge pull request 'start replacing activitybuilder' (#1015) from acitvities-adaption into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1015
2025-05-12 23:09:46 +02:00
3ab1dbd1f1 start replacing activitybuilder 2025-05-12 23:08:45 +02:00
6e9367fa07 Merge pull request 'prepare to remove old log, in favor of activities' (#1013) from acitvities-adaption into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1013
2025-05-12 22:32:19 +02:00
e4a8caf632 prepare to remove old log, in favor of activities 2025-05-12 22:29:34 +02:00
cd39f1a694 Merge pull request 'clean duplicate speicifications' (#1009) from clean-backend-code into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1009
2025-05-11 20:56:11 +02:00
396fc8e659 clean duplicate speicifications 2025-05-11 20:52:13 +02:00
f86d2f6307 Merge pull request 'fix notification' (#1007) from fix-notification into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1007
2025-05-10 15:48:39 +02:00
1ecde79593 fix notification 2025-05-10 15:47:25 +02:00
e8b8ba393f Merge pull request 'inform new cox about things' (#1004) from informat-new-cox into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1004
2025-05-09 13:06:29 +02:00
3801c7ce8c inform new cox about things 2025-05-09 13:04:51 +02:00
816257d4be Merge pull request 'remove notes from users (switched to activity)' (#1002) from remove-notes into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1002
2025-05-09 08:57:45 +02:00
23399b7757 remove notes from users (switched to activity) 2025-05-09 08:44:17 +02:00
0c5812f725 Merge pull request 'show all activities for admin; Fixes #984' (#1000) from show-all-activities into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#1000
2025-05-09 08:32:33 +02:00
d88a35bb82 show all activities for admin; Fixes #984 2025-05-09 08:30:09 +02:00
52abcbb3fb Merge pull request 'show proper text for all who have acheived diamond equatorprice' (#998) from fix-diamond-text into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#998
2025-05-07 13:44:47 +02:00
29777cdc36 show proper text for all who have acheived diamond equatorprice 2025-05-07 13:43:57 +02:00
22b9a2e324 Merge pull request '[TASK] add search bar and improve ux' (#996) from improve-role-view into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#996
2025-05-07 13:06:39 +02:00
a97d515f03 lint 2025-05-07 13:06:02 +02:00
Marie Birner
72fc3ed91e [TASK] add search bar and improve ux 2025-05-07 11:52:58 +02:00
b079eafc3d Merge pull request 'update deps' (#994) from upd into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#994
2025-05-06 22:51:56 +02:00
6e1bfe8635 update deps 2025-05-06 22:49:51 +02:00
ce28f93d65 Merge pull request 'single-user-edit-page' (#992) from single-user-edit-page into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#992
2025-05-06 13:43:23 +02:00
bf3a4c686a Merge branch 'single-user-edit-page' of ssh://git.hofer.link:2222/Ruderverein-Donau-Linz/rowt into single-user-edit-page 2025-05-06 13:40:39 +02:00
5fb9e0fbba format 2025-05-06 13:40:33 +02:00
Marie Birner
f58e7d1307 Merge commit '374fed9e3bdcee30a3f8315d1f0c392204a885df' into single-user-edit-page 2025-05-06 09:57:02 +02:00
374fed9e3b fix tests by clicking on boat name instead of cox 2025-05-06 08:50:45 +02:00
Marie Birner
b9f2382cba [BUGFIX] buttons mobile margin 2025-05-06 07:45:32 +02:00
Marie Birner
aab3a15488 [BUGFIX] back link margin bottom mobile 2025-05-06 07:44:12 +02:00
83b93fba09 Merge branch 'single-user-edit-page' of ssh://git.hofer.link:2222/Ruderverein-Donau-Linz/rowt into single-user-edit-page 2025-05-05 22:24:32 +02:00
3b5ff70d1d don't clutter acitvities toooooo much 2025-05-05 22:23:28 +02:00
2af9ac20b1 Merge pull request '[TASK] add icons to add new user and improve ui in setting a fixed height in activity log' (#989) from improve-user-view into single-user-edit-page
Reviewed-on: Ruderverein-Donau-Linz/rowt#989
2025-05-05 22:16:11 +02:00
Marie Birner
5331ac71fa [TASK] add icons to add new user and improve ui in setting a fixed height in activity log 2025-05-05 22:12:19 +02:00
6098aedb74 fix tests? 2025-05-05 22:11:56 +02:00
Marie Birner
af2e7cb557 Merge commit '17513bbc386e849962157517d9a4496870b1a064' into single-user-edit-page 2025-05-05 21:14:41 +02:00
Marie Birner
81b99ef414 [TASK] edit form on logbook fixes #635 2025-05-05 21:14:23 +02:00
17513bbc38 give frontend stuff to be able to update logbook entriese 2025-05-05 21:11:41 +02:00
c1cecf3b20 don't panic on 'external cox' 2025-05-05 20:46:41 +02:00
8e40e563c6 even less clutter! 2025-05-05 20:44:00 +02:00
Marie Birner
bb78441cc4 Merge branch 'single-user-edit-page' of https://git.hofer.link/Ruderverein-Donau-Linz/rowt into single-user-edit-page 2025-05-05 20:37:45 +02:00
Marie Birner
abcf46281b [TASK] style detail view user 2025-05-05 20:37:29 +02:00
c460494be8 Merge branch 'single-user-edit-page' of ssh://git.hofer.link:2222/Ruderverein-Donau-Linz/rowt into single-user-edit-page 2025-05-05 20:37:20 +02:00
e5560ba536 don't clutter acitvities too much 2025-05-05 20:37:12 +02:00
Marie Birner
b7094bff06 Merge branch 'single-user-edit-page' of https://git.hofer.link/Ruderverein-Donau-Linz/rowt into single-user-edit-page 2025-05-05 20:21:11 +02:00
Marie Birner
6b8b4ba1d2 [TASK] style new user action in list view 2025-05-05 20:20:52 +02:00
03f76b1ae5 Merge branch 'single-user-edit-page' of ssh://git.hofer.link:2222/Ruderverein-Donau-Linz/rowt into single-user-edit-page 2025-05-05 20:01:48 +02:00
1864ea260c allow to edit roles 2025-05-05 20:01:32 +02:00
Marie Birner
35a5a55140 [TASK] change background color dark view 2025-05-05 19:44:11 +02:00
9a4dcc0b9d create activity for 'user deleted'; Fixes #985 2025-05-05 18:23:31 +02:00
43074b3bd7 fix err if scheckbuch has already paid 2025-05-05 11:49:26 +02:00
933e407c64 update prod db :-) 2025-05-05 11:41:32 +02:00
d9e86bf43b allow to create users 2025-05-05 11:35:38 +02:00
ebbb4fe3da don't create activity, e.g. for paid-changes, as this is already logged 2025-05-04 18:48:14 +02:00
9178476013 improve string 2025-05-04 18:41:50 +02:00
e853381bd7 Fill acitivites from various activities; Fixes #972 2025-05-04 18:38:14 +02:00
8777ccb341 format 2025-05-04 10:31:36 +02:00
6362fed909 Be able to update financial and skill; Fixes #974 2025-05-04 10:31:15 +02:00
905178e60d add explanation text + reformat 2025-05-03 21:22:35 +02:00
cd52e76b61 remove already replaced part 2025-05-03 21:17:17 +02:00
151c97aabc add notes 2025-05-03 21:12:04 +02:00
e360c4f06b start with activity + fix tests 2025-05-03 19:04:13 +02:00
d50501b362 delete large user-update-function 🎉 Fixes #958 2025-05-03 18:31:14 +02:00
e6895c8cf1 allow final usertype changes; Fixes #954 2025-05-03 18:26:19 +02:00
Marie Birner
7bd863ddf1 [TASK] add schnupper to scheckbuch button 2025-05-03 17:44:27 +02:00
afc32cc41e allow to change from schnupperant to scheckbuch 2025-05-03 17:39:03 +02:00
Marie Birner
9dfcb4e2c4 [TASK] change text not logged in yet 2025-05-03 17:23:40 +02:00
Marie Birner
149b6afbf5 [TASK] adapt action in user detail view schnupperant or scheckbuch 2025-05-03 17:19:51 +02:00
f9a53a703b Merge branch 'single-user-edit-page' of ssh://git.hofer.link:2222/Ruderverein-Donau-Linz/rowt into single-user-edit-page 2025-05-03 17:14:10 +02:00
c8be0c2c22 allow changing from schnupperant 2025-05-03 17:14:03 +02:00
Marie Birner
6c8667973d [TASK] rm cursor pointer and rename mitgliedstyp to mitgliedsstatus 2025-05-03 16:57:54 +02:00
Marie Birner
07f7dbca12 [BUGFIX] foerdend to foerdernd in html 2025-05-03 16:50:22 +02:00
3f29400831 Merge branch 'single-user-edit-page' of ssh://git.hofer.link:2222/Ruderverein-Donau-Linz/rowt into single-user-edit-page 2025-05-03 16:46:45 +02:00
46981c3311 allow to change type between members 2025-05-03 16:46:40 +02:00
Marie Birner
b08fcdc05b [TASK] change list view user 2025-05-03 16:46:30 +02:00
Marie Birner
a60606bbe4 [TASK] add icons to back button and download button 2025-05-03 16:26:43 +02:00
Marie Birner
540031cab4 [TASK] add back button 2025-05-03 16:20:24 +02:00
Marie Birner
a93c420630 [TASK] edit membership type button and dialog 2025-05-03 16:13:57 +02:00
8dc55a7aad format 2025-05-03 15:54:52 +02:00
Marie Birner
5b78afff63 [TASK] refactor roles and move elements around 2025-05-03 15:54:10 +02:00
25c3a28c7d Merge branch 'single-user-edit-page' of ssh://git.hofer.link:2222/Ruderverein-Donau-Linz/rowt into single-user-edit-page 2025-05-03 15:09:23 +02:00
2bb42c3f6a allow moving scheckbuch -> unterstützend + fördernd 2025-05-03 15:09:15 +02:00
Marie Birner
d7dec5da29 [TASK] only allowed users change role from scheckbuch to mitglied 2025-05-03 14:45:57 +02:00
Marie Birner
ffe1745b65 [TASK] add opacity disabled select 2025-05-03 14:42:43 +02:00
f374016207 update users 2025-05-03 13:14:47 +02:00
6d329eb980 format 2025-05-03 12:58:04 +02:00
cfd3c6200f move block 2025-05-03 12:57:49 +02:00
Marie Birner
4d95282e58 Merge commit '9aab07422dc1f6af7fb3836704a565e109b01ff3' into single-user-edit-page 2025-05-03 12:45:04 +02:00
Marie Birner
540c122248 [TASK] refactor ui user detail page 2025-05-03 12:44:46 +02:00
9aab07422d allow moving scheckbuch -> regular 2025-05-03 12:27:02 +02:00
Marie Birner
c47b1988b2 [TASK] add input group element with interaction 2025-05-02 17:24:40 +02:00
a73bbf059f format + reorder blocks 2025-05-02 15:15:02 +02:00
1f17a10133 clearer error msg 2025-05-01 20:15:39 +02:00
73c79fb008 progress 2025-05-01 20:08:05 +02:00
c2b57583cf fix error when no fee is set 2025-05-01 19:14:40 +02:00
d2000f4699 show payment status in user view; Fixes #965 2025-04-30 23:31:17 +02:00
c4ed766c4d staging diff improvements 2025-04-30 15:55:50 +02:00
68cf563964 more self-explanatory roles + formatted_names for roles 2025-04-30 15:55:19 +02:00
b2e07653e6 create member type 2025-04-30 15:04:47 +02:00
19887e133d Only show Vereinsmitglied-block for members 2025-04-30 13:55:52 +02:00
d2914f9287 be able to update data individually; Fixes #952 2025-04-30 13:38:45 +02:00
c8d5c633d7 be able to update data individually; Fixes #951 2025-04-30 11:06:10 +02:00
90087843ad format 2025-04-29 23:06:45 +02:00
5e588f209f Merge branch 'main' into single-user-edit-page 2025-04-29 23:06:25 +02:00
c99c83d9fb Merge pull request 'fix scheckbuch list' (#961) from fix-list-scheckbuch into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#961
2025-04-29 23:02:18 +02:00
ef7beccdf2 fix scheckbuch list 2025-04-29 23:01:29 +02:00
34850321b7 first draft of single user-edit page 2025-04-29 22:37:04 +02:00
b0168b798c Merge pull request 'separate-scheckbuch-user' (#949) from separate-scheckbuch-user into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#949
2025-04-29 21:06:07 +02:00
7604678d4a make scheckbuch user behave as previously, but in own file 2025-04-29 21:00:50 +02:00
876451fc02 Merge branch 'main' into separate-scheckbuch-user 2025-04-29 20:44:09 +02:00
80a70fb812 Merge pull request 'log event updates' (#947) from log-event-updates into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#947
2025-04-29 20:37:43 +02:00
8059e5b8fc log event updates 2025-04-29 20:36:32 +02:00
f1423b8713 Merge pull request 'format dtstart according to ics standard -> leading zero' (#945) from format-cal-according-to-standard into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#945
2025-04-28 22:21:42 +02:00
47b46cf41d format dtstart according to ics standard -> leading zero 2025-04-28 22:20:06 +02:00
a484785027 first ideas of separate user structs, working on #911 2025-04-25 15:41:46 +02:00
4134b2a65b Merge pull request 'no need to show rower box, if no rower can particiapte' (#936) from hide-box into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#936
2025-04-19 21:30:27 +02:00
f289c7b6d7 no need to show rower box, if no rower can particiapte 2025-04-19 21:29:17 +02:00
0f1bc39b4b Merge pull request 'document nextcloud integration, for future nextcloud setups' (#934) from doc-nextcloud-integration into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#934
2025-04-19 09:20:10 +02:00
3eb84ce46b document nextcloud integration, for future nextcloud setups 2025-04-19 09:19:11 +02:00
c8b01bcd03 Merge pull request 'zero-rower-events' (#932) from zero-rower-events into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#932
2025-04-19 00:22:35 +02:00
9b31ea981a hack in frontend test not working, as we can' hack a cancellation with setting rowers=0 2025-04-19 00:21:51 +02:00
b4a22820e7 tests are not using magic values as well... 2025-04-18 23:48:08 +02:00
af0aad2a99 Merge pull request 'also be able to cancel trips (not only events)' (#930) from zero-rower-events into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#930
2025-04-18 23:32:40 +02:00
fe6db2cdd5 one more check 2025-04-18 23:32:00 +02:00
5cd75ed8c8 also be able to cancel trips (not only events) 2025-04-18 23:24:03 +02:00
1ed0d8fd32 Merge pull request 'reduce amount of magic values, goal is to have specific states -> e.g. cancelled' (#928) from zero-rower-events into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#928
2025-04-18 23:14:04 +02:00
10740f988d reduce amount of magic values, goal is to have specific states -> e.g. cancelled 2025-04-18 23:01:17 +02:00
f98963a28a Merge pull request 'simple-nx-auth' (#924) from simple-nx-auth into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#924
2025-04-18 17:44:51 +02:00
37b6ea6057 remove unused dep; cargo clippy 2025-04-18 17:44:21 +02:00
06c5e5a9d1 Merge branch 'staging' into simple-nx-auth 2025-04-18 17:10:10 +02:00
0059dfe96f simple nx auth 2025-04-18 17:04:10 +02:00
e01afa7d74 Merge pull request 'use maries' magic css skills to unbreak signal links on mobile; Fixes #891' (#922) from signal-breaking-mobile into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#922
2025-04-17 22:01:46 +02:00
2458b0a100 Merge pull request 'signal-breaking-mobile' (#921) from signal-breaking-mobile into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#921
2025-04-17 22:01:40 +02:00
36245fd0f7 use maries' magic css skills to unbreak signal links on mobile; Fixes #891 2025-04-17 22:00:15 +02:00
85bec7f591 Merge pull request 'better description of the button' (#920) from send-fee-reminder into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#920
2025-04-17 21:54:25 +02:00
7e0b30f058 Merge pull request 'better description of the button' (#919) from send-fee-reminder into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#919
2025-04-17 21:53:56 +02:00
b0a2d3d539 better description of the button 2025-04-17 21:52:24 +02:00
ac5f9d253d Merge pull request 'allow others to send fee reminder thus reducing my bus factor' (#916) from send-fee-reminder into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#916
2025-04-17 20:44:51 +02:00
8340e8b33f Merge pull request 'allow others to send fee reminder thus reducing my bus factor' (#915) from send-fee-reminder into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#915
2025-04-17 20:44:45 +02:00
db429b6fe3 high security application... 2025-04-17 20:42:36 +02:00
cf90ab6e1a allow others to send fee reminder thus reducing my bus factor 2025-04-17 20:38:41 +02:00
3b25143a08 Merge pull request '400 instead of 303' (#907) from nx-auth into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#907
2025-04-16 11:32:07 +02:00
4ce9a573fe 400 instead of 303 2025-04-16 11:31:46 +02:00
78aafe4d41 Merge pull request 'nx-auth' (#906) from nx-auth into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#906
2025-04-16 10:57:35 +02:00
dc2ee38aa0 no funny business w/ get params 2025-04-16 10:56:57 +02:00
2b79df8e42 no funny business w/ get params 2025-04-16 10:46:19 +02:00
43c0b9ffc1 Merge pull request 'nx-auth' (#905) from nx-auth into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#905
2025-04-16 10:19:13 +02:00
588520914c add nextcloud auth route 2025-04-16 10:18:27 +02:00
819c4bb31b Merge pull request 'fix tests' (#903) from fix-cal-uid into main
Reviewed-on: Ruderverein-Donau-Linz/rowt#903
2025-04-15 23:18:32 +02:00
0c425f7a8e Merge pull request 'fix tests' (#902) from fix-cal-uid into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#902
2025-04-15 23:18:26 +02:00
5da4b592ea fix tests 2025-04-15 23:17:57 +02:00
21b33566bc Merge pull request 'make default duration 3 hrs (to have a larger block in the cal)' (#900) from fix-cal-uid into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#900
2025-04-15 23:13:13 +02:00
ca3de1123b Merge pull request 'fix ci' (#898) from fix-cal-uid into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#898
2025-04-15 22:12:09 +02:00
1c628f40ed Merge pull request 'also show cox_helps_at_event in cal' (#897) from fix-cal-uid into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#897
2025-04-15 20:53:40 +02:00
2f4874321f Merge pull request 'have unique uid's, fixes error in some clients (e.g. sogo)' (#895) from fix-cal-uid into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#895
2025-04-15 20:33:46 +02:00
35dffdd8f0 Merge pull request 'upd' (#892) from upd into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#892
2025-04-15 14:20:20 +02:00
b419004949 Merge pull request 'fix-kiosk-error' (#885) from fix-kiosk-error into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#885
2025-03-26 20:58:10 +01:00
94938fb4ea Merge pull request 'update deps' (#883) from upd into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#883
2025-03-26 14:52:02 +01:00
2368f03761 Merge pull request 'update id's' (#881) from add-unit-test into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#881
2025-03-09 19:21:35 +01:00
a7d33548d4 Merge pull request 'add-unit-test' (#879) from add-unit-test into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#879
2025-03-09 19:18:24 +01:00
0f345862ee Merge pull request 'correct-name-in-notification' (#878) from correct-name-in-notification into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#878
2025-03-09 13:34:36 +01:00
856e3b2cff Merge pull request 'update deps' (#872) from update-deps into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#872
2025-03-06 10:24:35 +01:00
9b9cf98473 Merge pull request 'remove-philipp-mentioning' (#870) from remove-philipp-mentioning into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#870
2025-02-17 22:58:45 +01:00
ae61564ad4 Merge pull request 'update-deps' (#868) from update-deps into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#868
2025-02-13 10:06:50 +01:00
a2a39103e0 Merge pull request 'upd' (#865) from upd into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#865
2025-02-11 21:54:14 +01:00
d82bd3ebeb Merge pull request 'fix ci' (#862) from allow-secretary-to-edit-boats into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#862
2025-02-11 09:37:20 +01:00
32800b1897 Merge pull request 'allow vorstand to edit boats' (#860) from allow-secretary-to-edit-boats into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#860
2025-02-11 09:23:28 +01:00
cfd8b12556 Merge pull request 'main' (#859) from main into staging
Reviewed-on: Ruderverein-Donau-Linz/rowt#859
2025-02-10 18:49:20 +01:00
131 changed files with 17500 additions and 1876 deletions

View File

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

View File

@@ -0,0 +1,51 @@
name: Update Cargo Dependencies
on:
schedule:
- cron: '0 2 * * 5' # Run weekly on Friday at 2am
workflow_dispatch: # Allow manual triggering
jobs:
update-dependencies:
runs-on: ubuntu-latest
container: git.hofer.link/philipp/ci-images:rust-latest
steps:
- uses: actions/checkout@v3
- name: Update dependencies
run: |
cargo upgrade
cargo update
- name: Create Pull Request Staging
uses: https://git.hofer.link/philipp/create-pull-request@18ef1fdad70eec569ab10292c1fa79c1b5296370
with:
token: ${{ secrets.GITEATOKEN }}
commit-message: Update Cargo dependencies
title: Update Cargo dependencies
body: |
This PR updates Cargo dependencies to their latest versions.
@philipp
- Run `cargo upgrade` to update version requirements in Cargo.toml
- Run `cargo update` to update Cargo.lock
branch: update-cargo-dependencies
delete-branch: false
- name: Create Pull Request Main
uses: https://git.hofer.link/philipp/create-pull-request@18ef1fdad70eec569ab10292c1fa79c1b5296370
with:
token: ${{ secrets.GITEATOKEN }}
commit-message: Update Cargo dependencies
title: Update Cargo dependencies
body: |
This PR updates Cargo dependencies to their latest versions.
@philipp
- Run `cargo upgrade` to update version requirements in Cargo.toml
- Run `cargo update` to update Cargo.lock
branch: update-cargo-dependencies
base: main
delete-branch: true

210
Cargo.lock generated
View File

@@ -221,9 +221,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "backtrace"
version = "0.3.74"
version = "0.3.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a"
checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
dependencies = [
"addr2line",
"cfg-if",
@@ -303,9 +303,9 @@ checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
[[package]]
name = "bytemuck"
version = "1.22.0"
version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540"
checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c"
[[package]]
name = "byteorder"
@@ -321,9 +321,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "cc"
version = "1.2.19"
version = "1.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362"
checksum = "8691782945451c1c383942c4874dbe63814f61cb57ef773cda2972682b7bb3c0"
dependencies = [
"shlex",
]
@@ -336,9 +336,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.40"
version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [
"android-tzdata",
"iana-time-zone",
@@ -615,9 +615,9 @@ dependencies = [
[[package]]
name = "der"
version = "0.7.9"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"pem-rfc7468",
@@ -635,9 +635,9 @@ dependencies = [
[[package]]
name = "deunicode"
version = "1.6.1"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]]
name = "devise"
@@ -1028,9 +1028,9 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.2.15"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
@@ -1126,9 +1126,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.15.2"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"
dependencies = [
"allocator-api2",
"equivalent",
@@ -1141,7 +1141,7 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.2",
"hashbrown 0.15.3",
]
[[package]]
@@ -1158,9 +1158,9 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hermit-abi"
version = "0.5.0"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e"
checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08"
[[package]]
name = "hex"
@@ -1476,7 +1476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [
"equivalent",
"hashbrown 0.15.2",
"hashbrown 0.15.3",
"serde",
]
@@ -1521,7 +1521,7 @@ version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
dependencies = [
"hermit-abi 0.5.0",
"hermit-abi 0.5.1",
"libc",
"windows-sys 0.59.0",
]
@@ -1549,9 +1549,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jiff"
version = "0.2.8"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5ad87c89110f55e4cd4dc2893a9790820206729eaf221555f742d540b0724a0"
checksum = "f02000660d30638906021176af16b17498bd0d12813dbfe7b276d8bc7f3c0806"
dependencies = [
"jiff-static",
"log",
@@ -1562,9 +1562,9 @@ dependencies = [
[[package]]
name = "jiff-static"
version = "0.2.8"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d076d5b64a7e2fe6f0743f02c43ca4a6725c0f904203bfe276a5b3e793103605"
checksum = "f3c30758ddd7188629c6713fc45d1188af4f44c90582311d0c8d8c9907f60c48"
dependencies = [
"proc-macro2",
"quote",
@@ -1594,9 +1594,9 @@ dependencies = [
[[package]]
name = "kqueue"
version = "1.0.8"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
@@ -1654,9 +1654,9 @@ checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libm"
version = "0.2.11"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
checksum = "a25169bd5913a4b437588a7e3d127cd6e90127b60e0ffbd834a38f1599e016b8"
[[package]]
name = "libredox"
@@ -2002,9 +2002,9 @@ dependencies = [
[[package]]
name = "openssl-sys"
version = "0.9.107"
version = "0.9.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07"
checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847"
dependencies = [
"cc",
"libc",
@@ -2267,14 +2267,14 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy 0.8.24",
"zerocopy 0.8.25",
]
[[package]]
name = "proc-macro2"
version = "1.0.94"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
@@ -2294,9 +2294,9 @@ dependencies = [
[[package]]
name = "psm"
version = "0.1.25"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f58e5423e24c18cc840e1c98370b3993c6649cd1678b4d24318bcf0a083cbe88"
checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f"
dependencies = [
"cc",
]
@@ -2355,14 +2355,14 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.15",
"getrandom 0.2.16",
]
[[package]]
name = "redox_syscall"
version = "0.5.11"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3"
checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af"
dependencies = [
"bitflags 2.9.0",
]
@@ -2439,7 +2439,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.15",
"getrandom 0.2.16",
"libc",
"untrusted",
"windows-sys 0.52.0",
@@ -2594,9 +2594,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustix"
version = "1.0.5"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf"
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
dependencies = [
"bitflags 2.9.0",
"errno",
@@ -2607,9 +2607,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.26"
version = "0.23.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df51b5869f3a441595eac5e8ff14d486ff285f7b8c0df8770e49c3b56351f0f0"
checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321"
dependencies = [
"log",
"once_cell",
@@ -2637,9 +2637,9 @@ checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c"
[[package]]
name = "rustls-webpki"
version = "0.103.1"
version = "0.103.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03"
checksum = "7149975849f1abb3832b246010ef62ccc80d3a76169517ada7188252b9cfb437"
dependencies = [
"ring",
"rustls-pki-types",
@@ -2777,9 +2777,9 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.10.8"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
@@ -2803,9 +2803,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.2"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
dependencies = [
"libc",
]
@@ -2885,9 +2885,9 @@ dependencies = [
[[package]]
name = "sqlx"
version = "0.8.4"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14e22987355fbf8cfb813a0cf8cd97b1b4ec834b94dbd759a9e8679d41fabe83"
checksum = "f3c3a85280daca669cfd3bcb68a337882a8bc57ec882f72c5d13a430613a738e"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -2898,9 +2898,9 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.8.4"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55c4720d7d4cd3d5b00f61d03751c685ad09c33ae8290c8a2c11335e0604300b"
checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3"
dependencies = [
"base64",
"bytes",
@@ -2913,7 +2913,7 @@ dependencies = [
"futures-intrusive",
"futures-io",
"futures-util",
"hashbrown 0.15.2",
"hashbrown 0.15.3",
"hashlink",
"indexmap",
"log",
@@ -2930,14 +2930,14 @@ dependencies = [
"tokio-stream",
"tracing",
"url",
"webpki-roots",
"webpki-roots 0.26.11",
]
[[package]]
name = "sqlx-macros"
version = "0.8.4"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175147fcb75f353ac7675509bc58abb2cb291caf0fd24a3623b8f7e3eb0a754b"
checksum = "7f4200e0fde19834956d4252347c12a083bdcb237d7a1a1446bffd8768417dce"
dependencies = [
"proc-macro2",
"quote",
@@ -2948,9 +2948,9 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.8.4"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cde983058e53bfa75998e1982086c5efe3c370f3250bf0357e344fa3352e32b"
checksum = "882ceaa29cade31beca7129b6beeb05737f44f82dbe2a9806ecea5a7093d00b7"
dependencies = [
"dotenvy",
"either",
@@ -2974,9 +2974,9 @@ dependencies = [
[[package]]
name = "sqlx-mysql"
version = "0.8.4"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "847d2e5393a4f39e47e4f36cab419709bc2b83cbe4223c60e86e1471655be333"
checksum = "0afdd3aa7a629683c2d750c2df343025545087081ab5942593a5288855b1b7a7"
dependencies = [
"atoi",
"base64",
@@ -3017,9 +3017,9 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.8.4"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc35947a541b9e0a2e3d85da444f1c4137c13040267141b208395a0d0ca4659f"
checksum = "a0bedbe1bbb5e2615ef347a5e9d8cd7680fb63e77d9dafc0f29be15e53f1ebe6"
dependencies = [
"atoi",
"base64",
@@ -3055,9 +3055,9 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.8.4"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c48291dac4e5ed32da0927a0b981788be65674aeb62666d19873ab4289febde"
checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc"
dependencies = [
"atoi",
"chrono",
@@ -3095,9 +3095,9 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "stacker"
version = "0.1.20"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "601f9201feb9b09c00266478bf459952b9ef9a6b94edb2f21eba14ab681a60a9"
checksum = "cddb07e32ddb770749da91081d8d0ac3a16f1a569a18b20348cd371f5dead06b"
dependencies = [
"cc",
"cfg-if",
@@ -3134,9 +3134,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.100"
version = "2.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf"
dependencies = [
"proc-macro2",
"quote",
@@ -3145,9 +3145,9 @@ dependencies = [
[[package]]
name = "synstructure"
version = "0.13.1"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
@@ -3277,9 +3277,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.44.2"
version = "1.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48"
checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165"
dependencies = [
"backtrace",
"bytes",
@@ -3316,9 +3316,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.14"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034"
checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
dependencies = [
"bytes",
"futures-core",
@@ -3329,9 +3329,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.8.20"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148"
checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae"
dependencies = [
"serde",
"serde_spanned",
@@ -3341,26 +3341,33 @@ dependencies = [
[[package]]
name = "toml_datetime"
version = "0.6.8"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.24"
version = "0.22.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474"
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"winnow 0.7.6",
"toml_write",
"winnow 0.7.9",
]
[[package]]
name = "toml_write"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076"
[[package]]
name = "tower-service"
version = "0.3.3"
@@ -3567,9 +3574,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.0.10"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b0351ca625c7b41a8e4f9bb6c5d9755f67f62c2187ebedecacd9974674b271d"
checksum = "b7a3e9af6113ecd57b8c63d3cd76a385b2e3881365f1f489e54f49801d0c83ea"
dependencies = [
"base64",
"cookie_store",
@@ -3583,14 +3590,14 @@ dependencies = [
"serde_json",
"ureq-proto",
"utf-8",
"webpki-roots",
"webpki-roots 0.26.11",
]
[[package]]
name = "ureq-proto"
version = "0.3.5"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae239d0a3341aebc94259414d1dc67cfce87d41cbebc816772c91b77902fafa4"
checksum = "fadf18427d33828c311234884b7ba2afb57143e6e7e69fda7ee883b624661e36"
dependencies = [
"base64",
"http 1.3.1",
@@ -3766,9 +3773,18 @@ dependencies = [
[[package]]
name = "webpki-roots"
version = "0.26.8"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.0",
]
[[package]]
name = "webpki-roots"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb"
dependencies = [
"rustls-pki-types",
]
@@ -4041,9 +4057,9 @@ dependencies = [
[[package]]
name = "winnow"
version = "0.7.6"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63d3fcd9bba44b03821e7d699eeee959f3126dcc4aa8e4ae18ec617c2a5cea10"
checksum = "d9fb597c990f03753e08d3c29efbfcf2019a003b4bf4ba19225c158e1549f0f3"
dependencies = [
"memchr",
]
@@ -4113,11 +4129,11 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.24"
version = "0.8.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879"
checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb"
dependencies = [
"zerocopy-derive 0.8.24",
"zerocopy-derive 0.8.25",
]
[[package]]
@@ -4133,9 +4149,9 @@ dependencies = [
[[package]]
name = "zerocopy-derive"
version = "0.8.24"
version = "0.8.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be"
checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef"
dependencies = [
"proc-macro2",
"quote",

View File

@@ -9,7 +9,7 @@ rowing-tera = ["rocket_dyn_templates", "tera"]
rest = []
[dependencies]
rocket = { version = "0.5.0", features = ["secrets"]}
rocket = { version = "0.5", features = ["secrets"]}
rocket_dyn_templates = {version = "0.2", features = [ "tera" ], optional = true }
log = "0.4"
env_logger = "0.11"
@@ -19,15 +19,15 @@ serde = { version = "1.0", features = [ "derive" ]}
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"]}
chrono-tz = "0.10"
tera = { version = "1.18", features = ["date-locale"], optional = true}
tera = { version = "1.20", features = ["date-locale"], optional = true}
ics = "0.5"
futures = "0.3"
lettre = "0.11"
csv = "1.3"
itertools = "0.14"
job_scheduler_ng = "2.0"
job_scheduler_ng = "2.2"
ureq = { version = "3.0", features = ["json"] }
regex = "1.10"
regex = "1.11"
urlencoding = "2.1"
[target.'cfg(not(windows))'.dependencies]

287
LICENSE Normal file
View File

@@ -0,0 +1,287 @@
EUROPEAN UNION PUBLIC LICENCE v. 1.2
EUPL © the European Union 2007, 2016
This European Union Public Licence (the EUPL) applies to the Work (as defined
below) which is provided under the terms of this Licence. Any use of the Work,
other than as authorised under this Licence is prohibited (to the extent such
use is covered by a right of the copyright holder of the Work).
The Work is provided under the terms of this Licence when the Licensor (as
defined below) has placed the following notice immediately following the
copyright notice for the Work:
Licensed under the EUPL
or has expressed by any other means his willingness to license under the EUPL.
1. Definitions
In this Licence, the following terms have the following meaning:
- The Licence: this Licence.
- The Original Work: the work or software distributed or communicated by the
Licensor under this Licence, available as Source Code and also as Executable
Code as the case may be.
- Derivative Works: the works or software that could be created by the
Licensee, based upon the Original Work or modifications thereof. This Licence
does not define the extent of modification or dependence on the Original Work
required in order to classify a work as a Derivative Work; this extent is
determined by copyright law applicable in the country mentioned in Article 15.
- The Work: the Original Work or its Derivative Works.
- The Source Code: the human-readable form of the Work which is the most
convenient for people to study and modify.
- The Executable Code: any code which has generally been compiled and which is
meant to be interpreted by a computer as a program.
- The Licensor: the natural or legal person that distributes or communicates
the Work under the Licence.
- Contributor(s): any natural or legal person who modifies the Work under the
Licence, or otherwise contributes to the creation of a Derivative Work.
- The Licensee or You: any natural or legal person who makes any usage of
the Work under the terms of the Licence.
- Distribution or Communication: any act of selling, giving, lending,
renting, distributing, communicating, transmitting, or otherwise making
available, online or offline, copies of the Work or providing access to its
essential functionalities at the disposal of any other natural or legal
person.
2. Scope of the rights granted by the Licence
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
sublicensable licence to do the following, for the duration of copyright vested
in the Original Work:
- use the Work in any circumstance and for all usage,
- reproduce the Work,
- modify the Work, and make Derivative Works based upon the Work,
- communicate to the public, including the right to make available or display
the Work or copies thereof to the public and perform publicly, as the case may
be, the Work,
- distribute the Work or copies thereof,
- lend and rent the Work or copies thereof,
- sublicense rights in the Work or copies thereof.
Those rights can be exercised on any media, supports and formats, whether now
known or later invented, as far as the applicable law permits so.
In the countries where moral rights apply, the Licensor waives his right to
exercise his moral right to the extent allowed by law in order to make effective
the licence of the economic rights here above listed.
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
any patents held by the Licensor, to the extent necessary to make use of the
rights granted on the Work under this Licence.
3. Communication of the Source Code
The Licensor may provide the Work either in its Source Code form, or as
Executable Code. If the Work is provided as Executable Code, the Licensor
provides in addition a machine-readable copy of the Source Code of the Work
along with each copy of the Work that the Licensor distributes or indicates, in
a notice following the copyright notice attached to the Work, a repository where
the Source Code is easily and freely accessible for as long as the Licensor
continues to distribute or communicate the Work.
4. Limitations on copyright
Nothing in this Licence is intended to deprive the Licensee of the benefits from
any exception or limitation to the exclusive rights of the rights owners in the
Work, of the exhaustion of those rights or of other applicable limitations
thereto.
5. Obligations of the Licensee
The grant of the rights mentioned above is subject to some restrictions and
obligations imposed on the Licensee. Those obligations are the following:
Attribution right: The Licensee shall keep intact all copyright, patent or
trademarks notices and all notices that refer to the Licence and to the
disclaimer of warranties. The Licensee must include a copy of such notices and a
copy of the Licence with every copy of the Work he/she distributes or
communicates. The Licensee must cause any Derivative Work to carry prominent
notices stating that the Work has been modified and the date of modification.
Copyleft clause: If the Licensee distributes or communicates copies of the
Original Works or Derivative Works, this Distribution or Communication will be
done under the terms of this Licence or of a later version of this Licence
unless the Original Work is expressly distributed only under this version of the
Licence — for example by communicating EUPL v. 1.2 only. The Licensee
(becoming Licensor) cannot offer or impose any additional terms or conditions on
the Work or Derivative Work that alter or restrict the terms of the Licence.
Compatibility clause: If the Licensee Distributes or Communicates Derivative
Works or copies thereof based upon both the Work and another work licensed under
a Compatible Licence, this Distribution or Communication can be done under the
terms of this Compatible Licence. For the sake of this clause, Compatible
Licence refers to the licences listed in the appendix attached to this Licence.
Should the Licensee's obligations under the Compatible Licence conflict with
his/her obligations under this Licence, the obligations of the Compatible
Licence shall prevail.
Provision of Source Code: When distributing or communicating copies of the Work,
the Licensee will provide a machine-readable copy of the Source Code or indicate
a repository where this Source will be easily and freely available for as long
as the Licensee continues to distribute or communicate the Work.
Legal Protection: This Licence does not grant permission to use the trade names,
trademarks, service marks, or names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the copyright notice.
6. Chain of Authorship
The original Licensor warrants that the copyright in the Original Work granted
hereunder is owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each Contributor warrants that the copyright in the modifications he/she brings
to the Work are owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each time You accept the Licence, the original Licensor and subsequent
Contributors grant You a licence to their contributions to the Work, under the
terms of this Licence.
7. Disclaimer of Warranty
The Work is a work in progress, which is continuously improved by numerous
Contributors. It is not a finished work and may therefore contain defects or
bugs inherent to this type of development.
For the above reason, the Work is provided under the Licence on an as is basis
and without warranties of any kind concerning the Work, including without
limitation merchantability, fitness for a particular purpose, absence of defects
or errors, accuracy, non-infringement of intellectual property rights other than
copyright as stated in Article 6 of this Licence.
This disclaimer of warranty is an essential part of the Licence and a condition
for the grant of any rights to the Work.
8. Disclaimer of Liability
Except in the cases of wilful misconduct or damages directly caused to natural
persons, the Licensor will in no event be liable for any direct or indirect,
material or moral, damages of any kind, arising out of the Licence or of the use
of the Work, including without limitation, damages for loss of goodwill, work
stoppage, computer failure or malfunction, loss of data or any commercial
damage, even if the Licensor has been advised of the possibility of such damage.
However, the Licensor will be liable under statutory product liability laws as
far such laws apply to the Work.
9. Additional agreements
While distributing the Work, You may choose to conclude an additional agreement,
defining obligations or services consistent with this Licence. However, if
accepting obligations, You may act only on your own behalf and on your sole
responsibility, not on behalf of the original Licensor or any other Contributor,
and only if You agree to indemnify, defend, and hold each Contributor harmless
for any liability incurred by, or claims asserted against such Contributor by
the fact You have accepted any warranty or additional liability.
10. Acceptance of the Licence
The provisions of this Licence can be accepted by clicking on an icon I agree
placed under the bottom of a window displaying the text of this Licence or by
affirming consent in any other similar way, in accordance with the rules of
applicable law. Clicking on that icon indicates your clear and irrevocable
acceptance of this Licence and all of its terms and conditions.
Similarly, you irrevocably accept this Licence and all of its terms and
conditions by exercising any rights granted to You by Article 2 of this Licence,
such as the use of the Work, the creation by You of a Derivative Work or the
Distribution or Communication by You of the Work or copies thereof.
11. Information to the public
In case of any Distribution or Communication of the Work by means of electronic
communication by You (for example, by offering to download the Work from a
remote location) the distribution channel or media (for example, a website) must
at least provide to the public the information requested by the applicable law
regarding the Licensor, the Licence and the way it may be accessible, concluded,
stored and reproduced by the Licensee.
12. Termination of the Licence
The Licence and the rights granted hereunder will terminate automatically upon
any breach by the Licensee of the terms of the Licence.
Such a termination will not terminate the licences of any person who has
received the Work from the Licensee under the Licence, provided such persons
remain in full compliance with the Licence.
13. Miscellaneous
Without prejudice of Article 9 above, the Licence represents the complete
agreement between the Parties as to the Work.
If any provision of the Licence is invalid or unenforceable under applicable
law, this will not affect the validity or enforceability of the Licence as a
whole. Such provision will be construed or reformed so as necessary to make it
valid and enforceable.
The European Commission may publish other linguistic versions or new versions of
this Licence or updated versions of the Appendix, so far this is required and
reasonable, without reducing the scope of the rights granted by the Licence. New
versions of the Licence will be published with a unique version number.
All linguistic versions of this Licence, approved by the European Commission,
have identical value. Parties can take advantage of the linguistic version of
their choice.
14. Jurisdiction
Without prejudice to specific agreement between parties,
- any litigation resulting from the interpretation of this License, arising
between the European Union institutions, bodies, offices or agencies, as a
Licensor, and any Licensee, will be subject to the jurisdiction of the Court
of Justice of the European Union, as laid down in article 272 of the Treaty on
the Functioning of the European Union,
- any litigation arising between other parties and resulting from the
interpretation of this License, will be subject to the exclusive jurisdiction
of the competent court where the Licensor resides or conducts its primary
business.
15. Applicable Law
Without prejudice to specific agreement between parties,
- this Licence shall be governed by the law of the European Union Member State
where the Licensor has his seat, resides or has his registered office,
- this licence shall be governed by Belgian law if the Licensor has no seat,
residence or registered office inside a European Union Member State.
Appendix
Compatible Licences according to Article 5 EUPL are:
- GNU General Public License (GPL) v. 2, v. 3
- GNU Affero General Public License (AGPL) v. 3
- Open Software License (OSL) v. 2.1, v. 3.0
- Eclipse Public License (EPL) v. 1.0
- CeCILL v. 2.0, v. 2.1
- Mozilla Public Licence (MPL) v. 2
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
works other than software
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
Reciprocity (LiLiQ-R+).
The European Commission may update this Appendix to later versions of the above
licences without producing a new version of the EUPL, as long as they provide
the rights granted in Article 2 of this Licence and protect the covered Source
Code from exclusive appropriation.
All other changes or additions to this Appendix require the production of a new
EUPL version.

View File

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

View File

@@ -2,7 +2,7 @@
secret_key = "/NtVGizglEoyoxBLzsRDWTy4oAG1qDw4J4O+CWJSv+fypD7W9sam8hUY4j90EZsbZk8wEradS5zBoWtWKi3k8w=="
rss_key = "rss-key-for-ci"
limits = { file = "10 MiB", data-form = "10 MiB"}
smtp_pw = "8kIjlLH79Ky6D3j"
smtp_pw = "my-smtp-password"
usage_log_path = "./usage.txt"
openweathermap_key = "c8dab8f91b5b815d76e9879cbaecd8d5"
openweathermap_key = "openweather-key"
wordpress_key = "pw-to-allow-sending-notifications"

6
demo_db.sh Executable file
View File

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

94
doc/nextcloud-notes.md Normal file
View File

@@ -0,0 +1,94 @@
# Nextcloud integration
- Based on [this plugin](https://github.com/nextcloud/user_external)
- Install that plugin via web
- Connect to server, enter nextcloud-docker-image: `docker exec -it nextcloud-aio-nextcloud bash`
- Adapt `/var/www/html/custom_apps/user_external/lib/BasicAuth.php` to switch from BasicAuth to RowtAuth:
```php
<?php
/**
* Copyright (c) 2019 Lutz Freitag <lutz.freitag@gottliebtfreitag.de>
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/
namespace OCA\UserExternal;
class BasicAuth extends Base {
private $authUrl;
public function __construct($authUrl) {
parent::__construct($authUrl);
$this->authUrl = $authUrl;
}
/**
* Check if the password is correct without logging in the user
*
* @param string $uid The username
* @param string $password The password
*
* @return true/false
*/
public function checkPassword($uid, $password) {
// Prepare POST data with credentials
$postData = http_build_query([
'name' => $uid,
'password' => $password
]);
// Create context with POST method
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/x-www-form-urlencoded',
'content' => $postData,
'follow_location' => 0
]
]);
// Get the content of the response
$content = @file_get_contents($this->authUrl, false, $context);
if ($content === false) {
\OC::$server->getLogger()->error(
'ERROR: Failed to get content from Auth Url: '.$this->authUrl,
['app' => 'user_external']
);
return false;
}
// Check if the content is "SUCC"
if (trim($content) === "SUCC") {
$this->storeUser($uid);
return $uid;
}
return false;
}
}
```
- In `/var/www/html/config/config.php` add this:
```
'user_backends' =>
array (
0 =>
array (
'class' => '\\OCA\\UserExternal\\BasicAuth',
'arguments' =>
array (
0 => 'https://app.rudernlinz.at/nxauth',
),
),
),
```
- In `/var/www/html/config/config.php` add this `'skeletondirectory' => '',` to disable default folders for new users
- To automatically add users to a group (e.g. `vorstand`), use the `Auto Groups` plugin
- Shared folders are not shared with new members due to [this bug](https://github.com/nextcloud/server/issues/25062#issuecomment-766445043)
- Find DB config: `docker exec nextcloud-aio-database env | grep POSTGRES`
- Workaround: Connect to docker-db: `docker exec -it nextcloud-aio-database bash`
- Connect to db: `psql -U nextcloud -d nextcloud_database`
- (with `\l` you see all dbs)
- Connect to nextcloud db: `\c nextcloud_database`
- Do query from issue: `UPDATE oc_share SET accepted = 1 WHERE share_type = 1;`

2
fd
View File

@@ -1,5 +1,5 @@
#!/bin/bash
scp root@128.140.64.118:/home/rowing/db.sqlite db.sqlite
scp root@app.rudernlinz.at:/root/rowing-prod/db.sqlite db.sqlite
#sqlite3 db.sqlite < seeds.sql

View File

@@ -24,6 +24,7 @@ document.addEventListener("DOMContentLoaded", function () {
reloadPage();
setCurrentdate(<HTMLInputElement>document.querySelector("#departure"));
initDropdown();
editReadOnlyField();
});
function changeTheme() {
@@ -40,6 +41,30 @@ function changeTheme() {
}
}
function editReadOnlyField() {
const editBtns = document.querySelectorAll(
'.edit-js'
);
if (editBtns) {
Array.prototype.forEach.call(editBtns, (btn: HTMLButtonElement) => {
btn.addEventListener("click", function () {
let wrapper = btn.parentElement;
let input = <HTMLInputElement> wrapper?.querySelector('input.input'),
select = <HTMLSelectElement> wrapper?.querySelector('select.input'),
attribute = 'readonly';
if(select) attribute = 'disabled';
let element = input ? input : select;
element?.toggleAttribute(attribute);
if(!element?.hasAttribute(attribute)) element?.focus();
wrapper?.classList.toggle('editable');
});
});
}
}
/***
* init javascript
* 1) detect native color scheme or use set theme in local storage
@@ -388,6 +413,7 @@ function initNewChoice(select: HTMLInputElement) {
steering_person.setAttribute("required", "required");
}
const choice = new Choices(select, {
searchResultLimit: -1,
searchFields: ["label", "value", "customProperties.searchableText"],
removeItemButton: true,
loadingText: "Wird geladen...",
@@ -400,6 +426,7 @@ function initNewChoice(select: HTMLInputElement) {
return `Nur ${maxItemCount} Ruderer können hinzugefügt werden`;
},
callbackOnInit: function () {
console.log(this);
this._currentState.items.forEach(function (obj) {
if (boat_in_ottensheim && obj.customProperties) {
if (obj.customProperties.is_racing) {

View File

@@ -21,7 +21,7 @@
"vite-plugin-static-copy": "^0.13.1"
},
"dependencies": {
"choices.js": "^10.2.0",
"choices.js": "^11.1.0",
"d3": "^7.8.5",
"terser": "^5.21.0"
}

View File

@@ -28,4 +28,8 @@
&[aria-pressed='true'] {
@apply outline outline-2 outline-offset-2 outline-primary-600 bg-primary-100 text-primary-950;
}
&-hidden {
@apply hidden;
}
}

View File

@@ -5,3 +5,7 @@
.h2 {
@apply font-bold uppercase tracking-wide text-center rounded-t-md text-primary-950 dark:text-white bg-gray-200 dark:bg-primary-950 bg-opacity-80 text-lg px-3 py-3;
}
.h3 {
@apply text-center text-xl uppercase tracking-wide font-bold text-primary-900 dark:text-white;
}

View File

@@ -2,3 +2,12 @@
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
}
.rounded-l-none-important {
border-bottom-left-radius: 0px !important;
border-top-left-radius: 0px !important;
}
.rounded-none-important {
border-radius: 0px !important;
}

View File

@@ -2,6 +2,26 @@
@apply relative block w-full bg-white dark:bg-black border-0 py-1.5 px-2 text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-black placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-primary-600 sm:text-sm sm:leading-6;
}
.input-group {
@apply flex;
input[readonly],
select[disabled] {
opacity: .7;
}
&.editable {
input[type="reset"],
input[type="submit"] {
@apply block;
}
button[type="button"] {
@apply hidden;
}
}
}
select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;

View File

@@ -10,4 +10,12 @@
&-white {
@apply text-white hover:text-primary-100 underline;
}
&-black {
@apply text-black hover:text-primary-950 dark:text-white hover:dark:text-primary-300 underline;
}
&-no-underline {
@apply no-underline;
}
}

View File

@@ -1,5 +1,4 @@
import { test, expect, } from "@playwright/test";
import type { Page } from "@playwright/test";
import { test, expect } from "@playwright/test";
test("cox can create and delete trip", async ({ page }) => {
await page.goto("/auth");
@@ -8,6 +7,7 @@ test("cox can create and delete trip", async ({ page }) => {
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("cox");
await page.getByPlaceholder("Passwort").press("Enter");
await page.locator('li').filter({ hasText: 'Geplante Ausfahrten' }).getByRole('link').click();
await page.locator('a[href="#"]:has-text("Ausfahrt")').first().click();
await page.locator("#sidebar #planned_starting_time").click();
await page.locator("#sidebar #planned_starting_time").fill("18:00");
@@ -17,7 +17,7 @@ test("cox can create and delete trip", async ({ page }) => {
await page.getByRole("button", { name: "Erstellen", exact: true }).click();
await expect(page.locator("body")).toContainText("18:00 Uhr (cox) Details");
await page.goto("/");
await page.goto("/planned");
await page.getByRole('link', { name: 'Details' }).nth(1).click();
await page.getByRole("link", { name: "Termin löschen" }).click();
await expect(page.locator("body")).toContainText("Erfolgreich gelöscht!");
@@ -38,6 +38,7 @@ test.describe("cox can edit trips", () => {
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("cox");
await page.getByPlaceholder("Passwort").press("Enter");
await page.locator('li').filter({ hasText: 'Geplante Ausfahrten' }).getByRole('link').click();
await page.locator('a[href="#"]:has-text("Ausfahrt")').first().click();
await page.locator("#sidebar #planned_starting_time").click();
await page.locator("#sidebar #planned_starting_time").fill("18:00");
@@ -50,7 +51,7 @@ test.describe("cox can edit trips", () => {
});
test("edit remarks", async () => {
await sharedPage.goto("/");
await sharedPage.goto("/planned");
await sharedPage.getByRole('link', { name: 'Details' }).nth(1).click();
await sharedPage.locator("#sidebar #notes").click();
await sharedPage.locator("#sidebar #notes").fill("Meine Anmerkung");
@@ -66,7 +67,7 @@ test.describe("cox can edit trips", () => {
});
test("add and remove guest", async () => {
await sharedPage.goto("/");
await sharedPage.goto("/planned");
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await sharedPage.locator("#sidebar #user_note").click();
await sharedPage.locator("#sidebar #user_note").fill("Mein Gast");
@@ -106,7 +107,7 @@ test.describe("cox can edit trips", () => {
});
test("change amount rower", async () => {
await sharedPage.goto("/");
await sharedPage.goto("/planned");
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
"Freie Plätze: 5",
@@ -120,22 +121,73 @@ test.describe("cox can edit trips", () => {
});
test("call off trip", async () => {
await sharedPage.goto("/");
// Someone registers...
await sharedPage.goto("/auth/logout");
await sharedPage.goto("/auth");
await sharedPage.getByPlaceholder("Name").click();
await sharedPage.getByPlaceholder("Name").fill("rower");
await sharedPage.getByPlaceholder("Name").press("Tab");
await sharedPage.getByPlaceholder("Passwort").fill("rower");
await sharedPage.getByPlaceholder("Passwort").press("Enter");
await sharedPage.goto("/planned");
await sharedPage.getByRole('link', { name: 'Mitrudern' }).nth(1).click();
// Login as cox again
await sharedPage.goto("/auth/logout");
await sharedPage.goto("/auth");
await sharedPage.getByPlaceholder("Name").click();
await sharedPage.getByPlaceholder("Name").fill("cox");
await sharedPage.getByPlaceholder("Name").press("Tab");
await sharedPage.getByPlaceholder("Passwort").fill("cox");
await sharedPage.getByPlaceholder("Passwort").press("Enter");
await sharedPage.goto("/planned");
// ... now I can cancel trip
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
"Freie Plätze: 3",
);
await sharedPage.getByRole("spinbutton").click();
await sharedPage.getByRole("spinbutton").fill("0");
await sharedPage.getByRole("button", { name: "Speichern" }).click();
await sharedPage.getByRole("button", { name: "Ausfahrt absagen" }).click();
await expect(sharedPage.locator("body")).toContainText(
"Ausfahrt erfolgreich aktualisiert.",
);
await expect(sharedPage.locator("body")).toContainText("(Absage cox)");
// Done with the test -> cancel the cancellation of the trip, otherwise the afterAll function below fails
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await sharedPage.getByRole("spinbutton").click();
await sharedPage.getByRole("spinbutton").fill("3");
await sharedPage.getByRole("button", { name: "Speichern" }).click();
// deregistering
await sharedPage.goto("/auth/logout");
await sharedPage.goto("/auth");
await sharedPage.getByPlaceholder("Name").click();
await sharedPage.getByPlaceholder("Name").fill("rower");
await sharedPage.getByPlaceholder("Name").press("Tab");
await sharedPage.getByPlaceholder("Passwort").fill("rower");
await sharedPage.getByPlaceholder("Passwort").press("Enter");
await sharedPage.goto("/planned");
await sharedPage.getByRole('link', { name: 'Abmelden' }).click();
// now cox can delete trip again in afterAll
await sharedPage.goto("/auth/logout");
await sharedPage.goto("/auth");
await sharedPage.getByPlaceholder("Name").click();
await sharedPage.getByPlaceholder("Name").fill("cox");
await sharedPage.getByPlaceholder("Name").press("Tab");
await sharedPage.getByPlaceholder("Passwort").fill("cox");
await sharedPage.getByPlaceholder("Passwort").press("Enter");
});
test.afterAll(async () => {
await sharedPage.goto("/");
await sharedPage.goto("/planned");
await sharedPage.getByRole('link', { name: 'Details' }).nth(1).click();
await sharedPage.getByRole("link", { name: "Termin löschen" }).click();
await sharedPage.close();

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

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

View File

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

View File

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

17
rotstaging.service Normal file
View File

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

View File

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

104
seeds_demo.sql Normal file
View File

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

View File

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

View File

@@ -1,5 +1,7 @@
#![allow(clippy::blocks_in_conditions)]
use std::ops::Deref;
pub mod model;
#[cfg(feature = "rowing-tera")]
@@ -11,6 +13,87 @@ pub mod rest;
pub mod scheduled;
pub(crate) const AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD: i64 = 10;
pub(crate) const RENNRUDERBEITRAG: i64 = 11000;
pub(crate) const BOAT_STORAGE: i64 = 4500;
pub(crate) const FAMILY_TWO: i64 = 30000;
pub(crate) const FAMILY_THREE_OR_MORE: i64 = 35000;
pub(crate) const STUDENT_OR_PUPIL: i64 = 8000;
pub(crate) const REGULAR: i64 = 22000;
pub(crate) const UNTERSTUETZEND: i64 = 2500;
pub(crate) const FOERDERND: i64 = 8500;
pub(crate) const SCHECKBUCH: i64 = 3000;
pub(crate) const EINSCHREIBGEBUEHR: i64 = 3000;
pub(crate) const DUAL_MEMBERSHIP: i64 = 18000;
pub(crate) const TRIAL_ROWING: i64 = 12000;
pub(crate) const TRIAL_ROWING_REDUCED: i64 = 6000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NonEmptyString(String);
impl NonEmptyString {
pub fn new(s: String) -> Option<Self> {
if s.is_empty() {
None
} else {
Some(NonEmptyString(s))
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
// Implement Deref to allow automatic dereferencing to &str
impl Deref for NonEmptyString {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
// This allows &NonEmptyString to be converted to &str
impl AsRef<str> for NonEmptyString {
fn as_ref(&self) -> &str {
&self.0
}
}
// This allows NonEmptyString to be converted to String with .into()
impl From<NonEmptyString> for String {
fn from(s: NonEmptyString) -> Self {
s.0
}
}
impl TryFrom<&str> for NonEmptyString {
type Error = &'static str;
fn try_from(s: &str) -> Result<Self, Self::Error> {
if s.is_empty() {
Err("String cannot be empty")
} else {
Ok(NonEmptyString(s.to_string()))
}
}
}
impl TryFrom<String> for NonEmptyString {
type Error = &'static str;
fn try_from(s: String) -> Result<Self, Self::Error> {
if s.is_empty() {
Err("String cannot be empty")
} else {
Ok(NonEmptyString(s))
}
}
}
#[cfg(test)]
#[macro_export]

View File

@@ -8,7 +8,7 @@ use rot::rest;
use rot::tera;
use rot::{scheduled, tera::Config};
use sqlx::{pool::PoolOptions, sqlite::SqliteConnectOptions, ConnectOptions};
use sqlx::{ConnectOptions, pool::PoolOptions, sqlite::SqliteConnectOptions};
#[macro_use]
extern crate rocket;

270
src/model/activity.rs Normal file
View File

@@ -0,0 +1,270 @@
use std::ops::DerefMut;
use super::{
logbook::{Logbook, LogbookWithBoatAndRowers},
role::Role,
user::{ManageUserUser, User},
};
use chrono::{DateTime, Duration, Local, NaiveDateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
#[derive(FromRow, Debug, Serialize, Deserialize, Clone)]
pub struct Activity {
pub id: i64,
pub created_at: NaiveDateTime,
pub text: String,
pub relevant_for: String,
pub keep_until: Option<NaiveDateTime>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ActivityWithDetails {
#[serde(flatten)]
pub(crate) activity: Activity,
keep_until_days: Option<i64>,
}
impl From<Activity> for ActivityWithDetails {
fn from(activity: Activity) -> Self {
let keep_until_days = activity.keep_until.map(|keep_until| {
let now = Utc::now().naive_utc();
let duration = keep_until.signed_duration_since(now);
duration.num_days()
});
Self {
keep_until_days,
activity,
}
}
}
// TODO: add `reason` as additional db field, to be able to query and show this to the users
pub enum Reason<'a> {
Auth(ReasonAuth<'a>),
Logbook(ReasonLogbook<'a>),
// `User` changed the data of `User`, explanation in `String`
UserDataChange(&'a ManageUserUser, &'a User, String),
// New Note for User
NewUserNote(&'a ManageUserUser, &'a User, String),
}
impl From<Reason<'_>> for ActivityBuilder {
fn from(value: Reason<'_>) -> Self {
match value {
Reason::Auth(auth) => auth.into(),
Reason::UserDataChange(changed_by, changed_user, explanation) => Self::new(&format!(
"{changed_by} hat die Daten von {changed_user} aktualisiert: {explanation}"
))
.user(changed_user),
Reason::NewUserNote(changed_by, user, explanation) => {
Self::new(&format!("({changed_by}) {explanation}")).user(user)
}
Reason::Logbook(logbook) => logbook.into(),
}
}
}
pub enum ReasonAuth<'a> {
// `User` tried to login with `String` as UserAgent
SuccLogin(&'a User, String),
// `User` tried to login which was already deleted
DeletedUserLogin(&'a User),
// `User` tried to login, supplied wrong PW
WrongPw(&'a User),
}
impl<'a> From<ReasonAuth<'a>> for Reason<'a> {
fn from(auth_reason: ReasonAuth<'a>) -> Self {
Reason::Auth(auth_reason)
}
}
impl From<ReasonAuth<'_>> for ActivityBuilder {
fn from(value: ReasonAuth<'_>) -> Self {
match value {
ReasonAuth::SuccLogin(user, agent) => {
Self::new(&format!("{user} hat sich eingeloggt (User-Agent: {agent})"))
.user(user)
.keep_until_days(7)
}
ReasonAuth::DeletedUserLogin(user) => Self::new(&format!(
"{user} wollte sich einloggen, klappte jedoch nicht weil der Account gelöscht wurde."
))
.user(user)
.keep_until_days(30),
ReasonAuth::WrongPw(user) => Self::new(&format!(
"User {user} wollte sich einloggen, hat jedoch das falsche Passwort angegeben."
))
.user(user)
.keep_until_days(7),
}
}
}
pub enum ReasonLogbook<'a> {
// `User` tried to login with `String` as UserAgent
BoardOrAdminDeleted(&'a User, &'a LogbookWithBoatAndRowers),
}
impl<'a> From<ReasonLogbook<'a>> for Reason<'a> {
fn from(logbook_reason: ReasonLogbook<'a>) -> Self {
Reason::Logbook(logbook_reason)
}
}
impl From<ReasonLogbook<'_>> for ActivityBuilder {
fn from(value: ReasonLogbook<'_>) -> Self {
match value {
ReasonLogbook::BoardOrAdminDeleted(user, logbook) => Self::new(&format!(
"{user} hat den Logbuch-Eintrag gelöscht: {logbook}"
))
.user(user)
.logbook(&logbook.logbook)
.keep_until_days(7),
}
}
}
pub struct ActivityBuilder {
text: String,
relevant_for: String,
keep_until: Option<NaiveDateTime>,
}
impl ActivityBuilder {
/// TODO: maybe make this private, and only allow specific acitivites defined in `Reason`
#[must_use]
pub fn new(text: &str) -> Self {
Self {
text: text.into(),
relevant_for: String::new(),
keep_until: None,
}
}
#[must_use]
pub fn user(self, user: &User) -> Self {
Self {
relevant_for: format!("{}user-{};", self.relevant_for, user.id),
..self
}
}
#[must_use]
pub fn role(self, role: &Role) -> Self {
Self {
relevant_for: format!("{}role-{};", self.relevant_for, role.id),
..self
}
}
#[must_use]
pub fn logbook(self, logbook: &Logbook) -> Self {
Self {
relevant_for: format!("{}logbook-{};", self.relevant_for, logbook.id),
..self
}
}
#[must_use]
pub fn keep_until_days(self, days: i64) -> Self {
let now = Utc::now().naive_utc();
Self {
keep_until: Some(now + Duration::days(days)),
..self
}
}
pub async fn save(self, db: &SqlitePool) {
Activity::create(db, &self.text, &self.relevant_for, self.keep_until).await;
}
pub async fn save_tx(self, db: &mut Transaction<'_, Sqlite>) {
Activity::create_with_tx(db, &self.text, &self.relevant_for, self.keep_until).await;
}
}
impl Activity {
pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> {
sqlx::query_as!(
Self,
"SELECT id, created_at, text, relevant_for, keep_until FROM activity WHERE id like ?",
id
)
.fetch_one(db)
.await
.ok()
}
pub(super) async fn create_with_tx(
db: &mut Transaction<'_, Sqlite>,
text: &str,
relevant_for: &str,
keep_until: Option<NaiveDateTime>,
) {
sqlx::query!(
"INSERT INTO activity(text, relevant_for, keep_until) VALUES (?, ?, ?)",
text,
relevant_for,
keep_until
)
.execute(db.deref_mut())
.await
.unwrap();
}
pub(super) async fn create(
db: &SqlitePool,
text: &str,
relevant_for: &str,
keep_until: Option<NaiveDateTime>,
) {
let mut tx = db.begin().await.unwrap();
Self::create_with_tx(&mut tx, text, relevant_for, keep_until).await;
tx.commit().await.unwrap();
}
pub async fn for_user(db: &SqlitePool, user: &User) -> Vec<Activity> {
let user_str = format!("user-{};", user.id);
sqlx::query_as!(
Self,
"
SELECT id, created_at, text, relevant_for, keep_until FROM activity
WHERE
relevant_for like CONCAT('%', ?, '%')
ORDER BY created_at DESC;
",
user_str
)
.fetch_all(db)
.await
.unwrap()
}
async fn last(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as!(
Self,
"
SELECT id, created_at, text, relevant_for, keep_until FROM activity
ORDER BY id DESC
LIMIT 1000
"
)
.fetch_all(db)
.await
.unwrap()
}
pub async fn show(db: &SqlitePool) -> String {
let mut ret = String::new();
for log in Self::last(db).await {
let utc_time: DateTime<Utc> = Utc::from_utc_datetime(&Utc, &log.created_at);
let local_time = utc_time.with_timezone(&Local);
ret.push_str(&format!("- {local_time}: {}\n", log.text));
}
ret
}
}

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

View File

@@ -1,74 +1,16 @@
use std::ops::DerefMut;
use super::activity::ActivityBuilder;
use sqlx::{Sqlite, SqlitePool, Transaction};
use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
#[derive(FromRow, Debug, Serialize, Deserialize)]
pub struct Log {
pub msg: String,
pub created_at: NaiveDateTime,
}
pub struct Log {}
// TODO: remove and convert to proper acitvities
impl Log {
pub async fn create(db: &SqlitePool, msg: String) -> bool {
sqlx::query!("INSERT INTO log(msg) VALUES (?)", msg,)
.execute(db)
.await
.is_ok()
ActivityBuilder::new(&msg).save(db).await;
true
}
pub async fn create_with_tx(db: &mut Transaction<'_, Sqlite>, msg: String) -> bool {
sqlx::query!("INSERT INTO log(msg) VALUES (?)", msg,)
.execute(db.deref_mut())
.await
.is_ok()
}
async fn last(db: &SqlitePool) -> Vec<Log> {
sqlx::query_as!(
Log,
"
SELECT msg, created_at
FROM log
ORDER BY id DESC
LIMIT 1000
"
)
.fetch_all(db)
.await
.unwrap()
}
pub async fn generate_feed(db: &SqlitePool) -> String {
let mut ret = String::from(
r#"<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Ruder App Admin Feed</title>
<link>ruad.at</link>
<description>An RSS feed with activities</description>"#,
);
for log in Self::last(db).await {
let utc_time: DateTime<Utc> = Utc::from_utc_datetime(&Utc, &log.created_at);
let local_time = utc_time.with_timezone(&Local);
ret.push_str("<item><title>");
ret.push_str(&format!("({}) {}", local_time, log.msg));
ret.push_str("</title></item>");
}
ret.push_str("</channel>");
ret.push_str("</rss>");
ret.replace('\n', "")
}
pub async fn show(db: &SqlitePool) -> String {
let mut ret = String::new();
for log in Self::last(db).await {
let utc_time: DateTime<Utc> = Utc::from_utc_datetime(&Utc, &log.created_at);
let local_time = utc_time.with_timezone(&Local);
ret.push_str(&format!("- {} - {}\n", local_time, log.msg));
}
ret
ActivityBuilder::new(&msg).save_tx(db).await;
true
}
}

1331
src/model/logbook.rs Normal file

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

@@ -5,35 +5,48 @@ use waterlevel::WaterlevelDay;
use crate::AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD;
use self::{
event::{Event, EventWithUserAndTriptype},
trip::{Trip, TripWithUserAndType},
waterlevel::Waterlevel,
weather::Weather,
use self::{waterlevel::Waterlevel, weather::Weather};
use boatreservation::{BoatReservation, BoatReservationWithDetails};
use planned::{
event::{Event, EventWithDetails},
trip::{Trip, TripWithDetails},
};
use std::collections::HashMap;
pub mod event;
pub mod activity;
pub mod boat;
pub mod boatdamage;
pub mod boathouse;
pub mod boatreservation;
pub mod distance;
pub mod family;
pub mod location;
pub mod log;
pub mod logbook;
pub mod logtype;
pub mod mail;
pub mod notification;
pub mod personal;
pub mod planned;
pub mod role;
pub mod trip;
pub mod tripdetails;
pub mod triptype;
pub mod rower;
pub mod stat;
pub mod trailer;
pub mod trailerreservation;
pub mod user;
pub mod usertrip;
pub mod waterlevel;
pub mod weather;
#[derive(Serialize, Debug)]
pub struct Day {
day: NaiveDate,
events: Vec<EventWithUserAndTriptype>,
trips: Vec<TripWithUserAndType>,
events: Vec<EventWithDetails>,
trips: Vec<TripWithDetails>,
is_pinned: bool,
regular_sees_this_day: bool,
max_waterlevel: Option<WaterlevelDay>,
weather: Option<Weather>,
boat_reservations: HashMap<String, Vec<BoatReservationWithDetails>>,
}
impl Day {
@@ -50,6 +63,9 @@ impl Day {
regular_sees_this_day,
max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await,
weather: Weather::find_by_day(db, day).await,
boat_reservations: BoatReservation::with_groups(
BoatReservation::for_day(db, day).await,
),
}
} else {
Self {
@@ -60,6 +76,9 @@ impl Day {
regular_sees_this_day,
max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await,
weather: Weather::find_by_day(db, day).await,
boat_reservations: BoatReservation::with_groups(
BoatReservation::for_day(db, day).await,
),
}
}
}

View File

@@ -5,7 +5,7 @@ use regex::Regex;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
use super::{role::Role, user::User, usertrip::UserTrip};
use super::{planned::usertrip::UserTrip, role::Role, user::User};
#[derive(FromRow, Debug, Serialize, Deserialize, Clone)]
pub struct Notification {
@@ -89,20 +89,6 @@ impl Notification {
tx.commit().await.unwrap();
}
pub async fn create_for_all(
db: &SqlitePool,
message: &str,
category: &str,
link: Option<&str>,
action_after_reading: Option<&str>,
) {
let users = User::all(db).await;
for user in users {
Self::create(db, &user, message, category, link, action_after_reading).await;
}
}
pub async fn create_for_steering_people_tx(
db: &mut Transaction<'_, Sqlite>,
message: &str,
@@ -226,19 +212,29 @@ ORDER BY read_at DESC, created_at DESC;
.await
.unwrap();
}
pub(crate) async fn delete_by_link(db: &sqlx::Pool<Sqlite>, link: &str) {
let link = Some(link);
sqlx::query!("DELETE FROM notification WHERE link=?", link)
.execute(db)
.await
.unwrap();
}
}
#[cfg(test)]
mod test {
use crate::{
model::{
event::{Event, EventUpdate, Registration},
notification::Notification,
planned::{
event::{Event, EventUpdate, Registration},
trip::Trip,
tripdetails::{TripDetails, TripDetailsToAdd},
user::{EventUser, SteeringUser, User},
usertrip::UserTrip,
},
user::{EventUser, SteeringUser, User},
},
testdb,
};
@@ -262,7 +258,7 @@ mod test {
let trip_details = TripDetails::find_by_id(&pool, tripdetails_id)
.await
.unwrap();
let user = EventUser::new(&pool, User::find_by_id(&pool, 1).await.unwrap())
let user = EventUser::new(&pool, &User::find_by_id(&pool, 1).await.unwrap())
.await
.unwrap();
Event::create(&pool, &user, "new-event".into(), 2, false, &trip_details).await;
@@ -275,7 +271,7 @@ mod test {
UserTrip::create(&pool, &rower, &trip_details, None)
.await
.unwrap();
let cox = SteeringUser::new(&pool, User::find_by_name(&pool, "cox").await.unwrap())
let cox = SteeringUser::new(&pool, &User::find_by_name(&pool, "cox").await.unwrap())
.await
.unwrap();
Trip::new_join(&pool, &cox, &event).await.unwrap();
@@ -284,13 +280,13 @@ mod test {
let cancel_update = EventUpdate {
name: &event.name,
planned_amount_cox: event.planned_amount_cox as i32,
max_people: 0,
max_people: -1,
notes: event.notes.as_deref(),
always_show: event.always_show,
is_locked: event.is_locked,
trip_type_id: None,
};
event.update(&pool, &cancel_update).await;
event.update(&pool, &user, &cancel_update).await;
// Rower received notification
let notifications = Notification::for_user(&pool, &rower).await;
@@ -320,12 +316,12 @@ mod test {
is_locked: event.is_locked,
trip_type_id: None,
};
event.update(&pool, &update).await;
event.update(&pool, &user, &update).await;
assert!(Notification::for_user(&pool, &rower).await.is_empty());
assert!(Notification::for_user(&pool, &cox.user).await.is_empty());
// Cancel event again
event.update(&pool, &cancel_update).await;
event.update(&pool, &user, &cancel_update).await;
// Rower is removed if notification is accepted
assert!(event.is_rower_registered(&pool, &rower).await);

View File

@@ -1,13 +1,24 @@
use std::io::Write;
use ics::{components::Property, ICalendar};
use ics::{
ICalendar,
components::Property,
properties::{DtEnd, DtStart, Summary},
};
use sqlx::SqlitePool;
use crate::model::{event::Event, trip::Trip, user::User};
use crate::model::{
planned::{event::Event, trip::Trip},
user::User,
};
use chrono::{Duration, NaiveTime};
pub(crate) async fn get_personal_cal(db: &SqlitePool, user: &User) -> String {
let mut calendar = ICalendar::new("2.0", "ics-rs");
calendar.push(Property::new("X-WR-CALNAME", "ruad.at - Deine Ausfahrten"));
calendar.push(Property::new(
"X-WR-CALNAME",
"Donau Linz - Deine Ausfahrten",
));
let events = Event::all_with_user(db, user).await;
for event in events {
@@ -16,9 +27,131 @@ pub(crate) async fn get_personal_cal(db: &SqlitePool, user: &User) -> String {
let trips = Trip::all_with_user(db, user).await;
for trip in trips {
calendar.add_event(trip.get_vevent(user).await);
calendar.add_event(trip.get_vevent(db, user).await);
}
let mut buf = Vec::new();
write!(&mut buf, "{}", calendar).unwrap();
String::from_utf8(buf).unwrap()
}
impl Trip {
pub(crate) async fn get_vevent<'a>(self, db: &'a SqlitePool, user: &'a User) -> ics::Event<'a> {
let mut vevent =
ics::Event::new(format!("trip-{}@rudernlinz.at", self.id), "19900101T180000");
let time_str = self.planned_starting_time.replace(':', "");
let formatted_time = if time_str.len() == 3 {
format!("0{}", time_str)
} else {
time_str
};
vevent.push(DtStart::new(format!(
"{}T{}00",
self.day.replace('-', ""),
formatted_time
)));
let original_time = NaiveTime::parse_from_str(&self.planned_starting_time, "%H:%M")
.expect("Failed to parse time");
let long_trip = match self.trip_type(db).await {
Some(a) if a.name == "Lange Ausfahrt" => true,
_ => false,
};
let later_time = if long_trip {
original_time + Duration::hours(6)
} else {
original_time + Duration::hours(3)
};
if later_time > original_time {
// Check if no day-overflow
let time_three_hours_later = later_time.format("%H%M").to_string();
vevent.push(DtEnd::new(format!(
"{}T{}00",
self.day.replace('-', ""),
time_three_hours_later
)));
}
let mut name = String::new();
if self.is_cancelled() {
name.push_str("ABGESAGT");
if let Some(notes) = &self.notes {
if !notes.is_empty() {
name.push_str(&format!(" (Grund: {notes})"))
}
}
name.push_str("! :-( ");
}
if self.cox_id == user.id {
name.push_str("Ruderausfahrt (selber ausgeschrieben)");
} else {
name.push_str(&format!("Ruderausfahrt mit {} ", self.cox_name));
}
vevent.push(Summary::new(name));
vevent
}
}
impl Event {
pub(crate) async fn get_vevent(self, db: &SqlitePool) -> ics::Event {
let mut vevent = ics::Event::new(
format!("event-{}@rudernlinz.at", self.id),
"19900101T180000",
);
let time_str = self.planned_starting_time.replace(':', "");
let formatted_time = if time_str.len() == 3 {
format!("0{}", time_str)
} else {
time_str.clone() // TODO: remove again
};
vevent.push(DtStart::new(format!(
"{}T{}00",
self.day.replace('-', ""),
formatted_time
)));
let original_time = NaiveTime::parse_from_str(&self.planned_starting_time, "%H:%M")
.expect("Failed to parse time");
let long_trip = match self.trip_type(db).await {
Some(a) if a.name == "Lange Ausfahrt" => true,
_ => false,
};
let later_time = if long_trip {
original_time + Duration::hours(6)
} else {
original_time + Duration::hours(3)
};
if later_time > original_time {
// Check if no day-overflow
let time_three_hours_later = later_time.format("%H%M").to_string();
vevent.push(DtEnd::new(format!(
"{}T{}00",
self.day.replace('-', ""),
time_three_hours_later
)));
}
let tripdetails = self.trip_details(db).await;
let mut name = String::new();
if self.is_cancelled() {
name.push_str("ABGESAGT");
if let Some(notes) = &tripdetails.notes {
if !notes.is_empty() {
name.push_str(&format!(" (Grund: {notes})"))
}
}
name.push_str("! :-( ");
}
name.push_str(&format!("{} ", self.name));
if let Some(triptype) = tripdetails.triptype(db).await {
name.push_str(&format!("{} ", triptype.name))
}
vevent.push(Summary::new(name));
vevent
}
}

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,19 @@
use std::io::Write;
use chrono::{Duration, NaiveDate, NaiveTime};
use ics::{
properties::{DtEnd, DtStart, Summary},
ICalendar,
};
use chrono::NaiveDate;
use ics::ICalendar;
use serde::Serialize;
use sqlx::{FromRow, Row, SqlitePool};
use super::{
use super::{tripdetails::TripDetails, triptype::TripType};
use crate::model::{
log::Log,
notification::Notification,
role::Role,
tripdetails::TripDetails,
triptype::TripType,
user::{EventUser, User},
};
/// DB structure of an event
#[derive(Serialize, Clone, FromRow, Debug, PartialEq)]
pub struct Event {
pub id: i64,
@@ -34,11 +31,13 @@ pub struct Event {
}
#[derive(Serialize, Debug)]
pub struct EventWithUserAndTriptype {
pub struct EventWithDetails {
#[serde(flatten)]
pub event: Event,
trip_type: Option<TripType>,
tripdetails: TripDetails,
cox_needed: bool,
cancelled: bool,
cox: Vec<Registration>,
rower: Vec<Registration>,
}
@@ -116,6 +115,12 @@ pub struct EventUpdate<'a> {
pub trip_type_id: Option<i64>,
}
impl EventUpdate<'_> {
fn cancelled(&self) -> bool {
self.max_people == -1
}
}
impl Event {
pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> {
sqlx::query_as!(
@@ -134,16 +139,21 @@ WHERE planned_event.id like ?
.ok()
}
pub async fn get_pinned_for_day(
db: &SqlitePool,
day: NaiveDate,
) -> Vec<EventWithUserAndTriptype> {
pub(crate) async fn trip_type(&self, db: &SqlitePool) -> Option<TripType> {
if let Some(trip_type_id) = self.trip_type_id {
TripType::find_by_id(db, trip_type_id).await
} else {
None
}
}
pub async fn get_pinned_for_day(db: &SqlitePool, day: NaiveDate) -> Vec<EventWithDetails> {
let mut events = Self::get_for_day(db, day).await;
events.retain(|e| e.event.always_show);
events
}
pub async fn get_for_day(db: &SqlitePool, day: NaiveDate) -> Vec<EventWithUserAndTriptype> {
pub async fn get_for_day(db: &SqlitePool, day: NaiveDate) -> Vec<EventWithDetails> {
let day = format!("{day}");
let events = sqlx::query_as!(
Event,
@@ -164,10 +174,15 @@ WHERE day=?",
if let Some(trip_type_id) = event.trip_type_id {
trip_type = TripType::find_by_id(db, trip_type_id).await;
}
ret.push(EventWithUserAndTriptype {
let tripdetails = TripDetails::find_by_id(db, event.trip_details_id)
.await
.expect("db constraints");
ret.push(EventWithDetails {
cox_needed: event.planned_amount_cox > cox.len() as i64,
cox,
rower: Registration::all_rower(db, event.trip_details_id).await,
cancelled: tripdetails.cancelled(),
tripdetails,
event,
trip_type,
});
@@ -248,8 +263,10 @@ WHERE trip_details.id=?
}
async fn advertise(db: &SqlitePool, day: &str, planned_starting_time: &str, name: &str) {
Notification::create_for_all(
let donau = Role::find_by_name(db, "Donau Linz").await.unwrap();
Notification::create_for_role(
db,
&donau,
&format!("Am {} um {} wurde ein neues Event angelegt: {} Wir freuen uns wenn du dabei mitmachst, die Anmeldung ist ab sofort offen :-)", day, planned_starting_time, name),
"Neues Event",
Some(&format!("/planned#{day}")),
@@ -301,7 +318,7 @@ WHERE trip_details.id=?
}
//TODO: create unit test
pub async fn update(&self, db: &SqlitePool, update: &EventUpdate<'_>) {
pub async fn update(&self, db: &SqlitePool, user: &EventUser, update: &EventUpdate<'_>) {
sqlx::query!(
"UPDATE planned_event SET name = ?, planned_amount_cox = ? WHERE id = ?",
update.name,
@@ -313,7 +330,7 @@ WHERE trip_details.id=?
.unwrap(); //Okay, as planned_event can only be created with proper DB backing
let tripdetails = self.trip_details(db).await;
let was_already_cancelled = tripdetails.max_people == 0;
let was_already_cancelled = tripdetails.cancelled();
sqlx::query!(
"UPDATE trip_details SET max_people = ?, notes = ?, always_show = ?, is_locked = ?, trip_type_id = ? WHERE id = ?",
@@ -328,6 +345,20 @@ WHERE trip_details.id=?
.await
.unwrap(); //Okay, as planned_event can only be created with proper DB backing
Log::create(
db,
format!(
"{} updated the event {} on {} at {} from {:?} to {:?}",
user.user.name,
self.name,
tripdetails.day,
tripdetails.planned_starting_time,
self,
update
),
)
.await;
if !tripdetails.always_show && update.always_show {
Self::advertise(
db,
@@ -338,7 +369,7 @@ WHERE trip_details.id=?
.await;
}
if update.max_people == 0 && !was_already_cancelled {
if update.cancelled() && !was_already_cancelled {
let coxes = Registration::all_cox(db, self.id).await;
for user in coxes {
if let Some(user) = User::find_by_name(db, &user.name).await {
@@ -387,7 +418,7 @@ WHERE trip_details.id=?
}
}
}
if update.max_people > 0 && was_already_cancelled {
if !update.cancelled() && was_already_cancelled {
Notification::delete_by_action(
db,
&format!("remove_user_trip_with_trip_details_id:{}", tripdetails.id),
@@ -425,7 +456,7 @@ WHERE trip_details.id=?
}
pub fn is_cancelled(&self) -> bool {
self.max_people == 0
self.max_people == -1
}
pub async fn get_ics_feed(db: &SqlitePool) -> String {
@@ -440,48 +471,6 @@ WHERE trip_details.id=?
String::from_utf8(buf).unwrap()
}
pub(crate) async fn get_vevent(self, db: &SqlitePool) -> ics::Event {
let mut vevent = ics::Event::new(format!("event-{}@ruad.at", self.id), "19900101T180000");
vevent.push(DtStart::new(format!(
"{}T{}00",
self.day.replace('-', ""),
self.planned_starting_time.replace(':', "")
)));
let original_time = NaiveTime::parse_from_str(&self.planned_starting_time, "%H:%M")
.expect("Failed to parse time");
let later_time = original_time + Duration::hours(3);
if later_time > original_time {
// Check if no day-overflow
let time_three_hours_later = later_time.format("%H%M").to_string();
vevent.push(DtEnd::new(format!(
"{}T{}00",
self.day.replace('-', ""),
time_three_hours_later
)));
}
let tripdetails = self.trip_details(db).await;
let mut name = String::new();
if self.is_cancelled() {
name.push_str("ABGESAGT");
if let Some(notes) = &tripdetails.notes {
if !notes.is_empty() {
name.push_str(&format!(" (Grund: {notes})"))
}
}
name.push_str("! :-( ");
}
name.push_str(&format!("{} ", self.name));
if let Some(triptype) = tripdetails.triptype(db).await {
name.push_str(&format!("{} ", triptype.name))
}
vevent.push(Summary::new(name));
vevent
}
pub async fn trip_details(&self, db: &SqlitePool) -> TripDetails {
TripDetails::find_by_id(db, self.trip_details_id)
.await
@@ -493,7 +482,7 @@ WHERE trip_details.id=?
mod test {
use crate::{
model::{
tripdetails::TripDetails,
planned::tripdetails::TripDetails,
user::{EventUser, User},
},
testdb,
@@ -517,7 +506,7 @@ mod test {
let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap();
let admin = EventUser::new(&pool, User::find_by_id(&pool, 1).await.unwrap())
let admin = EventUser::new(&pool, &User::find_by_id(&pool, 1).await.unwrap())
.await
.unwrap();
Event::create(&pool, &admin, "new-event".into(), 2, false, &trip_details).await;
@@ -543,6 +532,11 @@ mod test {
let today = Local::now().date_naive().format("%Y%m%d").to_string();
let actual = Event::get_ics_feed(&pool).await;
assert_eq!(format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:ics-rs\r\nBEGIN:VEVENT\r\nUID:event-1@ruad.at\r\nDTSTAMP:19900101T180000\r\nDTSTART:{today}T100000\r\nDTEND:{today}T130000\r\nSUMMARY:test-planned-event \r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"), actual);
assert_eq!(
format!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:ics-rs\r\nBEGIN:VEVENT\r\nUID:event-1@rudernlinz.at\r\nDTSTAMP:19900101T180000\r\nDTSTART:{today}T100000\r\nDTEND:{today}T130000\r\nSUMMARY:test-planned-event \r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
),
actual
);
}
}

19
src/model/planned/mod.rs Normal file
View File

@@ -0,0 +1,19 @@
//! This module contains everything for managing planned trips and events.
//! `Cox` can create trips, `EventUser` can create events. Rowers can join those.
/// Events can be created by everyone who has the `manage_events` role. They are used if multiple coxes are needed, e.g. for "Fetzenfahrt", "Anrudern", .... Additionally, events are shown in public calendar (e.g. on the website).
pub mod event;
/// Trips can be created by every cox. They are "simple", every-day trips.
pub mod trip;
/// Extracts the common data for both Trips and Events. Rower can register using this.
pub mod tripdetails;
/// Type of the trip
pub mod triptype;
/// Associative table between `User` and `TripDetails`. Its functionality should probably move into
/// those files.
// TODO: make this mod unnecessary
pub mod usertrip;

View File

@@ -0,0 +1,79 @@
use super::Trip;
use crate::model::{
log::Log,
notification::Notification,
planned::{tripdetails::TripDetails, triptype::TripType},
user::{ErgoUser, SteeringUser, User},
};
use sqlx::SqlitePool;
impl Trip {
/// Cox decides to create own trip.
pub async fn new_own(db: &SqlitePool, cox: &SteeringUser, trip_details: TripDetails) {
Self::perform_new(db, &cox.user, trip_details).await
}
/// ErgoUser decides to create ergo 'trip'. Returns false, if trip is not a ergo-session (and
/// thus User is not allowed to create such a trip)
pub async fn new_own_ergo(db: &SqlitePool, ergo: &ErgoUser, trip_details: TripDetails) -> bool {
if let Some(typ) = trip_details.triptype(db).await {
let allowed_type = TripType::find_by_id(db, 4).await.unwrap();
if typ == allowed_type {
Self::perform_new(db, &ergo.user, trip_details).await;
return true;
}
}
false
}
async fn perform_new(db: &SqlitePool, user: &User, trip_details: TripDetails) {
let _ = sqlx::query!(
"INSERT INTO trip (cox_id, trip_details_id) VALUES(?, ?)",
user.id,
trip_details.id
)
.execute(db)
.await;
Log::create(db, format!("{user} created a new trip: {trip_details}")).await;
Self::notify_trips_same_datetime(db, trip_details, user).await;
}
async fn notify_trips_same_datetime(db: &SqlitePool, trip_details: TripDetails, user: &User) {
let same_starting_datetime = TripDetails::find_by_startingdatetime(
db,
trip_details.day,
trip_details.planned_starting_time,
)
.await;
for notify in same_starting_datetime {
// don't notify oneself
if notify.id == trip_details.id {
continue;
}
// don't notify people who have cancelled their trip
if notify.cancelled() {
continue;
}
if let Some(trip) = Trip::find_by_trip_details(db, notify.id).await {
let user_earlier_trip = User::find_by_id(db, trip.cox_id as i32).await.unwrap();
Notification::create(
db,
&user_earlier_trip,
&format!(
"{user} hat eine Ausfahrt zur selben Zeit ({} um {}) wie du erstellt",
trip.day, trip.planned_starting_time
),
"Neue Ausfahrt zur selben Zeit",
None,
None,
)
.await;
}
}
}
}

View File

@@ -1,25 +1,28 @@
use chrono::{Duration, Local, NaiveDate, NaiveTime};
use ics::properties::{DtEnd, DtStart, Summary};
use chrono::{Local, NaiveDate};
use serde::Serialize;
use sqlx::SqlitePool;
mod create;
use super::{
event::{Event, Registration},
log::Log,
notification::Notification,
tripdetails::TripDetails,
triptype::TripType,
user::{SteeringUser, User},
usertrip::UserTrip,
};
use crate::model::{
log::Log,
notification::Notification,
user::{SteeringUser, User},
};
#[derive(Serialize, Clone, Debug)]
pub struct Trip {
id: i64,
pub id: i64,
pub cox_id: i64,
cox_name: String,
pub cox_name: String,
trip_details_id: Option<i64>,
planned_starting_time: String,
pub planned_starting_time: String,
pub max_people: i64,
pub day: String,
pub notes: Option<String>,
@@ -30,11 +33,12 @@ pub struct Trip {
}
#[derive(Serialize, Debug)]
pub struct TripWithUserAndType {
pub struct TripWithDetails {
#[serde(flatten)]
pub trip: Trip,
pub rower: Vec<Registration>,
trip_type: Option<TripType>,
cancelled: bool,
}
pub struct TripUpdate<'a> {
@@ -46,7 +50,13 @@ pub struct TripUpdate<'a> {
pub is_locked: bool,
}
impl TripWithUserAndType {
impl TripUpdate<'_> {
fn cancelled(&self) -> bool {
self.max_people == -1
}
}
impl TripWithDetails {
pub async fn from(db: &SqlitePool, trip: Trip) -> Self {
let mut trip_type = None;
if let Some(trip_type_id) = trip.trip_type_id {
@@ -54,62 +64,14 @@ impl TripWithUserAndType {
}
Self {
rower: Registration::all_rower(db, trip.trip_details_id.unwrap()).await,
trip,
trip_type,
cancelled: trip.is_cancelled(),
trip,
}
}
}
impl Trip {
/// Cox decides to create own trip.
pub async fn new_own(db: &SqlitePool, cox: &SteeringUser, trip_details: TripDetails) {
Self::perform_new(db, &cox.user, trip_details).await
}
async fn perform_new(db: &SqlitePool, user: &User, trip_details: TripDetails) {
let _ = sqlx::query!(
"INSERT INTO trip (cox_id, trip_details_id) VALUES(?, ?)",
user.id,
trip_details.id
)
.execute(db)
.await;
let same_starting_datetime = TripDetails::find_by_startingdatetime(
db,
trip_details.day,
trip_details.planned_starting_time,
)
.await;
for notify in same_starting_datetime {
// don't notify oneself
if notify.id == trip_details.id {
continue;
}
// don't notify people who have cancelled their trip
if notify.cancelled() {
continue;
}
if let Some(trip) = Trip::find_by_trip_details(db, notify.id).await {
let user_earlier_trip = User::find_by_id(db, trip.cox_id as i32).await.unwrap();
Notification::create(
db,
&user_earlier_trip,
&format!(
"{} hat eine Ausfahrt zur selben Zeit ({} um {}) wie du erstellt",
user.name, trip.day, trip.planned_starting_time
),
"Neue Ausfahrt zur selben Zeit",
None,
None,
)
.await;
}
}
}
pub async fn find_by_trip_details(db: &SqlitePool, tripdetails_id: i64) -> Option<Self> {
sqlx::query_as!(
Self,
@@ -127,46 +89,12 @@ WHERE trip_details.id=?
.ok()
}
pub(crate) async fn get_vevent(self, user: &User) -> ics::Event {
let mut vevent = ics::Event::new(format!("trip-{}@ruad.at", self.id), "19900101T180000");
vevent.push(DtStart::new(format!(
"{}T{}00",
self.day.replace('-', ""),
self.planned_starting_time.replace(':', "")
)));
let original_time = NaiveTime::parse_from_str(&self.planned_starting_time, "%H:%M")
.expect("Failed to parse time");
let later_time = original_time + Duration::hours(3);
if later_time > original_time {
// Check if no day-overflow
let time_three_hours_later = later_time.format("%H%M").to_string();
vevent.push(DtEnd::new(format!(
"{}T{}00",
self.day.replace('-', ""),
time_three_hours_later
)));
}
let mut name = String::new();
if self.is_cancelled() {
name.push_str("ABGESAGT");
if let Some(notes) = &self.notes {
if !notes.is_empty() {
name.push_str(&format!(" (Grund: {notes})"))
}
}
name.push_str("! :-( ");
}
if self.cox_id == user.id {
name.push_str("Ruderausfahrt (selber ausgeschrieben)");
pub(crate) async fn trip_type(&self, db: &SqlitePool) -> Option<TripType> {
if let Some(trip_type_id) = self.trip_type_id {
TripType::find_by_id(db, trip_type_id).await
} else {
name.push_str(&format!("Ruderausfahrt mit {} ", self.cox_name));
None
}
vevent.push(Summary::new(name));
vevent
}
pub async fn all(db: &SqlitePool) -> Vec<Self> {
@@ -234,7 +162,7 @@ WHERE trip.id=?
return Err(CoxHelpError::DetailsLocked);
}
if event.max_people == 0 {
if event.is_cancelled() {
return Err(CoxHelpError::CanceledEvent);
}
@@ -251,12 +179,12 @@ WHERE trip.id=?
}
}
pub async fn get_for_today(db: &SqlitePool) -> Vec<TripWithUserAndType> {
pub async fn get_for_today(db: &SqlitePool) -> Vec<TripWithDetails> {
let today = Local::now().date_naive();
Self::get_for_day(db, today).await
}
pub async fn get_for_day(db: &SqlitePool, day: NaiveDate) -> Vec<TripWithUserAndType> {
pub async fn get_for_day(db: &SqlitePool, day: NaiveDate) -> Vec<TripWithDetails> {
let day = format!("{day}");
let trips = sqlx::query_as!(
Trip,
@@ -275,7 +203,7 @@ WHERE day=?
let mut ret = Vec::new();
for trip in trips {
ret.push(TripWithUserAndType::from(db, trip).await);
ret.push(TripWithDetails::from(db, trip).await);
}
ret
}
@@ -298,9 +226,9 @@ WHERE day=?
};
let tripdetails = TripDetails::find_by_id(db, trip_details_id).await.unwrap();
let was_already_cancelled = tripdetails.max_people == 0;
let was_already_cancelled = tripdetails.cancelled();
let is_locked = if update.max_people == 0 {
let is_locked = if update.cancelled() {
false
} else {
update.is_locked
@@ -318,10 +246,8 @@ WHERE day=?
.await
.unwrap(); //Okay, as trip_details can only be created with proper DB backing
if update.max_people == 0 && !was_already_cancelled {
let rowers = TripWithUserAndType::from(db, update.trip.clone())
.await
.rower;
if update.cancelled() && !was_already_cancelled {
let rowers = TripWithDetails::from(db, update.trip.clone()).await.rower;
for user in rowers {
if let Some(user) = User::find_by_name(db, &user.name).await {
let notes = match update.notes {
@@ -357,7 +283,7 @@ WHERE day=?
.await;
}
if update.max_people > 0 && was_already_cancelled {
if !update.cancelled() && was_already_cancelled {
Notification::delete_by_action(
db,
&format!("remove_user_trip_with_trip_details_id:{}", trip_details_id),
@@ -445,14 +371,14 @@ WHERE day=?
pub(crate) async fn get_pinned_for_day(
db: &sqlx::Pool<sqlx::Sqlite>,
day: NaiveDate,
) -> Vec<TripWithUserAndType> {
) -> Vec<TripWithDetails> {
let mut trips = Self::get_for_day(db, day).await;
trips.retain(|e| e.trip.always_show);
trips
}
fn is_cancelled(&self) -> bool {
self.max_people == 0
pub(crate) fn is_cancelled(&self) -> bool {
self.max_people == -1
}
}
@@ -487,13 +413,15 @@ pub enum TripUpdateError {
mod test {
use crate::{
model::{
event::Event,
notification::Notification,
planned::{
event::Event,
trip::{self, TripDeleteError},
tripdetails::TripDetails,
user::{SteeringUser, User},
usertrip::UserTrip,
},
user::{SteeringUser, User},
},
testdb,
};
@@ -508,7 +436,7 @@ mod test {
let cox = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox".into()).await.unwrap(),
&User::find_by_name(&pool, "cox".into()).await.unwrap(),
)
.await
.unwrap();
@@ -525,7 +453,7 @@ mod test {
let pool = testdb!();
let cox = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox".into()).await.unwrap(),
&User::find_by_name(&pool, "cox".into()).await.unwrap(),
)
.await
.unwrap();
@@ -534,7 +462,7 @@ mod test {
let cox2 = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox2".into()).await.unwrap(),
&User::find_by_name(&pool, "cox2".into()).await.unwrap(),
)
.await
.unwrap();
@@ -543,9 +471,11 @@ mod test {
let last_notification = &Notification::for_user(&pool, &cox).await[0];
assert!(last_notification
assert!(
last_notification
.message
.starts_with("cox2 hat eine Ausfahrt zur selben Zeit"));
.starts_with("cox2 hat eine Ausfahrt zur selben Zeit")
);
}
#[sqlx::test]
@@ -563,7 +493,7 @@ mod test {
let cox = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox2".into()).await.unwrap(),
&User::find_by_name(&pool, "cox2".into()).await.unwrap(),
)
.await
.unwrap();
@@ -579,7 +509,7 @@ mod test {
let cox = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox2".into()).await.unwrap(),
&User::find_by_name(&pool, "cox2".into()).await.unwrap(),
)
.await
.unwrap();
@@ -596,7 +526,7 @@ mod test {
let cox = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox".into()).await.unwrap(),
&User::find_by_name(&pool, "cox".into()).await.unwrap(),
)
.await
.unwrap();
@@ -624,7 +554,7 @@ mod test {
let cox = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox".into()).await.unwrap(),
&User::find_by_name(&pool, "cox".into()).await.unwrap(),
)
.await
.unwrap();
@@ -652,7 +582,7 @@ mod test {
let cox = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox2".into()).await.unwrap(),
&User::find_by_name(&pool, "cox2".into()).await.unwrap(),
)
.await
.unwrap();
@@ -677,7 +607,7 @@ mod test {
let cox = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox".into()).await.unwrap(),
&User::find_by_name(&pool, "cox".into()).await.unwrap(),
)
.await
.unwrap();
@@ -700,7 +630,7 @@ mod test {
let cox = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox".into()).await.unwrap(),
&User::find_by_name(&pool, "cox".into()).await.unwrap(),
)
.await
.unwrap();
@@ -718,7 +648,7 @@ mod test {
let cox = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox2".into()).await.unwrap(),
&User::find_by_name(&pool, "cox2".into()).await.unwrap(),
)
.await
.unwrap();
@@ -740,7 +670,7 @@ mod test {
let cox = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox".into()).await.unwrap(),
&User::find_by_name(&pool, "cox".into()).await.unwrap(),
)
.await
.unwrap();

View File

@@ -1,14 +1,14 @@
use crate::model::user::User;
use crate::model::{notification::Notification, user::User};
use chrono::{Local, NaiveDate};
use rocket::FromForm;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
use super::{
notification::Notification,
trip::{Trip, TripWithUserAndType},
trip::{Trip, TripWithDetails},
triptype::TripType,
};
use std::fmt::Display;
#[derive(FromRow, Debug, Serialize, Deserialize)]
pub struct TripDetails {
@@ -23,6 +23,20 @@ pub struct TripDetails {
pub is_locked: bool,
}
impl Display for TripDetails {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!(
"Ausfahrt am {} um {} mit {} Personen",
self.day, self.planned_starting_time, self.max_people
))?;
if let Some(notes) = &self.notes {
f.write_str(&format!(" Notizen: {notes}"))?;
}
Ok(())
}
}
#[derive(FromForm, Serialize)]
pub struct TripDetailsToAdd<'r> {
//TODO: properly parse `planned_starting_time`
@@ -95,7 +109,7 @@ WHERE day = ? AND planned_starting_time = ?
}
pub fn cancelled(&self) -> bool {
self.max_people == 0
self.max_people == -1
}
/// This function is called when a person registers to a trip or when the cox changes the
@@ -138,7 +152,7 @@ WHERE day = ? AND planned_starting_time = ?
// This trip_details belongs to a planned_event, no need to do anything
continue;
};
let pot_coxes = TripWithUserAndType::from(db, trip.clone()).await;
let pot_coxes = TripWithDetails::from(db, trip.clone()).await;
let pot_coxes = pot_coxes.rower;
for user in pot_coxes {
let cox = User::find_by_id(db, trip.cox_id as i32).await.unwrap();
@@ -196,7 +210,7 @@ WHERE day = ? AND planned_starting_time = ?
.fetch_one(db)
.await
.unwrap(); //TODO: fixme
let amount_currently_registered = i64::from(amount_currently_registered.count);
let amount_currently_registered = amount_currently_registered.count;
amount_currently_registered >= self.max_people
}
@@ -303,7 +317,7 @@ pub(crate) enum Action {
#[cfg(test)]
mod test {
use crate::{model::tripdetails::TripDetailsToAdd, testdb};
use crate::{model::planned::tripdetails::TripDetailsToAdd, testdb};
use super::TripDetails;
use sqlx::SqlitePool;

View File

@@ -2,12 +2,14 @@ use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
use super::{
notification::Notification,
trip::{Trip, TripWithUserAndType},
trip::{Trip, TripWithDetails},
tripdetails::TripDetails,
};
use crate::model::{
notification::Notification,
planned::tripdetails::{Action, CoxAtTrip::Yes},
user::{SteeringUser, User},
};
use crate::model::tripdetails::{Action, CoxAtTrip::Yes};
#[derive(FromRow, Debug, Serialize, Deserialize, Clone)]
pub struct UserTrip {
@@ -158,7 +160,7 @@ impl UserTrip {
.unwrap()
.cancelled()
{
let trip = TripWithUserAndType::from(db, trip.clone()).await;
let trip = TripWithDetails::from(db, trip.clone()).await;
if trip.rower.len() == 1 {
trip_to_delete = Some(trip.trip);
}
@@ -197,7 +199,7 @@ impl UserTrip {
let mut add_info = "";
if let Some(trip) = &trip_to_delete {
let cox = User::find_by_id(db, trip.cox_id as i32).await.unwrap();
trip.delete(db, &SteeringUser::new(db, cox).await.unwrap())
trip.delete(db, &SteeringUser::new(db, &cox).await.unwrap())
.await
.unwrap();
add_info = " Das war die letzte angemeldete Person. Nachdem nun alle Bescheid wissen, wird die Ausfahrt ab sofort nicht mehr angezeigt.";
@@ -270,8 +272,10 @@ pub enum UserTripDeleteError {
mod test {
use crate::{
model::{
event::Event, trip::Trip, tripdetails::TripDetails, user::SteeringUser,
usertrip::UserTripError,
planned::{
event::Event, trip::Trip, tripdetails::TripDetails, usertrip::UserTripError,
},
user::SteeringUser,
},
testdb,
};
@@ -355,7 +359,7 @@ mod test {
let cox = SteeringUser::new(
&pool,
User::find_by_name(&pool, "cox".into()).await.unwrap(),
&User::find_by_name(&pool, "cox".into()).await.unwrap(),
)
.await
.unwrap();

View File

@@ -1,5 +1,6 @@
use std::ops::DerefMut;
use std::{cmp::Ordering, fmt::Display, ops::DerefMut};
use super::{activity::ActivityBuilder, user::AdminUser};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
@@ -7,12 +8,73 @@ use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
pub struct Role {
pub(crate) id: i64,
pub(crate) name: String,
pub(crate) formatted_name: Option<String>,
pub(crate) desc: Option<String>,
pub(crate) hide_in_lists: bool,
pub(crate) cluster: Option<String>,
}
// Implement PartialEq to compare roles based only on id
impl PartialEq for Role {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
// Implement Eq to indicate that equality is reflexive
impl Eq for Role {}
// Implement PartialOrd if you need to sort or compare roles
impl PartialOrd for Role {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.id.cmp(&other.id))
}
}
// Implement Ord if you need total ordering (for sorting)
impl Ord for Role {
fn cmp(&self, other: &Self) -> Ordering {
self.id.cmp(&other.id)
}
}
impl Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(formatted_name) = &self.formatted_name {
write!(f, "{}", formatted_name)
} else {
write!(f, "{}", self.name)
}
}
}
impl Role {
pub async fn all(db: &SqlitePool) -> Vec<Role> {
sqlx::query_as!(Role, "SELECT id, name, cluster FROM role")
sqlx::query_as!(
Role,
"SELECT id, name, formatted_name, desc, hide_in_lists, cluster FROM role"
)
.fetch_all(db)
.await
.unwrap()
}
pub async fn all_cluster(db: &SqlitePool, cluster: &str) -> Vec<Role> {
sqlx::query_as!(
Role,
r#"SELECT id,
CASE WHEN formatted_name IS NOT NULL AND formatted_name != ''
THEN formatted_name
ELSE name
END AS "name!: String",
'' as formatted_name,
desc,
hide_in_lists,
cluster
FROM role
WHERE cluster = ?"#,
cluster
)
.fetch_all(db)
.await
.unwrap()
@@ -22,7 +84,7 @@ impl Role {
sqlx::query_as!(
Self,
"
SELECT id, name, cluster
SELECT id, name, formatted_name, desc, hide_in_lists, cluster
FROM role
WHERE id like ?
",
@@ -36,7 +98,7 @@ WHERE id like ?
sqlx::query_as!(
Self,
"
SELECT id, name, cluster
SELECT id, name, formatted_name, desc, hide_in_lists, cluster
FROM role
WHERE id like ?
",
@@ -47,26 +109,11 @@ WHERE id like ?
.ok()
}
pub async fn find_by_cluster_tx(db: &mut Transaction<'_, Sqlite>, name: i32) -> Option<Self> {
sqlx::query_as!(
Self,
"
SELECT id, name, cluster
FROM role
WHERE cluster = ?
",
name
)
.fetch_one(db.deref_mut())
.await
.ok()
}
pub async fn find_by_name(db: &SqlitePool, name: &str) -> Option<Self> {
sqlx::query_as!(
Self,
"
SELECT id, name, cluster
SELECT id, name, formatted_name, desc, hide_in_lists, cluster
FROM role
WHERE name like ?
",
@@ -81,7 +128,7 @@ WHERE name like ?
sqlx::query_as!(
Self,
"
SELECT id, name, cluster
SELECT id, name, formatted_name, desc, hide_in_lists, cluster
FROM role
WHERE name like ?
",
@@ -92,6 +139,30 @@ WHERE name like ?
.ok()
}
pub async fn update(
&self,
db: &SqlitePool,
updated_by: &AdminUser,
formatted_name: &str,
desc: &str,
) -> Result<(), String> {
sqlx::query!(
"UPDATE role SET formatted_name=?, desc=? WHERE id=?",
formatted_name,
desc,
self.id
)
.execute(db)
.await
.map_err(|e| e.to_string())?;
ActivityBuilder::new(&format!(
"{updated_by} hat Rolle {self} von {self:#?} auf FORMATTED_NAME={formatted_name}, DESC={desc} aktualisiert."
)).role(self).save(db).await;
Ok(())
}
pub async fn names_from_role(&self, db: &SqlitePool) -> Vec<String> {
let query = format!(
"SELECT u.name

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

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

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

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

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

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

View File

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

581
src/model/user/basic.rs Normal file
View File

@@ -0,0 +1,581 @@
// TODO: put back in `src/model/user/mod.rs` once that is cleaned up
use super::{AllowedToEditPaymentStatusUser, ManageUserUser, User};
use crate::model::{
activity::{self, ActivityBuilder},
family::Family,
mail::valid_mails,
notification::Notification,
role::Role,
};
use chrono::NaiveDate;
use rocket::{fs::TempFile, tokio::io::AsyncReadExt};
use sqlx::SqlitePool;
impl User {
pub(crate) async fn add_note(
&self,
db: &SqlitePool,
updated_by: &ManageUserUser,
note: &str,
) -> Result<(), String> {
let note = note.trim();
ActivityBuilder::from(activity::Reason::UserDataChange(
updated_by,
self,
note.to_string(),
))
.save(db)
.await;
Ok(())
}
pub(crate) async fn update_mail(
&self,
db: &SqlitePool,
updated_by: &ManageUserUser,
new_mail: &str,
) -> Result<(), String> {
let new_mail = new_mail.trim();
if !valid_mails(new_mail) {
return Err(format!(
"{new_mail} ist kein gültiges Format für eine Mailadresse"
));
}
sqlx::query!("UPDATE user SET mail = ? where id = ?", new_mail, self.id)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a User of a valid id
let msg = match &self.mail {
Some(old_mail) => format!("Mail-Adresse von {old_mail} auf {new_mail} geändert."),
None => format!("Neue Mail-Adresse für: {new_mail}"),
};
ActivityBuilder::from(activity::Reason::UserDataChange(updated_by, self, msg))
.save(db)
.await;
Ok(())
}
pub(crate) async fn update_phone(
&self,
db: &SqlitePool,
updated_by: &ManageUserUser,
new_phone: &str,
) {
let new_phone = new_phone.trim();
let query = if new_phone.is_empty() {
if self.phone.is_none() {
return; // nothing to do
}
sqlx::query!("UPDATE user SET phone = NULL where id = ?", self.id)
} else {
if let Some(old_phone) = &self.phone {
if old_phone == new_phone {
return; //nothing to do
}
}
sqlx::query!("UPDATE user SET phone = ? where id = ?", new_phone, self.id)
};
query.execute(db).await.unwrap(); //Okay, because we can only create a User of a valid id
let msg = match &self.phone {
Some(old_phone) if new_phone.is_empty() => {
format!("Telefonnummer wurde entfernt (alte Nummer: {old_phone})")
}
Some(old_phone) => {
format!("Telefonnummer wurde von {old_phone} auf {new_phone} geändert.")
}
None => format!("Neue Telefonnummer hinzugefügt: {new_phone}"),
};
ActivityBuilder::from(activity::Reason::UserDataChange(updated_by, self, msg))
.save(db)
.await;
}
pub(crate) async fn update_address(
&self,
db: &SqlitePool,
updated_by: &ManageUserUser,
new_address: &str,
) {
let new_address = new_address.trim();
let query = if new_address.is_empty() {
if self.address.is_none() {
return; // nothing to do
}
sqlx::query!("UPDATE user SET address = NULL where id = ?", self.id)
} else {
if let Some(old_address) = &self.address {
if old_address == new_address {
return; //nothing to do
}
}
sqlx::query!(
"UPDATE user SET address = ? where id = ?",
new_address,
self.id
)
};
query.execute(db).await.unwrap(); //Okay, because we can only create a User of a valid id
let msg = match &self.address {
Some(old_address) if new_address.is_empty() => format!(
"{updated_by} hat die Adresse von {self} entfernt (alte Adresse: {old_address})"
),
Some(old_address) => format!(
"{updated_by} hat die Adresse von {self} von {old_address} auf {new_address} geändert."
),
None => format!("{updated_by} hat eine Adresse für {self} hinzugefügt: {new_address}"),
};
ActivityBuilder::new(&msg).user(self).save(db).await;
}
pub(crate) async fn update_nickname(
&self,
db: &SqlitePool,
updated_by: &ManageUserUser,
new_nickname: &str,
) -> Result<(), String> {
let new_nickname = new_nickname.trim();
let query = if new_nickname.is_empty() {
sqlx::query!("UPDATE user SET nickname = NULL where id = ?", self.id)
} else {
sqlx::query!(
"UPDATE user SET nickname = ? where id = ?",
new_nickname,
self.id
)
};
query.execute(db).await.unwrap(); //Okay, because we can only create a User of a valid id
let msg = match &self.nickname {
Some(old_nickname) if new_nickname.is_empty() => format!(
"{updated_by} hat den Sitznamen von {self} entfernt (alter Spitzname: {old_nickname})"
),
Some(old_nickname) => format!(
"{updated_by} hat den Spitznamen von {self} von {old_nickname} auf {new_nickname} geändert."
),
None => format!(
"{updated_by} hat einen neuen Spitznamen für {self} hinzugefügt: {new_nickname}"
),
};
ActivityBuilder::new(&msg).user(self).save(db).await;
Ok(())
}
pub(crate) async fn update_member_since(
&self,
db: &SqlitePool,
updated_by: &ManageUserUser,
new_member_since_date: &NaiveDate,
) {
sqlx::query!(
"UPDATE user SET member_since_date = ? where id = ?",
new_member_since_date,
self.id
)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a User of a valid id
let msg = match &self.member_since_date {
Some(old_member_since_date) => format!(
"{updated_by} hat das Beitrittsdatum von {self} von {old_member_since_date} auf {new_member_since_date} geändert."
),
None => format!(
"{updated_by} hat ein neues Beitrittsdatum für {self} hinzugefügt: {new_member_since_date}"
),
};
ActivityBuilder::new(&msg).user(self).save(db).await;
}
pub(crate) async fn update_birthdate(
&self,
db: &SqlitePool,
updated_by: &ManageUserUser,
new_birthdate: &NaiveDate,
) {
sqlx::query!(
"UPDATE user SET birthdate = ? where id = ?",
new_birthdate,
self.id
)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a User of a valid id
let msg = match &self.birthdate {
Some(old_birthdate) => format!(
"{updated_by} hat das Geburtsdatum von {self} von {old_birthdate} auf {new_birthdate} geändert."
),
None => {
format!("{updated_by} hat ein Geburtsdatum für {self} hinzugefügt: {new_birthdate}")
}
};
ActivityBuilder::new(&msg).user(self).save(db).await;
}
pub(crate) async fn update_family(
&self,
db: &SqlitePool,
updated_by: &ManageUserUser,
family: Option<Family>,
) {
if let Some(family) = family {
let family_id = family.id;
sqlx::query!(
"UPDATE user SET family_id = ? where id = ?",
family_id,
self.id
)
.execute(db)
.await
.unwrap();
ActivityBuilder::new(&format!(
"{updated_by} hat {self} zu einer Familie hinzugefügt."
))
.user(self)
.save(db)
.await;
} else {
sqlx::query!("UPDATE user SET family_id = NULL where id = ?", self.id)
.execute(db)
.await
.unwrap();
ActivityBuilder::new(&format!(
"{updated_by} hat die Familienzugehörigkeit von {self} gelöscht."
))
.user(self)
.save(db)
.await;
};
Family::clean_families_without_members(db).await;
}
pub(crate) async fn change_skill(
&self,
db: &SqlitePool,
updated_by: &ManageUserUser,
skill: Option<Role>,
) -> Result<(), String> {
let old_skill = self.skill(db).await;
let member = Role::find_by_name(db, "Donau Linz").await.unwrap();
let cox = Role::find_by_name(db, "cox").await.unwrap();
let bootsfuehrer = Role::find_by_name(db, "Bootsführer").await.unwrap();
match (old_skill, skill) {
(None, new) if new == Some(cox.clone()) => {
self.add_role(db, updated_by, &cox).await?;
Notification::create_for_role(
db,
&member,
&format!(
"Liebes Vereinsmitglied, {self} ist ab sofort Steuerperson 🎉 Hip hip ...!"
),
"Neue Steuerperson",
None,
None,
)
.await;
Notification::create(
db,
self,
&format!(
"Liebe neue Steuerperson, gratuliere zur geschafften Steuerprüfung 💪. Du kannst ab sofort selber Ausfahrten ausschreiben und der Steuerpersonen Signal-Gruppe beitreten: https://signal.group/#CjQKIHJInNb3zSVW7ipLo7_ygIqVxhxUaaNYx4sy2jdklLsIEhBHJNM2KZM1UnBdQxWy_Gdp"
),
"Gratulation",
None,
None,
)
.await;
ActivityBuilder::new(&format!("{updated_by} hat {self} zur Steuerperson gemacht"))
.user(self)
.save(db)
.await;
}
(old, new) if old == Some(cox.clone()) && new == Some(bootsfuehrer.clone()) => {
self.remove_role(db, updated_by, &cox).await?;
self.add_role(db, updated_by, &bootsfuehrer).await?;
Notification::create_for_role(
db,
&member,
&format!(
"Liebes Vereinsmitglied, {self} ist ab sofort Bootsführer:in 🎉 Hip hip ...!"
),
"Neue:r Bootsführer:in",
None,
None,
)
.await;
ActivityBuilder::new(&format!("{updated_by} hat {self} zum Bootsführer gemacht"))
.user(self)
.save(db)
.await;
}
(old, None) => {
if let Some(old) = old {
self.remove_role(db, updated_by, &old).await?;
let vorstand = Role::find_by_name(db, "Vorstand").await.unwrap();
Notification::create_for_role(
db,
&vorstand,
&format!("Lieber Vorstand, {self} ist ab sofort kein {old} mehr."),
"Steuerperson--;",
None,
None,
)
.await;
ActivityBuilder::new(&format!("{updated_by} hat {self} zum normalen Mitglied gemacht (keine Steuerperson/Bootsführer mehr)"))
.user(self)
.save(db)
.await;
}
}
(old, new) if old == Some(bootsfuehrer.clone()) && new == Some(cox.clone()) => {
self.remove_role(db, updated_by, &bootsfuehrer).await?;
self.add_role(db, updated_by, &cox).await?;
Notification::create_for_role(
db,
&member,
&format!(
"Lieber Vorstand, {self} ist ab sofort kein Bootsführer:in mehr, sondern 'nur' mehr eine Steuerperson."
),
"Bootsführer--",
None,
None,
)
.await;
ActivityBuilder::new(&format!(
"{updated_by} hat {self} zur Steuerperson gemacht (kein Bootsführer mehr)"
))
.user(self)
.save(db)
.await;
}
(old, new) => return Err(format!("Not allowed to change from {old:?} to {new:?}")),
};
Ok(())
}
pub(crate) async fn change_financial(
&self,
db: &SqlitePool,
updated_by: &ManageUserUser,
financial: Option<Role>,
) -> Result<(), String> {
let mut new = String::new();
let mut old = String::new();
if let Some(old_financial) = self.financial(db).await {
self.remove_role(db, updated_by, &old_financial).await?;
old.push_str(&old_financial.to_string());
} else {
old.push_str("Keine Ermäßigung");
}
if let Some(new_financial) = financial {
self.add_role(db, updated_by, &new_financial).await?;
new.push_str(&new_financial.to_string());
} else {
new.push_str("Keine Ermäßigung");
}
ActivityBuilder::new(&format!(
"{updated_by} hat die Ermäßigung von {self} von {old} auf {new} geändert"
))
.user(self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn remove_role(
&self,
db: &SqlitePool,
updated_by: &ManageUserUser,
role: &Role,
) -> Result<(), String> {
if !self.has_role(db, &role.name).await {
return Err(format!(
"Kann Rolle {role} von User {self} nicht entfernen, da der User die Rolle gar nicht hat"
));
}
sqlx::query!(
"DELETE FROM user_role WHERE user_id = ? and role_id = ?",
self.id,
role.id
)
.execute(db)
.await
.unwrap();
if !role.hide_in_lists && role.cluster.is_none() {
ActivityBuilder::new(&format!(
"{updated_by} hat die Rolle {role} von {self} entfernt."
))
.user(self)
.save(db)
.await;
}
Ok(())
}
pub(crate) async fn has_not_paid(
&self,
db: &SqlitePool,
updated_by: &AllowedToEditPaymentStatusUser,
) {
let paid = Role::find_by_name(db, "paid").await.unwrap();
sqlx::query!(
"DELETE FROM user_role WHERE user_id = ? and role_id = ?",
self.id,
paid.id
)
.execute(db)
.await
.unwrap();
ActivityBuilder::new(&format!(
"{updated_by} hat den Bezahlstatus von {self} auf 'nicht bezahlt' gesetzt."
))
.user(self)
.save(db)
.await;
}
pub(crate) async fn has_paid(
&self,
db: &SqlitePool,
updated_by: &AllowedToEditPaymentStatusUser,
) {
let paid = Role::find_by_name(db, "paid").await.unwrap();
sqlx::query!(
"INSERT INTO user_role(user_id, role_id) VALUES (?, ?)",
self.id,
paid.id
)
.execute(db)
.await
.expect("paid role has no group");
ActivityBuilder::new(&format!(
"{updated_by} hat den Bezahlstatus von {self} auf 'bezahlt' gesetzt."
))
.user(self)
.save(db)
.await;
}
pub(crate) async fn add_role(
&self,
db: &SqlitePool,
updated_by: &ManageUserUser,
role: &Role,
) -> Result<(), String> {
if self.has_role(db, &role.name).await {
return Err(format!(
"Kann Rolle {role} von User {self} nicht hinzufügen, da der User die Rolle schon hat"
));
}
sqlx::query!(
"INSERT INTO user_role(user_id, role_id) VALUES (?, ?)",
self.id,
role.id
)
.execute(db)
.await
.map_err(|_| {
format!(
"User already has a role in the cluster '{}'",
role.cluster
.clone()
.expect("db trigger can't activate on empty string")
)
})?;
if !role.hide_in_lists && role.cluster.is_none() {
ActivityBuilder::new(&format!(
"{updated_by} hat die Rolle '{role}' dem Benutzer {self} hinzugefügt."
))
.user(self)
.save(db)
.await;
}
Ok(())
}
pub(crate) async fn remove_membership_pdf(&self, db: &SqlitePool, updated_by: &ManageUserUser) {
ActivityBuilder::new(&format!(
"{updated_by} hat die Beitrittserklärung vom Beutzer gelöscht."
))
.user(self)
.save(db)
.await;
sqlx::query!(
"UPDATE user SET membership_pdf = null where id = ?",
self.id
)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a User of a valid id
}
pub(crate) async fn add_membership_pdf(
&self,
db: &SqlitePool,
updated_by: &ManageUserUser,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
if self.has_membership_pdf(db).await {
return Err(format!("User {self} hat bereits eine Beitrittserklärung."));
}
if membership_pdf.len() == 0 {
return Err("Keine Beitrittserklärung mitgeschickt.".to_string());
}
let mut stream = membership_pdf.open().await.unwrap();
let mut buffer = Vec::new();
stream.read_to_end(&mut buffer).await.unwrap();
sqlx::query!(
"UPDATE user SET membership_pdf = ? where id = ?",
buffer,
self.id
)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a User of a valid id
ActivityBuilder::new(&format!(
"{updated_by} hat die Mitgliedserklärung (PDF) für user {self} hinzugefügt."
))
.user(self)
.save(db)
.await;
Ok(())
}
}

View File

@@ -0,0 +1,167 @@
use super::User;
use crate::{
model::{
activity::ActivityBuilder, notification::Notification, role::Role, user::ManageUserUser,
},
special_user,
};
use rocket::async_trait;
use sqlx::SqlitePool;
special_user!(ClubMemberUser, +"Donau Linz", +"Förderndes Mitglied", +"Unterstützend");
impl ClubMemberUser {
async fn add_membership_role(&self, db: &SqlitePool, role: &Role) {
sqlx::query!(
"INSERT INTO user_role(user_id, role_id) VALUES (?, ?)",
self.id,
role.id
)
.execute(db)
.await
.unwrap();
}
async fn remove_membership_role(&self, db: &SqlitePool) {
let role = Role::find_by_name(db, "Förderndes Mitglied").await.unwrap();
sqlx::query!(
"DELETE FROM user_role WHERE user_id = ? and role_id = ?",
self.id,
role.id
)
.execute(db)
.await
.unwrap();
let role = Role::find_by_name(db, "Unterstützend").await.unwrap();
sqlx::query!(
"DELETE FROM user_role WHERE user_id = ? and role_id = ?",
self.id,
role.id
)
.execute(db)
.await
.unwrap();
let role = Role::find_by_name(db, "Donau Linz").await.unwrap();
sqlx::query!(
"DELETE FROM user_role WHERE user_id = ? and role_id = ?",
self.id,
role.id
)
.execute(db)
.await
.unwrap();
}
async fn new_membership_role(&self, db: &SqlitePool, role: &str) -> Result<(), String> {
let role = Role::find_by_name(db, role).await.unwrap();
self.remove_membership_role(db).await;
self.add_membership_role(db, &role).await;
Ok(())
}
pub(crate) async fn move_to_regular(
self,
db: &SqlitePool,
modified_by: &ManageUserUser,
) -> Result<(), String> {
if self.has_role(db, "Donau Linz").await {
return Err(format!("User {self} ist bereits reguläres Mitglied."));
}
self.new_membership_role(db, "Donau Linz").await?;
Notification::create_for_steering_people(
db,
&format!(
"Liebe Steuerberechtigte, {} hat upgegraded und ist nun ein neues reguläres Mitglied. 🎉",
self.name,
),
"Neues Vereinsmitglied",
None,
None,
)
.await;
ActivityBuilder::new(&format!(
"{modified_by} hat {self} zu einem regulären hochgestuft."
))
.user(&self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn move_to_unterstuetzend(
self,
db: &SqlitePool,
modified_by: &ManageUserUser,
) -> Result<(), String> {
if self.has_role(db, "Unterstützend").await {
return Err(format!("User {self} ist bereits unterstützendes Mitglied."));
}
self.new_membership_role(db, "Unterstützend").await?;
if let Some(vorstand) = Role::find_by_name(db, "vorstand").await {
Notification::create_for_role(
db,
&vorstand,
&format!(
"Lieber Vorstand, {} ist nun ein unterstützendes Mitglied.",
self.name,
),
"Neues unterstützendes Vereinsmitglied",
None,
None,
)
.await;
}
ActivityBuilder::new(&format!(
"{modified_by} hat {self} zu einem unterstützenden Mitglied gemacht."
))
.user(&self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn move_to_foerdernd(
self,
db: &SqlitePool,
modified_by: &ManageUserUser,
) -> Result<(), String> {
if self.has_role(db, "Förderndes Mitglied").await {
return Err(format!("User {self} ist bereits förderndes Mitglied."));
}
self.new_membership_role(db, "Förderndes Mitglied").await?;
if let Some(vorstand) = Role::find_by_name(db, "vorstand").await {
Notification::create_for_role(
db,
&vorstand,
&format!(
"Lieber Vorstand, {} ist nun ein förderndes Mitglied.",
self.name,
),
"Neues förderndes Vereinsmitglied",
None,
None,
)
.await;
}
ActivityBuilder::new(&format!(
"{modified_by} hat {self} zu ein förderndes Mitglied gemacht."
))
.user(&self)
.save(db)
.await;
Ok(())
}
}

View File

@@ -1,5 +1,12 @@
use super::User;
use crate::{
BOAT_STORAGE, DUAL_MEMBERSHIP, EINSCHREIBGEBUEHR, FAMILY_THREE_OR_MORE, FAMILY_TWO, FOERDERND,
REGULAR, RENNRUDERBEITRAG, SCHECKBUCH, STUDENT_OR_PUPIL, TRIAL_ROWING, TRIAL_ROWING_REDUCED,
UNTERSTUETZEND, model::family::Family,
};
use chrono::{Datelike, Local, NaiveDate};
use serde::Serialize;
use sqlx::SqlitePool;
#[derive(Debug, Serialize)]
pub struct Fee {
@@ -56,3 +63,154 @@ impl Fee {
}
}
}
impl User {
pub async fn fee(&self, db: &SqlitePool) -> Option<Fee> {
if !self.has_role(db, "Donau Linz").await
&& !self.has_role(db, "Unterstützend").await
&& !self.has_role(db, "Förderndes Mitglied").await
&& !self.has_role(db, "schnupperant").await
&& !self.has_role(db, "scheckbuch").await
{
return None;
}
if self.deleted {
return None;
}
let mut fee = Fee::new();
if let Some(family) = Family::find_by_opt_id(db, self.family_id).await {
for member in family.members(db).await {
fee.add_person(&member);
if member.has_role(db, "paid").await {
fee.paid();
}
fee.merge(member.fee_without_families(db).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.add_person(self);
if self.has_role(db, "paid").await {
fee.paid();
}
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
&& !self.has_role(db, "Unterstützend").await
&& !self.has_role(db, "Förderndes Mitglied").await
&& !self.has_role(db, "schnupperant").await
&& !self.has_role(db, "scheckbuch").await
{
return fee;
}
if self.has_role(db, "Rennrudern").await {
if self.has_role(db, "half-rennrudern").await {
fee.add("Rennruderbeitrag (1/2 Preis) ".into(), RENNRUDERBEITRAG / 2);
} else if !self.has_role(db, "renntrainer").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, "schnupperant").await {
if let Some(member_since_date) = &self.member_since_date {
if let Ok(member_since_date) =
NaiveDate::parse_from_str(member_since_date, "%Y-%m-%d")
{
if member_since_date.year() == Local::now().year()
&& !self.has_role(db, "no-einschreibgebuehr").await
{
fee.add("Einschreibgebühr".into(), EINSCHREIBGEBUEHR);
}
}
}
}
let halfprice = if let Some(member_since_date) = &self.member_since_date {
match NaiveDate::parse_from_str(member_since_date, "%Y-%m-%d") {
Ok(member_since_date) => {
let halfprice_startdate =
NaiveDate::from_ymd_opt(Local::now().year(), 7, 1).unwrap();
member_since_date >= halfprice_startdate
}
Err(_) => false,
}
} else {
false
};
if self.has_role(db, "schnupperant").await {
if self.has_role(db, "Student").await || self.has_role(db, "Schüler").await {
fee.add("Schnupperkurs (reduziert)".into(), TRIAL_ROWING_REDUCED);
} else {
fee.add("Schnupperkurs".into(), TRIAL_ROWING);
}
} else if self.has_role(db, "scheckbuch").await {
fee.add("Scheckbuch".into(), SCHECKBUCH);
} else 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 {
if halfprice {
fee.add("Schüler/Student (Halbpreis)".into(), STUDENT_OR_PUPIL / 2);
} else {
fee.add("Schüler/Student".into(), STUDENT_OR_PUPIL);
}
} else if self.has_role(db, "Ehrenmitglied").await {
fee.add("Ehrenmitglied".into(), 0);
} else if self.has_role(db, "dual_membership").await {
if halfprice {
fee.add(
"Doppelmitgliedschaft mit anderem österr. Ruderverein (Halbpreis)".into(),
DUAL_MEMBERSHIP / 2,
);
} else {
fee.add(
"Doppelmitgliedschaft mit anderem österr. Ruderverein".into(),
DUAL_MEMBERSHIP,
);
}
} else if halfprice {
fee.add("Mitgliedsbeitrag (Halbpreis)".into(), REGULAR / 2);
} else {
fee.add("Mitgliedsbeitrag".into(), REGULAR);
}
}
if !self.has_role(db, "schnupperant").await
&& self.has_role(db, "participated_schnupperkurs").await
{
if self.has_role(db, "Student").await || self.has_role(db, "Schüler").await {
fee.add(
"Anrechnung reduzierter Schnupperkurs".into(),
-TRIAL_ROWING_REDUCED,
);
} else {
fee.add("Anrechnung Schnupperkurs".into(), -TRIAL_ROWING);
}
}
fee
}
}

101
src/model/user/foerdernd.rs Normal file
View File

@@ -0,0 +1,101 @@
use super::{ManageUserUser, User, regular::ClubMember};
use crate::{
NonEmptyString,
model::{activity::ActivityBuilder, mail::Mail, notification::Notification, role::Role},
special_user,
};
use chrono::NaiveDate;
use rocket::{async_trait, fs::TempFile};
use sqlx::SqlitePool;
special_user!(FoerderndUser, +"Förderndes Mitglied");
impl ClubMember for FoerderndUser {}
impl FoerderndUser {
pub(crate) async fn send_welcome_mail_to_user(
&self,
db: &SqlitePool,
smtp_pw: &str,
) -> Result<(), String> {
let Some(mail) = &self.mail else {
return Err(format!(
"Couldn't send welcome mail, as the user {self} has no mail..."
));
};
Mail::send_single(
db,
mail,
"Willkommen im ASKÖ Ruderverein Donau Linz!",
format!(
"Hallo {0},
herzlich willkommen im ASKÖ Ruderverein Donau Linz! Wir freuen uns sehr, dich als neues Mitglied in unserem Verein begrüßen zu dürfen.
Um dir den Einstieg zu erleichtern, findest du in unserem Handbuch alle wichtigen Informationen über unseren Verein: https://rudernlinz.at/book. Bei weiteren Fragen stehen dir die Adressen info@rudernlinz.at (für allgemeine Fragen) und it@rudernlinz.at (bei technischen Fragen) jederzeit zur Verfügung.
Damit du dich noch mehr verbunden fühlst (:-)), haben wir im Bootshaus ein WLAN für Vereinsmitglieder 'ASKÖ Ruderverein Donau Linz' eingerichtet. Das Passwort dafür lautet 'donau1921' (ohne Anführungszeichen). Bitte gib das Passwort an keine vereinsfremden Personen weiter.
Riemen- & Dollenbruch
ASKÖ Ruderverein Donau Linz", self.name),
smtp_pw,
).await?;
ActivityBuilder::new(&format!(
"User {self} hat die Info-Mail bzgl. neues förderndes Mitglied (Handbuch und WLAN Infos) an {mail} gesendet bekommen"
))
.user(self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn create(
db: &SqlitePool,
created_by: &ManageUserUser,
smtp_pw: &str,
name: NonEmptyString,
mail: &str,
financial: Option<Role>,
birthdate: &NaiveDate,
member_since: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
let role = Role::find_by_name(db, "Förderndes Mitglied").await.unwrap();
let user = Self::create_member(
db,
created_by,
&role,
name,
mail,
financial,
birthdate,
member_since,
phone,
address,
membership_pdf,
)
.await?;
let user = Self::new(db, &user).await.unwrap();
user.send_welcome_mail_to_user(db, smtp_pw).await?;
if let Some(vorstand) = Role::find_by_name(db, "Vorstand").await {
Notification::create_for_role(
db,
&vorstand,
&format!("Lieber Vorstand, es gibt ein neues förderndes Mitglied: {user}"),
"Neues unterstützendes Vereinsmitglied",
None,
None,
)
.await;
}
Ok(())
}
}

54
src/model/user/member.rs Normal file
View File

@@ -0,0 +1,54 @@
use super::ScheckbuchUser;
use crate::model::{
logbook::{Logbook, LogbookWithBoatAndRowers},
user::User,
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
#[derive(Serialize, Deserialize)]
pub(crate) enum Member {
SchnupperInterest(User),
Schnupperant(User),
Scheckbuch(Vec<LogbookWithBoatAndRowers>),
Regular(User),
Foerdernd(User),
Unterstuetzend(User),
}
impl Member {
pub(crate) async fn from(db: &SqlitePool, user: User) -> Self {
if ScheckbuchUser::new(db, &user).await.is_some() {
Self::Scheckbuch(Logbook::completed_with_user(db, &user).await)
} else if user.has_role(db, "schnupper-interessierte").await {
Self::SchnupperInterest(user)
} else if user.has_role(db, "schnupperant").await {
Self::Schnupperant(user)
} else if user.has_role(db, "Donau Linz").await {
Self::Regular(user)
} else if user.has_role(db, "Förderndes Mitglied").await {
Self::Foerdernd(user)
} else if user.has_role(db, "Unterstützend").await {
Self::Unterstuetzend(user)
} else {
panic!("User {user} has no membership_type!!");
}
}
pub(crate) fn is_club_member(&self) -> bool {
matches!(
self,
Member::Regular(_) | Member::Foerdernd(_) | Member::Unterstuetzend(_)
)
}
pub(crate) fn supposed_to_pay(&self) -> bool {
matches!(
self,
Member::Schnupperant(_)
| Member::Scheckbuch(_)
| Member::Regular(_)
| Member::Foerdernd(_)
| Member::Unterstuetzend(_)
)
}
}

View File

@@ -1,12 +1,11 @@
use std::ops::{Deref, DerefMut};
use std::{fmt::Display, ops::DerefMut};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use chrono::{Datelike, Local, NaiveDate};
use log::info;
use rocket::async_trait;
use rocket::{
async_trait,
http::{Cookie, Status},
request,
request::{FromRequest, Outcome},
time::{Duration, OffsetDateTime},
Request,
@@ -14,25 +13,80 @@ use rocket::{
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
use super::{log::Log, role::Role, tripdetails::TripDetails, Day};
use crate::{tera::admin::user::UserEditForm, AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD};
use super::activity::{ActivityBuilder, ReasonAuth};
use super::{
log::Log,
logbook::Logbook,
mail::Mail,
notification::Notification,
personal::{equatorprice, rowingbadge},
planned::tripdetails::TripDetails,
role::Role,
stat::Stat,
Day,
};
use crate::AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD;
use scheckbuch::ScheckbuchUser;
mod basic;
pub(crate) mod clubmember;
mod fee;
pub(crate) mod foerdernd;
pub(crate) mod member;
pub(crate) mod regular;
pub(crate) mod scheckbuch;
pub(crate) mod schnupperant;
pub(crate) mod schnupperinterest;
pub(crate) mod unterstuetzend;
#[derive(FromRow, Serialize, Deserialize, Clone, Debug, Eq, Hash, PartialEq)]
pub struct User {
pub id: i64,
pub name: String,
pub pw: Option<String>,
pub dob: Option<String>,
pub weight: Option<String>,
pub sex: Option<String>,
pub deleted: bool,
pub last_access: Option<chrono::NaiveDateTime>,
pub member_since_date: Option<String>,
pub birthdate: Option<String>,
pub mail: Option<String>,
pub nickname: Option<String>,
pub phone: Option<String>,
pub address: Option<String>,
pub family_id: Option<i64>,
pub user_token: String,
}
impl Display for User {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name)
}
}
pub(crate) struct VecUser<'a>(pub &'a Vec<User>);
impl Display for VecUser<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
self.0
.iter()
.map(|user| user.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UserWithDetails {
#[serde(flatten)]
pub user: User,
pub amount_unread_notifications: i64,
pub allowed_to_steer: bool,
pub on_water: bool,
pub roles: Vec<String>,
}
@@ -41,12 +95,20 @@ impl UserWithDetails {
let allowed_to_steer = user.allowed_to_steer(db).await;
Self {
on_water: user.on_water(db).await,
roles: user.roles(db).await,
amount_unread_notifications: user.amount_unread_notifications(db).await,
allowed_to_steer,
user,
}
}
pub fn allowed_to_row(&self) -> bool {
self.roles.contains(&"Donau Linz".into())
|| self.roles.contains(&"Förderndes Mitglied".into())
|| self.roles.contains(&"scheckbuch".into())
|| self.user.name == "Externe Steuerperson"
}
}
#[derive(Debug)]
@@ -72,6 +134,17 @@ impl User {
self.has_role_tx(db, "cox").await || self.has_role_tx(db, "Bootsführer").await
}
pub async fn amount_boats(&self, db: &SqlitePool) -> i64 {
sqlx::query!(
"SELECT COUNT(*) as count FROM boat WHERE owner = ?",
self.id
)
.fetch_one(db)
.await
.unwrap()
.count
}
pub async fn amount_unread_notifications(&self, db: &SqlitePool) -> i64 {
sqlx::query!(
"SELECT COUNT(*) as count FROM notification WHERE user_id = ? AND read_at IS NULL",
@@ -101,11 +174,34 @@ impl User {
}
pub async fn allowed_to_update_always_show_trip(&self, db: &SqlitePool) -> bool {
AllowedToUpdateTripToAlwaysBeShownUser::new(db, self.clone())
AllowedToUpdateTripToAlwaysBeShownUser::new(db, self)
.await
.is_some()
}
pub async fn has_membership_pdf(&self, db: &SqlitePool) -> bool {
match sqlx::query_scalar!("SELECT membership_pdf FROM user WHERE id = ?", self.id)
.fetch_one(db)
.await
.unwrap()
{
Some(a) if a.is_empty() => false,
None => false,
_ => true,
}
}
pub async fn has_membership_pdf_tx(&self, db: &mut Transaction<'_, Sqlite>) -> bool {
match sqlx::query_scalar!("SELECT membership_pdf FROM user WHERE id = ?", self.id)
.fetch_one(db.deref_mut())
.await
.unwrap()
{
Some(a) if a.is_empty() => false,
None => false,
_ => true,
}
}
pub async fn roles(&self, db: &SqlitePool) -> Vec<String> {
sqlx::query!(
"SELECT r.name FROM role r JOIN user_role ur ON r.id = ur.role_id JOIN user u ON u.id = ur.user_id WHERE ur.user_id = ? AND u.deleted = 0;",
@@ -117,10 +213,44 @@ impl User {
.into_iter().map(|r| r.name).collect()
}
pub async fn financial(&self, db: &SqlitePool) -> Option<Role> {
sqlx::query_as!(
Role,
"
SELECT r.id, r.name, r.formatted_name, r.desc, r.hide_in_lists, r.cluster
FROM role r
JOIN user_role ur ON r.id = ur.role_id
WHERE ur.user_id = ?
AND r.cluster = 'financial';
",
self.id
)
.fetch_optional(db)
.await
.unwrap()
}
pub async fn skill(&self, db: &SqlitePool) -> Option<Role> {
sqlx::query_as!(
Role,
"
SELECT r.id, r.name, r.formatted_name, r.desc, r.hide_in_lists, r.cluster
FROM role r
JOIN user_role ur ON r.id = ur.role_id
WHERE ur.user_id = ?
AND r.cluster = 'skill';
",
self.id
)
.fetch_optional(db)
.await
.unwrap()
}
pub async fn real_roles(&self, db: &SqlitePool) -> Vec<Role> {
sqlx::query_as!(
Role,
"SELECT r.id, r.name, r.cluster
"SELECT r.id, r.name, r.cluster, r.formatted_name, r.desc, r.hide_in_lists
FROM role r
JOIN user_role ur ON r.id = ur.role_id
JOIN user u ON u.id = ur.user_id
@@ -153,7 +283,7 @@ impl User {
sqlx::query_as!(
Self,
"
SELECT id, name, pw, deleted, last_access, user_token
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, phone, address, family_id, user_token
FROM user
WHERE id like ?
",
@@ -168,7 +298,7 @@ WHERE id like ?
sqlx::query_as!(
Self,
"
SELECT id, name, pw, deleted, last_access, user_token
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, phone, address, family_id, user_token
FROM user
WHERE id like ?
",
@@ -180,14 +310,14 @@ WHERE id like ?
}
pub async fn find_by_name(db: &SqlitePool, name: &str) -> Option<Self> {
let name = name.trim().to_lowercase();
let name = name.trim();
sqlx::query_as!(
Self,
"
SELECT id, name, pw, deleted, last_access, user_token
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, phone, address, family_id, user_token
FROM user
WHERE lower(name)=?
WHERE lower(name)=lower(?)
",
name
)
@@ -196,6 +326,33 @@ WHERE lower(name)=?
.ok()
}
pub async fn on_water(&self, db: &SqlitePool) -> bool {
if sqlx::query!(
"SELECT * FROM logbook WHERE shipmaster=? AND arrival is null",
self.id
)
.fetch_optional(db)
.await
.unwrap()
.is_some()
{
return true;
}
if sqlx::query!(
"SELECT * FROM logbook JOIN rower ON rower.logbook_id=logbook.id WHERE rower_id=? AND arrival is null",
self.id
)
.fetch_optional(db)
.await
.unwrap()
.is_some()
{
return true;
}
false
}
pub async fn all(db: &SqlitePool) -> Vec<Self> {
Self::all_with_order(db, "last_access", false).await
}
@@ -203,7 +360,7 @@ WHERE lower(name)=?
pub async fn all_with_order(db: &SqlitePool, sort: &str, asc: bool) -> Vec<Self> {
let mut query = format!(
"
SELECT id, name, pw, deleted, last_access, user_token
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, phone, address, family_id, user_token
FROM user
WHERE deleted = 0
ORDER BY {}
@@ -231,26 +388,50 @@ WHERE lower(name)=?
sqlx::query_as!(
Self,
"
SELECT id, name, pw, deleted, last_access, user_token
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, phone, address, family_id, user_token
FROM user u
JOIN user_role ur ON u.id = ur.user_id
WHERE ur.role_id = ? AND deleted = 0
ORDER BY name;
",
role.id
", role.id
)
.fetch_all(db.deref_mut())
.await
.unwrap()
}
pub async fn all_payer_groups(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as!(
Self,
"
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, phone, address, family_id, user_token FROM user
WHERE family_id IS NOT NULL
GROUP BY family_id
UNION
-- Select users with a null family_id, without grouping
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, phone, address, family_id, user_token FROM user
WHERE family_id IS NULL;
"
)
.fetch_all(db)
.await
.unwrap()
}
pub async fn ergo(db: &SqlitePool) -> Vec<Self> {
let ergo = Role::find_by_name(db, "ergo").await.unwrap();
Self::all_with_role(db, &ergo).await
}
pub async fn cox(db: &SqlitePool) -> Vec<Self> {
sqlx::query_as!(
Self,
"
SELECT id, name, pw, deleted, last_access, user_token
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, phone, address, family_id, user_token
FROM user
WHERE deleted = 0 AND (SELECT COUNT(*) FROM user_role WHERE user_id=user.id AND role_id = (SELECT id FROM role WHERE name = 'cox')) > 0
WHERE deleted = 0 AND (SELECT COUNT(*) FROM user_role WHERE user_id=user.id AND role_id in (SELECT id FROM role WHERE name = 'cox' or name = 'Bootsführer')) > 0
ORDER BY last_access DESC
"
)
@@ -259,103 +440,50 @@ ORDER BY last_access DESC
.unwrap()
}
pub async fn create(db: &SqlitePool, name: &str) {
let name = name.trim();
if sqlx::query!("INSERT INTO USER(name) VALUES (?)", name)
pub async fn update_ergo(&self, db: &SqlitePool, dob: i32, weight: i64, sex: &str) {
sqlx::query!(
"UPDATE user SET dob = ?, weight = ?, sex = ? where id = ?",
dob,
weight,
sex,
self.id
)
.execute(db)
.await
.is_ok()
{
return;
}
sqlx::query!("UPDATE user SET deleted = false where name = ?", name)
.execute(db)
.await
.unwrap();
}
pub async fn update(&self, db: &SqlitePool, data: UserEditForm) -> Result<(), String> {
let mut db = db.begin().await.map_err(|e| e.to_string())?;
sqlx::query!("UPDATE user SET name = ? where id = ?", data.name, self.id)
.execute(db.deref_mut())
.await
.unwrap(); //Okay, because we can only create a User of a valid id
// handle roles
sqlx::query!("DELETE FROM user_role WHERE user_id = ?", self.id)
.execute(db.deref_mut())
.await
.unwrap();
for role_id in data.roles.into_keys() {
let role = Role::find_by_id_tx(&mut db, role_id.parse::<i32>().unwrap())
.await
.unwrap();
self.add_role_tx(&mut db, &role).await?;
}
db.commit().await.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn add_role(&self, db: &SqlitePool, role: &Role) -> Result<(), String> {
sqlx::query!(
"INSERT INTO user_role(user_id, role_id) VALUES (?, ?)",
self.id,
role.id
)
.execute(db)
.await
.map_err(|_| {
format!(
"User already has a role in the cluster '{}'",
role.cluster
.clone()
.expect("db trigger can't activate on empty string")
)
})?;
Ok(())
}
pub async fn add_role_tx(
async fn send_end_mail_scheckbuch(
&self,
db: &mut Transaction<'_, Sqlite>,
role: &Role,
mail: &str,
smtp_pw: &str,
) -> Result<(), String> {
sqlx::query!(
"INSERT INTO user_role(user_id, role_id) VALUES (?, ?)",
self.id,
role.id
)
.execute(db.deref_mut())
.await
.map_err(|_| {
Mail::send_single_tx(
db,
mail,
"ASKÖ Ruderverein Donau Linz | Deine Mitgliedschaft wartet auf Dich",
format!(
"User already has a role in the cluster '{}'",
role.cluster
.clone()
.expect("db trigger can't activate on empty string")
)
})?;
"Hallo {0},
herzlichen Glückwunsch---Du hast Deine fünf Scheckbuch-Ausfahrten erfolgreich absolviert! Wir hoffen, dass Du das Rudern bei uns genauso genossen hast wie wir es genossen haben, Dich auf dem Wasser zu begleiten.
Wir würden uns sehr freuen, Dich als festes Mitglied in unserem Verein willkommen zu heißen! Als Mitglied stehen Dir dann alle unsere Ausfahrten offen, die von unseren Steuerleuten organisiert werden. Im Sommer erwarten Dich zusätzlich spannende Events: Wanderfahrten, Sternfahrten, Fetzenfahrt, .... Im Winter bieten wir Indoor-Ergo-Challenges an, bei denen Du Deine Fitness auf dem Ruderergometer unter Beweis stellen kannst. Alle Details zu diesen Aktionen erfährst Du, sobald Du Teil unseres Vereins bist! :-)
Alle Informationen zu den Mitgliedsbeiträgen findest Du unter https://rudernlinz.at/unser-verein/gebuhren/ Falls Du Dich entscheidest, unserem Verein beizutreten, fülle bitte unser Beitrittsformular auf https://rudernlinz.at/unser-verein/downloads/ aus und sende es an info@rudernlinz.at.
Wir freuen uns, Dich bald wieder auf dem Wasser zu sehen.
Riemen- & Dollenbruch,
ASKÖ Ruderverein Donau Linz", self.name),
smtp_pw,
).await?;
ActivityBuilder::new(&format!("User {self} hat eine Mail bekommen, dass seine 5 Ausfahrten mit der heutigen Ausfahrt aufgebraucht sind, und dass der nächste Schritt eine Vereinsmitgliedschaft wäre (inkl. Links zu Beitrittserklärung + Info, dass sie an info@ geschickt werden soll.")).user(self).save_tx(db).await;
Ok(())
}
pub async fn remove_role(&self, db: &SqlitePool, role: &Role) {
sqlx::query!(
"DELETE FROM user_role WHERE user_id = ? and role_id = ?",
self.id,
role.id
)
.execute(db)
.await
.unwrap();
}
pub async fn login(db: &SqlitePool, name: &str, pw: &str) -> Result<Self, LoginError> {
let name = name.trim().to_lowercase(); // just to make sure...
let Some(user) = User::find_by_name(db, &name).await else {
@@ -364,11 +492,22 @@ ORDER BY last_access DESC
};
if user.deleted {
Log::create(
if let Some(board) = Role::find_by_name(db, "Vorstand").await {
Notification::create_for_role(
db,
format!("User ({name}) already deleted (tried to login)."),
&board,
&format!(
"{user} wollte sich einloggen, klappte jedoch nicht weil der Account gelöscht wurde."
),
"Fehlgeschlagener Login",
None,
None,
)
.await;
}
ActivityBuilder::from(ReasonAuth::DeletedUserLogin(&user))
.save(db)
.await;
return Err(LoginError::InvalidAuthenticationCombo); //User existed sometime ago; has
//been deleted
}
@@ -378,7 +517,9 @@ ORDER BY last_access DESC
if password_hash == user_pw {
return Ok(user);
}
Log::create(db, format!("User {name} supplied the wrong PW")).await;
ActivityBuilder::from(ReasonAuth::WrongPw(&user))
.save(db)
.await;
Err(LoginError::InvalidAuthenticationCombo)
} else {
info!("User {name} has no PW set");
@@ -386,11 +527,19 @@ ORDER BY last_access DESC
}
}
pub async fn reset_pw(&self, db: &SqlitePool) {
pub async fn reset_pw(&self, db: &SqlitePool, changed_by: &ManageUserUser) {
sqlx::query!("UPDATE user SET pw = null where id = ?", self.id)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a User of a valid id
// TODO: add responsible person
ActivityBuilder::new(&format!(
"{changed_by} hat das Passwort von User {self} zurückgesetzt."
))
.user(self)
.save(db)
.await;
}
pub async fn update_pw(&self, db: &SqlitePool, pw: &str) {
@@ -399,6 +548,10 @@ ORDER BY last_access DESC
.execute(db)
.await
.unwrap(); //Okay, because we can only create a User of a valid id
ActivityBuilder::new(&format!("{self} hat sein Passwort geändert."))
.user(self)
.save(db)
.await;
}
fn get_hashed_pw(pw: &str) -> String {
@@ -420,11 +573,15 @@ ORDER BY last_access DESC
.unwrap(); //Okay, because we can only create a User of a valid id
}
pub async fn delete(&self, db: &SqlitePool) {
pub async fn delete(&self, db: &SqlitePool, deleted_by: &ManageUserUser) {
sqlx::query!("UPDATE user SET deleted=1 WHERE id=?", self.id)
.execute(db)
.await
.unwrap(); //Okay, because we can only create a User of a valid id
ActivityBuilder::new(&format!("User {self} wurde von {deleted_by} gelöscht."))
.user(self)
.save(db)
.await;
}
pub async fn get_days(&self, db: &SqlitePool) -> Vec<Day> {
@@ -480,13 +637,122 @@ ORDER BY last_access DESC
AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD
}
}
pub(crate) async fn close_thousands_trip(&self, db: &SqlitePool) -> Option<String> {
let rowed_km = Stat::person(db, None, self).await.rowed_km;
if rowed_km % 1000 > 970 {
return Some(format!(
"{} braucht nur mehr {} km bis die {} km voll sind 🤑",
self.name,
1000 - rowed_km % 1000,
rowed_km + 1000 - (rowed_km % 1000)
));
}
None
}
pub(crate) async fn received_new_logentry(
&self,
db: &mut Transaction<'_, Sqlite>,
smtp_pw: &str,
) {
if self.has_role_tx(db, "scheckbuch").await {
let amount_trips = Logbook::completed_with_user_tx(db, self).await.len();
match amount_trips {
5 => {
if let Some(mail) = &self.mail {
let _ = self.send_end_mail_scheckbuch(db, mail, smtp_pw).await;
}
Notification::create_for_steering_people_tx(
db,
&format!(
"Liebe Steuerberechtigte, {} hat alle Ausfahrten des Scheckbuchs absolviert. Hoffentlich können wir uns bald über ein neues Mitglied freuen :-)",
self.name
),
"Scheckbuch fertig",
None,None
)
.await;
ActivityBuilder::new(&format!("5 Scheckbuchausfahrten von {self} wurden mit der heutigen Ausfahrt aufgebraucht. Info-Mail wurde an {self} geschickt + alle Steuerberechtigten informiert, dass wir pot. ein neues Mitglied haben"))
.user(self)
.save_tx(db)
.await;
}
a if a > 5 => {
let board = Role::find_by_name_tx(db, "Vorstand").await.unwrap();
Notification::create_for_role_tx(
db,
&board,
&format!(
"Lieber Vorstand, {} hat nun bereits die {}. seiner 5 Scheckbuchausfahrten absolviert.",
self.name, amount_trips
),
"Scheckbuch überfertig",
None,None
)
.await;
ActivityBuilder::new(&format!("{self} hat nun bereits die {amount_trips}. seiner 5 Scheckbuchausfahrten absolviert. Vorstand wurde via Notification informiert."))
.user(self)
.save_tx(db)
.await;
}
_ => {}
}
}
// check fahrtenabzeichen fertig
if rowingbadge::Status::completed_with_last_log(db, self).await {
let board = Role::find_by_name_tx(db, "Vorstand").await.unwrap();
Notification::create_for_role_tx(
db,
&board,
&format!(
"Lieber Vorstand, zur Info: {} hat gerade alle Anforderungen für das diesjährige Fahrtenabzeichen erfüllt.",
self.name
),
"Fahrtenabzeichen geschafft",
None,None
)
.await;
ActivityBuilder::new(&format!(
"{self} hat das heurige Fahrtenabzeichen geschafft! Der Vorstand + {self} wurde via Notification informiert."
))
.user(self)
.save_tx(db)
.await;
Notification::create_with_tx(db, self, "Mit deiner letzten Ausfahrt hast du nun alle Anforderungen für das heurige Fahrtenzeichen erfüllt. Gratuliere! 🎉", "Fahrtenabzeichen geschafft", None, None).await;
}
// check äquatorpreis geschafft?
if let Some(level) = equatorprice::new_level_with_last_log(db, self).await {
let board = Role::find_by_name_tx(db, "Vorstand").await.unwrap();
Notification::create_for_role_tx(
db,
&board,
&format!(
"Lieber Vorstand, zur Info: {} hat gerade alle Anforderungen für den Äquatorpreis in {level} geschafft.",
self.name
),
"Äquatorpreis",
None,None
)
.await;
ActivityBuilder::new(&format!("{self} hat den Äquatorpreis in {level} geschafft! Der Vorstand + {self} wurde via Notification informiert."))
.user(self)
.save_tx(db)
.await;
Notification::create_with_tx(db, self, &format!("Mit deiner letzten Ausfahrt erfüllst du nun alle Anforderungen für den Äquatorpreis in {level}. Gratuliere! 🎉"), "Äquatorpreis", None, None).await;
}
}
}
#[async_trait]
impl<'r> FromRequest<'r> for User {
type Error = LoginError;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
async fn from_request(req: &'r Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
match req.cookies().get_private("loggedin_user") {
Some(user_id) => match user_id.value().parse::<i32>() {
Ok(user_id) => {
@@ -513,14 +779,15 @@ impl<'r> FromRequest<'r> for User {
}
/// Creates a struct named $name. Allows to be created from a user, if one of the specified $roles are active for the user.
#[macro_export]
macro_rules! special_user {
($name:ident, $($role:tt)*) => {
#[derive(Debug)]
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct $name {
pub(crate) user: User,
}
impl Deref for $name {
impl std::ops::Deref for $name {
type Target = User;
fn deref(&self) -> &Self::Target {
&self.user
@@ -534,33 +801,39 @@ macro_rules! special_user {
}
#[async_trait]
impl<'r> FromRequest<'r> for $name {
type Error = LoginError;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
impl<'r> rocket::request::FromRequest<'r> for $name {
type Error = $crate::model::user::LoginError;
async fn from_request(req: &'r rocket::request::Request<'_>) -> rocket::request::Outcome<Self, Self::Error> {
let db = req.rocket().state::<SqlitePool>().unwrap();
match User::from_request(req).await {
Outcome::Success(user) => {
rocket::request::Outcome::Success(user) => {
if special_user!(@check_roles user, db, $($role)*) {
Outcome::Success($name { user })
rocket::request::Outcome::Success($name { user })
} else {
Outcome::Forward(Status::Forbidden)
rocket::request::Outcome::Forward(rocket::http::Status::Forbidden)
}
}
Outcome::Error(f) => Outcome::Error(f),
Outcome::Forward(f) => Outcome::Forward(f),
rocket::request::Outcome::Error(f) => rocket::request::Outcome::Error(f),
rocket::request::Outcome::Forward(f) => rocket::request::Outcome::Forward(f),
}
}
}
impl $name {
pub async fn new(db: &SqlitePool, user: User) -> Option<Self> {
pub async fn new(db: &SqlitePool, user: &User) -> Option<Self> {
if special_user!(@check_roles user, db, $($role)*) {
Some($name { user })
Some($name { user: user.clone() })
} else {
None
}
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name)
}
}
};
(@check_roles $user:ident, $db:ident, $(+$role:expr),* $(,-$neg_role:expr)*) => {
{
@@ -578,17 +851,71 @@ macro_rules! special_user {
};
}
special_user!(SteeringUser, +"cox");
special_user!(TechUser, +"tech");
special_user!(ErgoUser, +"ergo");
special_user!(SteeringUser, +"cox", +"Bootsführer");
special_user!(AdminUser, +"admin");
special_user!(AllowedForPlannedTripsUser, +"Donau Linz", +"scheckbuch", +"Förderndes Mitglied");
special_user!(DonauLinzUser, +"Donau Linz", +"Förderndes Mitglied", -"Unterstützend"); // TODO:
// remove ->
// RegularUser
special_user!(SchnupperBetreuerUser, +"schnupper-betreuer");
special_user!(VorstandUser, +"admin", +"Vorstand");
special_user!(EventUser, +"manage_events");
special_user!(ManageUserUser, +"admin");
special_user!(AllowedToEditPaymentStatusUser, +"kassier", +"admin");
special_user!(ManageUserUser, +"admin", +"schriftfuehrer");
special_user!(AllowedToSendFeeReminderUser, +"admin", +"schriftfuehrer", +"kassier");
special_user!(AllowedToUpdateTripToAlwaysBeShownUser, +"admin");
special_user!(AllowedToUpdateBoathouse, +"admin", +"Vorstand", +"tech");
#[derive(FromRow, Serialize, Deserialize, Clone, Debug)]
pub struct UserWithRolesAndMembershipPdf {
#[serde(flatten)]
pub user: User,
pub membership_pdf: bool,
pub roles: Vec<String>, // TODO: remove
pub proper_roles: Vec<Role>,
}
impl UserWithRolesAndMembershipPdf {
pub(crate) async fn from_user(db: &SqlitePool, user: User) -> Self {
let membership_pdf = user.has_membership_pdf(db).await;
Self {
roles: user.roles(db).await,
proper_roles: user.real_roles(db).await,
user,
membership_pdf,
}
}
}
#[derive(FromRow, Serialize, Deserialize, Clone, Debug)]
pub struct UserWithMembershipPdf {
#[serde(flatten)]
pub user: User,
pub membership_pdf: Option<Vec<u8>>,
}
impl UserWithMembershipPdf {
pub(crate) async fn from(db: &SqlitePool, user: User) -> Self {
let membership_pdf: Option<Vec<u8>> =
sqlx::query_scalar!("SELECT membership_pdf FROM user WHERE id = $1", user.id)
.fetch_optional(db)
.await
.unwrap()
.unwrap();
Self {
user,
membership_pdf,
}
}
}
#[cfg(test)]
mod test {
use std::collections::HashMap;
use crate::{tera::admin::user::UserEditForm, testdb};
use crate::{model::user::ManageUserUser, testdb};
use super::User;
use sqlx::SqlitePool;
@@ -635,41 +962,6 @@ mod test {
assert_eq!(res.len(), 4);
}
#[sqlx::test]
fn test_succ_create() {
let pool = testdb!();
User::create(&pool, "new-user-name".into()).await;
}
#[sqlx::test]
fn test_duplicate_name_create() {
let pool = testdb!();
User::create(&pool, "admin".into()).await;
}
#[sqlx::test]
fn test_update() {
let pool = testdb!();
let user = User::find_by_id(&pool, 1).await.unwrap();
user.update(
&pool,
UserEditForm {
id: 1,
name: "adminn".to_string(),
roles: HashMap::new(),
},
)
.await
.unwrap();
let user = User::find_by_id(&pool, 1).await.unwrap();
assert_eq!(user.name, "adminn".to_string());
}
#[sqlx::test]
fn succ_login_with_test_db() {
let pool = testdb!();
@@ -698,8 +990,9 @@ mod test {
fn reset() {
let pool = testdb!();
let user = User::find_by_id(&pool, 1).await.unwrap();
let changed_by = ManageUserUser::new(&pool, &user).await.unwrap();
user.reset_pw(&pool).await;
user.reset_pw(&pool, &changed_by).await;
let user = User::find_by_id(&pool, 1).await.unwrap();
assert_eq!(user.pw, None);

157
src/model/user/regular.rs Normal file
View File

@@ -0,0 +1,157 @@
use super::{ManageUserUser, User};
use crate::{
model::{activity::ActivityBuilder, mail::Mail, notification::Notification, role::Role},
special_user, NonEmptyString,
};
use chrono::NaiveDate;
use rocket::{async_trait, fs::TempFile, tokio::io::AsyncReadExt};
use sqlx::SqlitePool;
special_user!(RegularUser, +"Donau Linz");
pub trait ClubMember {
async fn create_member(
db: &SqlitePool,
created_by: &ManageUserUser,
role: &Role,
name: NonEmptyString,
mail: &str,
financial: Option<Role>,
birthdate: &NaiveDate,
member_since: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<User, String> {
if membership_pdf.len() == 0 {
return Err("Keine Beitrittserklärung mitgeschickt.".to_string());
}
let mut stream = membership_pdf.open().await.unwrap();
let mut buffer = Vec::new();
stream.read_to_end(&mut buffer).await.unwrap();
let name = name.as_str();
let phone = phone.as_str();
let address = address.as_str();
sqlx::query!(
"INSERT INTO user(name, member_since_date, birthdate, mail, phone, address, membership_pdf)
VALUES (?,?,?,?,?,?,?)",
name, member_since, birthdate, mail, phone, address,buffer
)
.execute(db)
.await
.map_err(|e| e.to_string())?;
let user = User::find_by_name(db, name).await.unwrap();
user.change_financial(db, created_by, financial).await?;
user.add_role(db, created_by, role).await?;
ActivityBuilder::new(&format!(
"{created_by} hat Mitglied {user} mit der Rolle {role} angelegt."
))
.user(&user)
.save(db)
.await;
Ok(user)
}
}
impl ClubMember for RegularUser {}
impl RegularUser {
pub(crate) async fn send_welcome_mail_to_user(
&self,
db: &SqlitePool,
smtp_pw: &str,
) -> Result<(), String> {
let Some(mail) = &self.mail else {
return Err(format!(
"Couldn't send welcome mail, as the user {self} has no mail..."
));
};
Mail::send_single(
db,
mail,
"Willkommen im ASKÖ Ruderverein Donau Linz!",
format!(
"Hallo {self},
herzlich willkommen im ASKÖ Ruderverein Donau Linz! Wir freuen uns sehr, dich als neues Mitglied in unserem Verein begrüßen zu dürfen.
Um dir den Einstieg zu erleichtern, findest du in unserem Handbuch alle wichtigen Informationen über unseren Verein: https://rudernlinz.at/book. Bei weiteren Fragen stehen dir die Adressen info@rudernlinz.at (für allgemeine Fragen) und it@rudernlinz.at (bei technischen Fragen) jederzeit zur Verfügung.
Du kannst auch gerne unserer Signal-Gruppe beitreten, um auf dem Laufenden zu bleiben und dich mit anderen Mitgliedern auszutauschen: https://signal.group/#CjQKICFrq6zSsRHxrucS3jEcQn6lknEXacAykwwLV3vNLKxPEhA17jxz7cpjfu3JZokLq1TH
Für die Organisation unserer Ausfahrten nutzen wir app.rudernlinz.at. Logge dich einfach mit deinem Namen ('{self}' ohne Anführungszeichen) ein, beim ersten Mal kannst du das Passwortfeld leer lassen. Unter 'Geplante Ausfahrten' kannst du dich jederzeit zu den Ausfahrten anmelden.
Beim nächsten Treffen im Verein, erinnere jemand vom Vorstand (https://rudernlinz.at/unser-verein/vorstand/) bitte daran, deinen Fingerabdruck zu registrieren, damit du Zugang zum Bootshaus erhältst.
Damit du dich noch mehr verbunden fühlst (:-)), haben wir im Bootshaus ein WLAN für Vereinsmitglieder 'ASKÖ Ruderverein Donau Linz' eingerichtet. Das Passwort dafür lautet 'donau1921' (ohne Anführungszeichen). Bitte gib das Passwort an keine vereinsfremden Personen weiter.
Falls du deinen Mitgliedsbeitrag noch nicht bezahlt hast, erledige dies bitte demnächst. Den genauen Betrag und einen QR Code, den du mit deiner Bankapp scannen kannst findest du unter https://app.rudernlinz.at/planned
Wenn du alle Ausfahrten, zu denen du dich angemeldet hast in deinem eigenen Kalender sehen willst, füge folgenden Link hinzu: https://app.rudernlinz.at/cal/personal/{}/{}
Wir freuen uns darauf, dich bald am Wasser zu sehen und gemeinsam tolle Erfahrungen zu sammeln!
Riemen- & Dollenbruch
ASKÖ Ruderverein Donau Linz", self.user.id, self.user.user_token),
smtp_pw,
).await?;
ActivityBuilder::new(&format!("Willkommensmail für {self} wurde an {mail} verschickt (Handbuch, Signal-Gruppe, App-Info, Fingerprint, WLAN)."))
.user(self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn create(
db: &SqlitePool,
created_by: &ManageUserUser,
smtp_pw: &str,
name: NonEmptyString,
mail: &str,
financial: Option<Role>,
birthdate: &NaiveDate,
member_since: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
let role = Role::find_by_name(db, "Donau Linz").await.unwrap();
let user = Self::create_member(
db,
created_by,
&role,
name,
mail,
financial,
birthdate,
member_since,
phone,
address,
membership_pdf,
)
.await?;
let user = Self::new(db, &user).await.unwrap();
user.send_welcome_mail_to_user(db, smtp_pw).await?;
Notification::create_for_steering_people(
db,
&format!("Liebe Steuerberechtigte, es gibt ein neues Mitglied: {user} 🎉"),
"Neues Vereinsmitglied",
None,
None,
)
.await;
Ok(())
}
}

View File

@@ -0,0 +1,304 @@
use super::foerdernd::FoerderndUser;
use super::regular::RegularUser;
use super::unterstuetzend::UnterstuetzendUser;
use super::{ManageUserUser, User};
use crate::NonEmptyString;
use crate::model::activity::ActivityBuilder;
use crate::model::role::Role;
use crate::{
SCHECKBUCH,
model::{mail::Mail, notification::Notification},
special_user,
};
use chrono::NaiveDate;
use rocket::async_trait;
use rocket::fs::TempFile;
use sqlx::SqlitePool;
special_user!(ScheckbuchUser, +"scheckbuch");
impl ScheckbuchUser {
async fn set_data_for_clubmember(
&self,
db: &SqlitePool,
changed_by: &ManageUserUser,
member_since: &NaiveDate,
birthdate: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
self.user.update_birthdate(db, changed_by, birthdate).await;
self.user
.update_member_since(db, changed_by, member_since)
.await;
self.user.update_phone(db, changed_by, &phone).await;
self.user.update_address(db, changed_by, &address).await;
self.user
.add_membership_pdf(db, changed_by, membership_pdf)
.await?;
Ok(())
}
pub(crate) async fn convert_to_regular_user(
self,
db: &SqlitePool,
smtp_pw: &str,
changed_by: &ManageUserUser,
member_since: &NaiveDate,
birthdate: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
self.set_data_for_clubmember(
db,
changed_by,
member_since,
birthdate,
phone,
address,
membership_pdf,
)
.await?;
// Change roles
let regular = Role::find_by_name(db, "Donau Linz").await.unwrap();
let scheckbook = Role::find_by_name(db, "scheckbuch").await.unwrap();
self.user.remove_role(db, changed_by, &scheckbook).await?;
self.user.add_role(db, changed_by, &regular).await?;
// Notify
let regular = RegularUser::new(db, &self.user).await.unwrap();
regular.send_welcome_mail_to_user(db, smtp_pw).await?;
Notification::create_for_steering_people(
db,
&format!(
"Liebe Steuerberechtigte, {} hatte ein Scheckbuch und ist nun seit {} ein neues reguläres Mitglied. 🎉",
self.name,
member_since
),
"Neues Vereinsmitglied",
None,
None,
)
.await;
ActivityBuilder::new(&format!(
"{changed_by} hat den Scheckbuch-User {self} auf ein reguläres Mitglied upgegraded! Die Steuerpersonen wurden via Notification informiert."
))
.user(&self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn convert_to_unterstuetzend_user(
self,
db: &SqlitePool,
smtp_pw: &str,
changed_by: &ManageUserUser,
member_since: &NaiveDate,
birthdate: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
// Set data
self.set_data_for_clubmember(
db,
changed_by,
member_since,
birthdate,
phone,
address,
membership_pdf,
)
.await?;
// Change roles
let unterstuetzend = Role::find_by_name(db, "Unterstützend").await.unwrap();
let scheckbook = Role::find_by_name(db, "scheckbuch").await.unwrap();
self.user.remove_role(db, changed_by, &scheckbook).await?;
self.user.add_role(db, changed_by, &unterstuetzend).await?;
let unterstuetzend = UnterstuetzendUser::new(db, &self.user).await.unwrap();
unterstuetzend
.send_welcome_mail_to_user(db, smtp_pw)
.await?;
if let Some(vorstand) = Role::find_by_name(db, "vorstand").await {
Notification::create_for_role(
db,
&vorstand,
&format!(
"Lieber Vorstand, {} hatte ein Scheckbuch und ist nun seit {} ein neues unterstützendes Mitglied.",
self.name,
member_since
),
"Neues unterstützendes Vereinsmitglied",
None,
None,
)
.await;
}
ActivityBuilder::new(&format!("{changed_by} hat den Scheckbuch-User {self} auf ein unterstützendes Mitglied upgegraded!"))
.user(&self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn convert_to_foerdernd_user(
self,
db: &SqlitePool,
smtp_pw: &str,
changed_by: &ManageUserUser,
member_since: &NaiveDate,
birthdate: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
// Set data
self.set_data_for_clubmember(
db,
changed_by,
member_since,
birthdate,
phone,
address,
membership_pdf,
)
.await?;
// Change roles
let unterstuetzend = Role::find_by_name(db, "Förderndes Mitglied").await.unwrap();
let scheckbook = Role::find_by_name(db, "scheckbuch").await.unwrap();
self.user.remove_role(db, changed_by, &scheckbook).await?;
self.user.add_role(db, changed_by, &unterstuetzend).await?;
let foerdernd = FoerderndUser::new(db, &self.user).await.unwrap();
foerdernd.send_welcome_mail_to_user(db, smtp_pw).await?;
if let Some(vorstand) = Role::find_by_name(db, "vorstand").await {
Notification::create_for_role(
db,
&vorstand,
&format!(
"Lieber Vorstand, {} hatte ein Scheckbuch und ist nun seit {} ein neues förderndes Mitglied.",
self.name,
member_since
),
"Neues förderndes Vereinsmitglied",
None,
None,
)
.await;
}
ActivityBuilder::new(&format!(
"{changed_by} hat den Scheckbuch-User {self} auf ein förderndes Mitglied upgegraded!"
))
.user(&self)
.save(db)
.await;
Ok(())
}
// TODO: make private
pub(crate) async fn notify(&self, db: &SqlitePool, smtp_pw: &str) -> Result<(), String> {
self.notify_coxes_about_new_scheckbuch(db).await;
self.send_welcome_mail_to_user(db, smtp_pw).await?;
ActivityBuilder::new(&format!(
"{self} hat eine Info-Mail bekommen (Erklärung Scheckbuch, Ruderapp) und alle Steuerberechtigten wurden informiert."
))
.user(self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn send_welcome_mail_to_user(
&self,
db: &SqlitePool,
smtp_pw: &str,
) -> Result<(), String> {
let Some(mail) = &self.mail else {
return Err(
"Kann Mail nicht versenden, weil der User keine Mailadresse hinterlegt hat.".into(),
);
};
Mail::send_single(
db,
mail,
"ASKÖ Ruderverein Donau Linz | Dein Scheckbuch wartet auf Dich",
format!(
"Hallo {0},
herzlich willkommen beim ASKÖ Ruderverein Donau Linz! Wir freuen uns sehr, dass Du Dich entschieden hast, das Rudern bei uns auszuprobieren. Mit Deinem Scheckbuch kannst Du jetzt an fünf Ausfahrten teilnehmen und so diesen Sport in seiner vollen Vielfalt erleben. Falls du die {1} € noch nicht bezahlt hast, nimm diese bitte zur nächsten Ausfahrt mit (oder überweise sie auf unser Bankkonto [dieses findest du auf https://rudernlinz.at]).
Für die Organisation unserer Ausfahrten nutzen wir app.rudernlinz.at. Logge Dich bitte mit Deinem Namen ('{0}', ohne Anführungszeichen) ein. Beim ersten Mal kannst Du das Passwortfeld leer lassen. Unter 'Geplante Ausfahrten' kannst Du Dich jederzeit für eine Ausfahrt anmelden. Wir bieten mindestens einmal pro Woche Ausfahrten an, sowohl für Anfänger als auch für Fortgeschrittene (A+F Rudern). Zusätzliche Ausfahrten werden von unseren Steuerleuten ausgeschrieben, öfters reinschauen kann sich also lohnen :-)
Nach deinen 5 Ausfahrten würden wir uns freuen, dich als Mitglied in unserem Verein begrüßen zu dürfen.
Wir freuen uns darauf, Dich bald am Wasser zu sehen und gemeinsam tolle Erfahrungen zu sammeln!
Riemen- & Dollenbruch,
ASKÖ Ruderverein Donau Linz", self.name, SCHECKBUCH/100),
smtp_pw,
).await?;
Ok(())
}
async fn notify_coxes_about_new_scheckbuch(&self, db: &SqlitePool) {
Notification::create_for_steering_people(
db,
&format!(
"Liebe Steuerberechtigte, {} hat nun ein Scheckbuch. Wie immer, freuen wir uns wenn du uns beim A+F Rudern unterstützt oder selber Ausfahrten ausschreibst. Bitte beachte, dass Scheckbuch-Personen nur Ausfahrten sehen, bei denen 'Scheckbuch-Anmeldungen erlauben' ausgewählt wurde.",
self.name
),
"Neues Scheckbuch",
None,None
)
.await;
}
pub(crate) async fn create(
db: &SqlitePool,
created_by: &ManageUserUser,
smtp_pw: &str,
name: NonEmptyString,
mail: &str,
) -> Result<(), String> {
let role = Role::find_by_name(db, "scheckbuch").await.unwrap();
let name = name.as_str();
sqlx::query!(
"INSERT INTO user(name, mail)
VALUES (?,?)",
name,
mail
)
.execute(db)
.await
.map_err(|e| e.to_string())?;
let user = User::find_by_name(db, name).await.unwrap();
user.add_role(db, created_by, &role).await?;
let user = Self::new(db, &user).await.unwrap();
user.notify(db, smtp_pw).await?;
ActivityBuilder::new(&format!("{created_by} hat Scheckbuch {user} angelegt."))
.user(&user)
.save(db)
.await;
Ok(())
}
}

View File

@@ -0,0 +1,432 @@
use super::foerdernd::FoerderndUser;
use super::regular::RegularUser;
use super::scheckbuch::ScheckbuchUser;
use super::schnupperinterest::SchnupperInterestUser;
use super::unterstuetzend::UnterstuetzendUser;
use super::{ManageUserUser, User};
use crate::NonEmptyString;
use crate::model::activity::ActivityBuilder;
use crate::model::role::Role;
use crate::{
model::{mail::Mail, notification::Notification},
special_user,
};
use chrono::NaiveDate;
use rocket::async_trait;
use rocket::fs::TempFile;
use sqlx::SqlitePool;
special_user!(SchnupperantUser, +"schnupperant");
impl SchnupperantUser {
async fn set_data_for_clubmember(
&self,
db: &SqlitePool,
changed_by: &ManageUserUser,
member_since: &NaiveDate,
birthdate: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
self.user.update_birthdate(db, changed_by, birthdate).await;
self.user
.update_member_since(db, changed_by, member_since)
.await;
self.user.update_phone(db, changed_by, &phone).await;
self.user.update_address(db, changed_by, &address).await;
self.user
.add_membership_pdf(db, changed_by, membership_pdf)
.await?;
Ok(())
}
pub(crate) async fn convert_to_regular_user(
self,
db: &SqlitePool,
smtp_pw: &str,
changed_by: &ManageUserUser,
member_since: &NaiveDate,
birthdate: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
self.set_data_for_clubmember(
db,
changed_by,
member_since,
birthdate,
phone,
address,
membership_pdf,
)
.await?;
// Change roles
let paid = Role::find_by_name(db, "paid").await.unwrap();
if self.user.remove_role(db, changed_by, &paid).await.is_err() {
self.remove_membership_pdf(db, changed_by).await;
return Err("Kann noch kein normales Mitglied werden, da die Schnupperkurs-Gebühr noch nicht bezahlt wurde.".into());
}
let scheckbook = Role::find_by_name(db, "schnupperant").await.unwrap();
self.user.remove_role(db, changed_by, &scheckbook).await?;
let regular = Role::find_by_name(db, "Donau Linz").await.unwrap();
self.user.add_role(db, changed_by, &regular).await?;
let participated_schnupperkurs = Role::find_by_name(db, "participated_schnupperkurs")
.await
.unwrap();
self.user
.add_role(db, changed_by, &participated_schnupperkurs)
.await?;
// Notify
let regular = RegularUser::new(db, &self.user).await.unwrap();
regular.send_welcome_mail_to_user(db, smtp_pw).await?;
Notification::create_for_steering_people(
db,
&format!(
"Liebe Steuerberechtigte, {} nahm an unserem Schnupperkurs teil und ist nun seit {member_since} ein neues reguläres Mitglied. 🎉",
self.name
),
"Neues Vereinsmitglied",
None,
None,
)
.await;
ActivityBuilder::new(&format!(
"{changed_by} hat den Schnupperant {self} auf ein reguläres Mitglied upgegraded!"
))
.user(&self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn move_to_scheckbook(
self,
db: &SqlitePool,
changed_by: &ManageUserUser,
smtp_pw: &str,
) -> Result<(), String> {
let schnupperant = Role::find_by_name(db, "schnupperant").await.unwrap();
let scheckbook = Role::find_by_name(db, "scheckbuch").await.unwrap();
self.user.remove_role(db, changed_by, &schnupperant).await?;
self.user.add_role(db, changed_by, &scheckbook).await?;
if let Some(no_einschreibgebuehr) = Role::find_by_name(db, "no-einschreibgebuehr").await {
self.add_role(db, changed_by, &no_einschreibgebuehr)
.await
.expect("role doesn't have a group");
}
let scheckbook = ScheckbuchUser::new(db, &self.user).await.unwrap();
scheckbook.notify(db, smtp_pw).await?;
Notification::create_for_steering_people(
db,
&format!(
"Liebe Steuerberechtigte, {} hat unseren Schnupperkurs absolviert und nun ein Scheckbuch. Wie immer, freuen wir uns wenn du uns beim A+F Rudern unterstützt oder selber Ausfahrten ausschreibst. Bitte beachte, dass Scheckbuch-Personen nur Ausfahrten sehen, bei denen 'Scheckbuch-Anmeldungen erlauben' ausgewählt wurde.",
self.name
),
"Neues Scheckbuch",
None,None
)
.await;
ActivityBuilder::new(&format!(
"{changed_by} hat dem ehemaligen Schnupperant {self} nun ein Scheckbuch gegeben"
))
.user(&self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn move_to_schnupperinterest(
self,
db: &SqlitePool,
changed_by: &ManageUserUser,
) -> Result<(), String> {
let schnupperinterest = Role::find_by_name(db, "schnupper-interessierte")
.await
.unwrap();
let schnupperant = Role::find_by_name(db, "schnupperant").await.unwrap();
self.user.remove_role(db, changed_by, &schnupperant).await?;
self.user
.add_role(db, changed_by, &schnupperinterest)
.await?;
let schnupperinterest = SchnupperInterestUser::new(db, &self.user).await.unwrap();
schnupperinterest.notify(db).await?;
if let Some(role) = Role::find_by_name(db, "schnupper-betreuer").await {
Notification::create_for_role(
db,
&role,
&format!(
"Lieber Schnupperbetreuer, {} hat sich vom Schnupperkurs abgemeldet.",
self.name
),
"Schnupperkurs Abmeldung",
None,
None,
)
.await;
}
ActivityBuilder::new(&format!(
"{changed_by} hat dem eigentlichen Schnupperanten {self} wieder auf die 'Interessierten'-Liste zurückgegeben."
))
.user(&self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn convert_to_unterstuetzend_user(
self,
db: &SqlitePool,
smtp_pw: &str,
changed_by: &ManageUserUser,
member_since: &NaiveDate,
birthdate: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
// Set data
self.set_data_for_clubmember(
db,
changed_by,
member_since,
birthdate,
phone,
address,
membership_pdf,
)
.await?;
// Change roles
let paid = Role::find_by_name(db, "paid").await.unwrap();
if self.user.remove_role(db, changed_by, &paid).await.is_err() {
self.remove_membership_pdf(db, changed_by).await;
return Err("Kann noch kein normales Mitglied werden, da die Schnupperkurs-Gebühr noch nicht bezahlt wurde.".into());
}
let unterstuetzend = Role::find_by_name(db, "Unterstützend").await.unwrap();
let scheckbook = Role::find_by_name(db, "schnupperant").await.unwrap();
self.user.remove_role(db, changed_by, &scheckbook).await?;
self.user.add_role(db, changed_by, &unterstuetzend).await?;
if let Some(no_einschreibgebuehr) = Role::find_by_name(db, "no-einschreibgebuehr").await {
self.add_role(db, changed_by, &no_einschreibgebuehr)
.await
.expect("role doesn't have a group");
}
let unterstuetzend = UnterstuetzendUser::new(db, &self.user).await.unwrap();
unterstuetzend
.send_welcome_mail_to_user(db, smtp_pw)
.await?;
if let Some(vorstand) = Role::find_by_name(db, "vorstand").await {
Notification::create_for_role(
db,
&vorstand,
&format!(
"Lieber Vorstand, {} nahm am Schnupperkurs teil und ist nun seit {} es ein neues unterstützendes Mitglied.",
self.name,
self.member_since_date.clone().unwrap()
),
"Neues unterstützendes Vereinsmitglied",
None,
None,
)
.await;
}
ActivityBuilder::new(&format!(
"{changed_by} hat den Schnupperant {self} auf ein unterstützendes Mitglied upgegraded!"
))
.user(&self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn convert_to_foerdernd_user(
self,
db: &SqlitePool,
smtp_pw: &str,
changed_by: &ManageUserUser,
member_since: &NaiveDate,
birthdate: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
// Set data
self.set_data_for_clubmember(
db,
changed_by,
member_since,
birthdate,
phone,
address,
membership_pdf,
)
.await?;
// Change roles
let paid = Role::find_by_name(db, "paid").await.unwrap();
if self.user.remove_role(db, changed_by, &paid).await.is_err() {
self.remove_membership_pdf(db, changed_by).await;
return Err("Kann noch kein normales Mitglied werden, da die Schnupperkurs-Gebühr noch nicht bezahlt wurde.".into());
}
let unterstuetzend = Role::find_by_name(db, "Förderndes Mitglied").await.unwrap();
let scheckbook = Role::find_by_name(db, "schnupperant").await.unwrap();
self.user.remove_role(db, changed_by, &scheckbook).await?;
self.user.add_role(db, changed_by, &unterstuetzend).await?;
if let Some(no_einschreibgebuehr) = Role::find_by_name(db, "no-einschreibgebuehr").await {
self.add_role(db, changed_by, &no_einschreibgebuehr)
.await
.expect("role doesn't have a group");
}
let foerdernd = FoerderndUser::new(db, &self.user).await.unwrap();
foerdernd.send_welcome_mail_to_user(db, smtp_pw).await?;
if let Some(vorstand) = Role::find_by_name(db, "vorstand").await {
Notification::create_for_role(
db,
&vorstand,
&format!(
"Lieber Vorstand, {} nahm am Schnupperkurs teil und ist nun seit {} es ein neues förderndes Mitglied.",
self.name,
self.member_since_date.clone().unwrap()
),
"Neues förderndes Vereinsmitglied",
None,
None,
)
.await;
}
ActivityBuilder::new(&format!(
"{changed_by} hat den Schnupperant {self} auf ein förderndes Mitglied upgegraded!"
))
.user(&self)
.save(db)
.await;
Ok(())
}
// TODO: make private
pub(crate) async fn notify(&self, db: &SqlitePool, smtp_pw: &str) -> Result<(), String> {
self.notify_coxes_about_new_schnupperant(db).await;
self.send_welcome_mail_to_user(db, smtp_pw).await?;
ActivityBuilder::new(&format!(
"{self} hat eine Mail bekommen (Inhalt: wir freuen uns auf ihn + senden detailliertere Infos später zu) und die Schnupperbetreuer wurden via Notification informiert."
))
.user(self)
.save(db)
.await;
Ok(())
}
async fn send_welcome_mail_to_user(
&self,
db: &SqlitePool,
smtp_pw: &str,
) -> Result<(), String> {
let Some(mail) = &self.mail else {
return Err(format!(
"Couldn't send mail, because user {self} has no mail"
));
};
Mail::send_single(
db,
mail,
"ASKÖ Ruderverein Donau Linz | Anmeldung Schnupperkurs",
format!(
"Hallo {0},
es freut uns sehr, dich bei unserem Schnupperkurs willkommen heißen zu dürfen.
Bitte überweise die {1} € auf unser Bankkonto (IBAN: AT58 2032 0321 0072 9256) und gib beim Verwendungszweck 'Schnupperkurs {0}' an.
Detaillierte Informationen folgen noch, du wirst sie ein paar Tage vor dem Termin bekommen (wenn das Wetter/Wasserstand/... abschätzbar ist).
Riemen- & Dollenbruch,
ASKÖ Ruderverein Donau Linz",
self.name,
self.fee(db).await.unwrap().sum_in_cents/100
),
smtp_pw,
)
.await?;
Ok(())
}
async fn notify_coxes_about_new_schnupperant(&self, db: &SqlitePool) {
if let Some(role) = Role::find_by_name(db, "schnupper-betreuer").await {
Notification::create_for_role(
db,
&role,
&format!(
"Lieber Schnupperbetreuer, {} hat sich zum Schnupperkurs angemeldet.",
self.name
),
"Neuer Schnupperant",
None,
None,
)
.await;
}
}
pub(crate) async fn create(
db: &SqlitePool,
created_by: &ManageUserUser,
smtp_pw: &str,
name: NonEmptyString,
mail: &str,
) -> Result<(), String> {
let role = Role::find_by_name(db, "schnupperant").await.unwrap();
let name = name.as_str();
sqlx::query!(
"INSERT INTO user(name, mail)
VALUES (?,?)",
name,
mail
)
.execute(db)
.await
.map_err(|e| e.to_string())?;
let user = User::find_by_name(db, name).await.unwrap();
user.add_role(db, created_by, &role).await?;
let user = Self::new(db, &user).await.unwrap();
user.notify(db, smtp_pw).await?;
ActivityBuilder::new(&format!(
"{created_by} hat {user} zur fixen Schnupperkurs-Anmeldung hinzugefügt."
))
.user(&user)
.save(db)
.await;
Ok(())
}
}

View File

@@ -0,0 +1,162 @@
use super::scheckbuch::ScheckbuchUser;
use super::schnupperant::SchnupperantUser;
use super::{ManageUserUser, User};
use crate::NonEmptyString;
use crate::model::activity::ActivityBuilder;
use crate::model::role::Role;
use crate::{model::notification::Notification, special_user};
use rocket::async_trait;
use sqlx::SqlitePool;
special_user!(SchnupperInterestUser, +"schnupper-interessierte");
impl SchnupperInterestUser {
pub(crate) async fn move_to_scheckbook(
self,
db: &SqlitePool,
changed_by: &ManageUserUser,
smtp_pw: &str,
) -> Result<(), String> {
let schnupperinterest = Role::find_by_name(db, "schnupper-interessierte")
.await
.unwrap();
let scheckbook = Role::find_by_name(db, "scheckbuch").await.unwrap();
self.user
.remove_role(db, changed_by, &schnupperinterest)
.await?;
self.user.add_role(db, changed_by, &scheckbook).await?;
let scheckbook = ScheckbuchUser::new(db, &self.user).await.unwrap();
scheckbook.notify(db, smtp_pw).await?;
Notification::create_for_steering_people(
db,
&format!(
"Liebe Steuerberechtigte, {} wollte unseren Schnupperkurs absolviert und nun ein Scheckbuch. Wie immer, freuen wir uns wenn du uns beim A+F Rudern unterstützt oder selber Ausfahrten ausschreibst. Bitte beachte, dass Scheckbuch-Personen nur Ausfahrten sehen, bei denen 'Scheckbuch-Anmeldungen erlauben' ausgewählt wurde.",
self.name
),
"Neues Scheckbuch",
None,
None
)
.await;
ActivityBuilder::new(&format!(
"Der Schnupperinteressierte {self} hat sich (ohne Schnupperkurs) doch gleich direkt für ein Scheckbuch entschieden. {changed_by} hat dieses eingerichtet."
))
.user(&self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn move_to_schnupperant(
self,
db: &SqlitePool,
changed_by: &ManageUserUser,
smtp_pw: &str,
) -> Result<(), String> {
let schnupperinterest = Role::find_by_name(db, "schnupper-interessierte")
.await
.unwrap();
let schnupperant = Role::find_by_name(db, "schnupperant").await.unwrap();
self.user
.remove_role(db, changed_by, &schnupperinterest)
.await?;
self.user.add_role(db, changed_by, &schnupperant).await?;
let schnupperant = SchnupperantUser::new(db, &self.user).await.unwrap();
schnupperant.notify(db, smtp_pw).await?;
if let Some(role) = Role::find_by_name(db, "schnupper-betreuer").await {
Notification::create_for_role(
db,
&role,
&format!(
"Lieber Schnupperbetreuer, {} hat sich zum Schnupperkurs angemeldet.",
self.name
),
"Neuer Schnupper-Interessierte:r",
None,
None,
)
.await;
}
ActivityBuilder::new(&format!(
"Der Schnupperinteressierte {self} hat sich zum Schnupperkurs angemeldet."
))
.user(&self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn notify(&self, db: &SqlitePool) -> Result<(), String> {
self.notify_schnupperbetreuer_about_new_interest(db).await;
ActivityBuilder::new(&format!(
"Der Schnupperbetreuer hat eine Info via Notification bekommen, dass {self} Interesse an einen Schnupperkurs hat."
))
.user(self)
.save(db)
.await;
Ok(())
}
async fn notify_schnupperbetreuer_about_new_interest(&self, db: &SqlitePool) {
if let Some(role) = Role::find_by_name(db, "schnupper-betreuer").await {
Notification::create_for_role(
db,
&role,
&format!(
"Lieber Schnupperbetreuer, {} hat Interesse zum Schnupperkurs bekundet.",
self.name
),
"Neuer Schnupper-Interessierte:r",
None,
None,
)
.await;
}
}
pub(crate) async fn create(
db: &SqlitePool,
created_by: &ManageUserUser,
name: NonEmptyString,
mail: &str,
) -> Result<(), String> {
let role = Role::find_by_name(db, "schnupper-interessierte")
.await
.unwrap();
let name = name.as_str();
sqlx::query!(
"INSERT INTO user(name, mail)
VALUES (?,?)",
name,
mail
)
.execute(db)
.await
.map_err(|e| e.to_string())?;
let user = User::find_by_name(db, name).await.unwrap();
user.add_role(db, created_by, &role).await?;
let user = Self::new(db, &user).await.unwrap();
user.notify(db).await?;
ActivityBuilder::new(&format!(
"{created_by} hat Schnupper-Interessierten {user} angelegt."
))
.user(&user)
.save(db)
.await;
Ok(())
}
}

View File

@@ -0,0 +1,101 @@
use super::{ManageUserUser, User, regular::ClubMember};
use crate::{
NonEmptyString,
model::{activity::ActivityBuilder, mail::Mail, notification::Notification, role::Role},
special_user,
};
use chrono::NaiveDate;
use rocket::{async_trait, fs::TempFile};
use sqlx::SqlitePool;
special_user!(UnterstuetzendUser, +"Unterstützend");
impl ClubMember for UnterstuetzendUser {}
impl UnterstuetzendUser {
pub(crate) async fn send_welcome_mail_to_user(
&self,
db: &SqlitePool,
smtp_pw: &str,
) -> Result<(), String> {
let Some(mail) = &self.mail else {
return Err(format!(
"Couldn't send welcome mail, as the user {self} has no mail..."
));
};
Mail::send_single(
db,
mail,
"Willkommen im ASKÖ Ruderverein Donau Linz!",
format!(
"Hallo {0},
herzlich willkommen im ASKÖ Ruderverein Donau Linz! Wir freuen uns sehr, dich als neues Mitglied in unserem Verein begrüßen zu dürfen.
Um dir den Einstieg zu erleichtern, findest du in unserem Handbuch alle wichtigen Informationen über unseren Verein: https://rudernlinz.at/book. Bei weiteren Fragen stehen dir die Adressen info@rudernlinz.at (für allgemeine Fragen) und it@rudernlinz.at (bei technischen Fragen) jederzeit zur Verfügung.
Damit du dich noch mehr verbunden fühlst (:-)), haben wir im Bootshaus ein WLAN für Vereinsmitglieder 'ASKÖ Ruderverein Donau Linz' eingerichtet. Das Passwort dafür lautet 'donau1921' (ohne Anführungszeichen). Bitte gib das Passwort an keine vereinsfremden Personen weiter.
Riemen- & Dollenbruch
ASKÖ Ruderverein Donau Linz", self.name),
smtp_pw,
).await?;
ActivityBuilder::new(&format!(
"{self} hat eine Mail an {mail} bekommen, mit Infos dass er/sie nun ein unterstützendes Mitglied ist (Handbuch, WLAN)."
))
.user(self)
.save(db)
.await;
Ok(())
}
pub(crate) async fn create(
db: &SqlitePool,
created_by: &ManageUserUser,
smtp_pw: &str,
name: NonEmptyString,
mail: &str,
financial: Option<Role>,
birthdate: &NaiveDate,
member_since: &NaiveDate,
phone: NonEmptyString,
address: NonEmptyString,
membership_pdf: &TempFile<'_>,
) -> Result<(), String> {
let role = Role::find_by_name(db, "Unterstützend").await.unwrap();
let user = Self::create_member(
db,
created_by,
&role,
name,
mail,
financial,
birthdate,
member_since,
phone,
address,
membership_pdf,
)
.await?;
let user = Self::new(db, &user).await.unwrap();
user.send_welcome_mail_to_user(db, smtp_pw).await?;
if let Some(vorstand) = Role::find_by_name(db, "Vorstand").await {
Notification::create_for_role(
db,
&vorstand,
&format!("Lieber Vorstand, es gibt ein neues unterstützendes Mitglied: {user}"),
"Neues unterstützendes Vereinsmitglied",
None,
None,
)
.await;
}
Ok(())
}
}

View File

@@ -1,4 +1,4 @@
use rocket::{form::Form, post, routes, Build, FromForm, Rocket, State};
use rocket::{Build, FromForm, Rocket, State, form::Form, post, routes};
use serde_json::json;
use sqlx::SqlitePool;

View File

@@ -5,7 +5,7 @@ use sqlx::SqlitePool;
use crate::model::waterlevel::{self, Waterlevel};
pub async fn update(db: &SqlitePool) -> Result<(), String> {
/*let mut tx = db.begin().await.unwrap();
let mut tx = db.begin().await.unwrap();
// 1. Delete water levels starting from yesterday
Waterlevel::delete_all(&mut tx).await;
@@ -44,7 +44,7 @@ pub async fn update(db: &SqlitePool) -> Result<(), String> {
}
// 3. Save in DB
tx.commit().await.unwrap();*/
tx.commit().await.unwrap();
Ok(())
}

View File

@@ -96,7 +96,9 @@ struct DailyWeather {
}
fn fetch(api_key: &str) -> Result<Data, String> {
let url = format!("https://api.openweathermap.org/data/3.0/onecall?lat=47.766249&lon=13.367683&units=metric&exclude=current,minutely,hourly,alert&appid={api_key}");
let url = format!(
"https://api.openweathermap.org/data/3.0/onecall?lat=48.31970&lon=14.29451&units=metric&exclude=current,minutely,hourly,alert&appid={api_key}"
);
match ureq::get(&url).call() {
Ok(mut response) => {

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

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

View File

@@ -1,15 +1,18 @@
use rocket::{
FromForm, Route, State,
form::Form,
get, post, put,
response::{Flash, Redirect},
routes, FromForm, Route, State,
routes,
};
use serde::Serialize;
use sqlx::SqlitePool;
use crate::model::{
planned::{
event::{self, Event},
tripdetails::{TripDetails, TripDetailsToAdd},
},
user::EventUser,
};
@@ -45,7 +48,7 @@ async fn create(
)
.await;
Flash::success(Redirect::to("/"), "Event hinzugefügt")
Flash::success(Redirect::to("/planned"), "Event hinzugefügt")
}
//TODO: add constraints (e.g. planned_amount_cox > 0)
@@ -65,7 +68,7 @@ struct UpdateEventForm<'r> {
async fn update(
db: &State<SqlitePool>,
data: Form<UpdateEventForm<'_>>,
_admin: EventUser,
user: EventUser,
) -> Flash<Redirect> {
let update = event::EventUpdate {
name: data.name,
@@ -78,22 +81,22 @@ async fn update(
};
match Event::find_by_id(db, data.id).await {
Some(planned_event) => {
planned_event.update(db, &update).await;
Flash::success(Redirect::to("/"), "Event erfolgreich bearbeitet")
planned_event.update(db, &user, &update).await;
Flash::success(Redirect::to("/planned"), "Event erfolgreich bearbeitet")
}
None => Flash::error(Redirect::to("/"), "Planned event id not found"),
None => Flash::error(Redirect::to("/planned"), "Planned event id not found"),
}
}
#[get("/planned-event/<id>/delete")]
async fn delete(db: &State<SqlitePool>, id: i64, _admin: EventUser) -> Flash<Redirect> {
let Some(event) = Event::find_by_id(db, id).await else {
return Flash::error(Redirect::to("/"), "Event does not exist");
return Flash::error(Redirect::to("/planned"), "Event does not exist");
};
match event.delete(db).await {
Ok(()) => Flash::success(Redirect::to("/"), "Event gelöscht"),
Err(e) => Flash::error(Redirect::to("/"), e),
Ok(()) => Flash::success(Redirect::to("/planned"), "Event gelöscht"),
Err(e) => Flash::error(Redirect::to("/planned"), e),
}
}
@@ -132,7 +135,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@@ -163,7 +166,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@@ -199,7 +202,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@@ -238,7 +241,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()
@@ -269,7 +272,7 @@ mod test {
let response = req.dispatch().await;
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.headers().get("Location").next(), Some("/"));
assert_eq!(response.headers().get("Location").next(), Some("/planned"));
let flash_cookie = response
.cookies()

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

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

View File

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

View File

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

65
src/tera/admin/role.rs Normal file
View File

@@ -0,0 +1,65 @@
use crate::model::{
role::Role,
user::{AdminUser, UserWithDetails, VorstandUser},
};
use rocket::{
FromForm, Route, State,
form::Form,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
routes,
};
use rocket_dyn_templates::{Template, tera::Context};
use sqlx::SqlitePool;
#[get("/role")]
async fn index(
db: &State<SqlitePool>,
admin: VorstandUser,
flash: Option<FlashMessage<'_>>,
) -> Template {
let roles = Role::all(db).await;
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("roles", &roles);
context.insert(
"loggedin_user",
&UserWithDetails::from_user(admin.user, db).await,
);
Template::render("admin/role", context.into_json())
}
#[derive(FromForm)]
pub struct RoleToUpdate<'r> {
pub formatted_name: &'r str,
pub desc: &'r str,
}
#[post("/role/<role_id>", data = "<data>")]
async fn update(
db: &State<SqlitePool>,
data: Form<RoleToUpdate<'_>>,
role_id: i32,
admin: AdminUser,
) -> Flash<Redirect> {
let role = Role::find_by_id(db, role_id).await;
let Some(role) = role else {
return Flash::error(Redirect::to("/admin/role"), "Role does not exist!");
};
match role
.update(db, &admin, &data.formatted_name, &data.desc)
.await
{
Ok(_) => Flash::success(Redirect::to("/admin/role"), "Rolle bearbeitet"),
Err(e) => Flash::error(Redirect::to("/admin/role"), e),
}
}
pub fn routes() -> Vec<Route> {
routes![index, update]
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
use rocket::{
FromForm, Request, Route, State,
form::Form,
get,
http::{Cookie, CookieJar},
@@ -8,12 +9,12 @@ use rocket::{
response::{Flash, Redirect},
routes,
time::{Duration, OffsetDateTime},
FromForm, Request, Route, State,
};
use rocket_dyn_templates::{context, tera, Template};
use rocket_dyn_templates::{Template, context, tera};
use sqlx::SqlitePool;
use crate::model::{
activity::{ActivityBuilder, ReasonAuth},
log::Log,
user::{LoginError, User},
};
@@ -73,22 +74,28 @@ async fn login(
);
}
Err(_) => {
return Flash::error(Redirect::to("/auth"), "Falscher Benutzername/Passwort. Du bist Vereinsmitglied und der Login klappt nicht? Melde dich bitte beim Vorstand!");
return Flash::error(
Redirect::to("/auth"),
"Falscher Benutzername/Passwort. Du bist Vereinsmitglied und der Login klappt nicht? Kontaktiere unseren Schriftführer oder schreibe eine Mail an info@rudernlinz.at!",
);
}
};
cookies.add_private(Cookie::new("loggedin_user", format!("{}", user.id)));
Log::create(
db,
format!(
"Succ login of {} with this useragent: {}",
login.name, agent.0
),
)
ActivityBuilder::from(ReasonAuth::SuccLogin(&user, agent.0))
.save(db)
.await;
Flash::success(Redirect::to("/"), "Login erfolgreich")
// Check for redirect_url cookie and redirect accordingly
match cookies.get_private("redirect_url") {
Some(redirect_cookie) => {
let redirect_url = redirect_cookie.value().to_string();
cookies.remove_private(redirect_cookie); // Remove the cookie after using it
Flash::success(Redirect::to(redirect_url), "Login erfolgreich")
}
None => Flash::success(Redirect::to("/"), "Login erfolgreich"),
}
}
#[get("/set-pw/<userid>")]

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

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

1155
src/tera/log.rs Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,34 +1,48 @@
use std::{fs::OpenOptions, io::Write};
use chrono::Local;
use chrono::{Datelike, Local};
use rocket::{
catch, catchers,
Build, Data, FromForm, Request, Rocket, State, catch, catchers,
fairing::{AdHoc, Fairing, Info, Kind},
form::Form,
fs::FileServer,
get,
http::Cookie,
post,
request::FlashMessage,
response::{Flash, Redirect},
routes,
time::{Duration, OffsetDateTime},
Build, Data, FromForm, Request, Rocket, State,
};
use rocket_dyn_templates::Template;
use serde::Deserialize;
use sqlx::SqlitePool;
use tera::Context;
use crate::model::{
use crate::{
SCHECKBUCH,
model::{
logbook::Logbook,
notification::Notification,
personal::Achievements,
role::Role,
user::{User, UserWithDetails},
},
};
pub(crate) mod admin;
mod auth;
pub(crate) mod board;
mod boatdamage;
pub(crate) mod boatreservation;
mod cox;
mod ergo;
mod log;
mod misc;
mod notification;
mod planned;
mod stat;
pub(crate) mod trailerreservation;
#[derive(FromForm, Debug)]
struct LoginForm<'r> {
@@ -36,6 +50,31 @@ struct LoginForm<'r> {
password: &'r str,
}
#[get("/")]
async fn index(db: &State<SqlitePool>, user: User, flash: Option<FlashMessage<'_>>) -> Template {
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
if user.has_role(db, "scheckbuch").await {
let last_trips = Logbook::completed_with_user(db, &user).await;
context.insert("last_trips", &last_trips);
}
let date = chrono::Utc::now();
if date.month() <= 3 || date.month() >= 10 {
context.insert("show_quick_ergo_button", "yes");
}
context.insert("achievements", &Achievements::for_user(db, &user).await);
context.insert("notifications", &Notification::for_user(db, &user).await);
context.insert("loggedin_user", &UserWithDetails::from_user(user, db).await);
context.insert("costs_scheckbuch", &SCHECKBUCH);
Template::render("index", context.into_json())
}
#[get("/impressum")]
async fn impressum(db: &State<SqlitePool>, user: Option<User>) -> Template {
let mut context = Context::new();
@@ -67,20 +106,105 @@ async fn steering(db: &State<SqlitePool>, user: User, flash: Option<FlashMessage
Template::render("steering", context.into_json())
}
#[post("/", data = "<login>")]
async fn wikiauth(db: &State<SqlitePool>, login: Form<LoginForm<'_>>) -> String {
if let Ok(user) = User::login(db, login.name, login.password).await {
if user.has_role(db, "allow_website_login").await {
return String::from("SUCC");
}
if user.has_role(db, "admin").await {
return String::from("SUCC");
}
if user.has_role(db, "Vorstand").await {
return String::from("SUCC");
}
}
"FAIL".into()
}
#[post("/", data = "<login>")]
async fn nextcloud_auth(db: &State<SqlitePool>, login: Form<LoginForm<'_>>) -> String {
if let Ok(user) = User::login(db, login.name, login.password).await {
if user.has_role(db, "admin").await {
return String::from("SUCC");
}
if user.has_role(db, "Vorstand").await {
return String::from("SUCC");
}
}
"FAIL".into()
}
#[catch(401)] //Unauthorized
fn unauthorized_error(req: &Request) -> Redirect {
// Save the URL the user tried to access, to be able to go there once logged in
let mut redirect_cookie = Cookie::new("redirect_url", format!("{}", req.uri()));
println!("{}", req.uri());
redirect_cookie.set_expires(OffsetDateTime::now_utc() + Duration::hours(1));
req.cookies().add_private(redirect_cookie);
Redirect::to("/auth")
}
#[derive(FromForm, Debug)]
struct NewBlogpostForm<'r> {
article_url: &'r str,
article_title: &'r str,
pw: &'r str,
}
#[post("/", data = "<blogpost>")]
async fn new_blogpost(
db: &State<SqlitePool>,
blogpost: Form<NewBlogpostForm<'_>>,
config: &State<Config>,
) -> String {
if blogpost.pw == config.wordpress_key {
let member = Role::find_by_name(db, "Donau Linz").await.unwrap();
Notification::create_for_role(
db,
&member,
&urlencoding::decode(blogpost.article_title).expect("UTF-8"),
"Neuer Blogpost",
Some(blogpost.article_url),
None,
)
.await;
"ACK".into()
} else {
"WRONG pw".into()
}
}
#[derive(FromForm, Debug)]
struct BlogpostUnpublishedForm<'r> {
article_url: &'r str,
pw: &'r str,
}
#[post("/", data = "<blogpost>")]
async fn blogpost_unpublished(
db: &State<SqlitePool>,
blogpost: Form<BlogpostUnpublishedForm<'_>>,
config: &State<Config>,
) -> String {
if blogpost.pw == config.wordpress_key {
Notification::delete_by_link(
db,
&urlencoding::decode(blogpost.article_url).expect("UTF-8"),
)
.await;
"ACK".into()
} else {
"WRONG pw".into()
}
}
#[catch(403)] //forbidden
fn forbidden_error() -> Flash<Redirect> {
Flash::error(Redirect::to("/"), "Keine Berechtigung für diese Aktion. Wenn du der Meinung bist, dass du das machen darfst, melde dich bitte bei p+ruadat@hofer.link.")
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.",
)
}
struct Usage {}
@@ -152,12 +276,23 @@ pub struct Config {
pub fn config(rocket: Rocket<Build>) -> Rocket<Build> {
rocket
.mount("/", routes![steering, impressum])
.mount("/", routes![index, steering, impressum])
.mount("/auth", auth::routes())
.mount("/", planned::routes())
.mount("/wikiauth", routes![wikiauth])
.mount("/nxauth", routes![nextcloud_auth])
.mount("/new-blogpost", routes![new_blogpost])
.mount("/blogpost-unpublished", routes![blogpost_unpublished])
.mount("/log", log::routes())
.mount("/planned", planned::routes())
.mount("/ergo", ergo::routes())
.mount("/notification", notification::routes())
.mount("/stat", stat::routes())
.mount("/boatdamage", boatdamage::routes())
.mount("/boatreservation", boatreservation::routes())
.mount("/trailerreservation", trailerreservation::routes())
.mount("/cox", cox::routes())
.mount("/admin", admin::routes())
.mount("/board", board::routes())
.mount("/", misc::routes())
.mount("/public", FileServer::from("static/"))
.register("/", catchers![unauthorized_error, forbidden_error])
@@ -195,11 +330,13 @@ mod test {
assert_eq!(response.status(), Status::Ok);
assert!(response
assert!(
response
.into_string()
.await
.unwrap()
.contains("Ruderassistent"));
.contains("Ruderassistent")
);
}
#[sqlx::test]

View File

@@ -1,7 +1,7 @@
use rocket::{
get,
Route, State, get,
response::{Flash, Redirect},
routes, Route, State,
routes,
};
use sqlx::SqlitePool;
@@ -11,20 +11,17 @@ use crate::model::{notification::Notification, user::User};
async fn mark_read(db: &State<SqlitePool>, user: User, notification_id: i64) -> Flash<Redirect> {
let Some(notification) = Notification::find_by_id(db, notification_id).await else {
return Flash::error(
Redirect::to("/notifications"),
Redirect::to("/"),
format!("Nachricht mit ID {notification_id} nicht gefunden."),
);
};
if notification.user_id == user.id {
notification.mark_read(db).await;
Flash::success(
Redirect::to("/notifications"),
"Nachricht als gelesen markiert",
)
Flash::success(Redirect::to("/"), "Nachricht als gelesen markiert")
} else {
Flash::success(
Redirect::to("/notifications"),
Redirect::to("/"),
"Du kannst fremde Nachrichten nicht als gelesen markieren.",
)
}

View File

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

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

@@ -0,0 +1,91 @@
use rocket::{Route, State, get, routes};
use rocket_dyn_templates::{Template, context};
use sqlx::SqlitePool;
use crate::model::{
stat::{self, BoatStat, Stat},
user::{DonauLinzUser, UserWithDetails},
};
use super::log::KioskCookie;
#[get("/boats", rank = 2)]
async fn index_boat(db: &State<SqlitePool>, user: DonauLinzUser) -> Template {
let stat = BoatStat::get(db).await;
let kiosk = false;
Template::render(
"stat.boats",
context!(loggedin_user: &UserWithDetails::from_user(user.into_inner(), db).await, stat, kiosk),
)
}
#[get("/boats")]
async fn index_boat_kiosk(db: &State<SqlitePool>, _kiosk: KioskCookie) -> Template {
let stat = BoatStat::get(db).await;
let kiosk = true;
Template::render("stat.boats", context!(stat, kiosk, show_kiosk_header: true))
}
#[get("/?<year>", rank = 2)]
async fn index(db: &State<SqlitePool>, user: DonauLinzUser, year: Option<i32>) -> Template {
let stat = Stat::people(db, year).await;
let club_km = Stat::sum_people(db, year).await;
let club_trips = Stat::trips_people(db, year).await;
let guest_km = Stat::guest(db, year).await;
let personal = stat::get_personal(db, &user).await;
let kiosk = false;
Template::render(
"stat.people",
context!(loggedin_user: &UserWithDetails::from_user(user.into_inner(), db).await, stat, personal, kiosk, guest_km, club_km, club_trips),
)
}
#[get("/?<year>")]
async fn index_kiosk(db: &State<SqlitePool>, _kiosk: KioskCookie, year: Option<i32>) -> Template {
let stat = Stat::people(db, year).await;
let club_km = Stat::sum_people(db, year).await;
let club_trips = Stat::trips_people(db, year).await;
let guest_km = Stat::guest(db, year).await;
let kiosk = true;
Template::render(
"stat.people",
context!(stat, kiosk, show_kiosk_header: true, guest_km, club_km, club_trips),
)
}
pub fn routes() -> Vec<Route> {
routes![index, index_kiosk, index_boat, index_boat_kiosk]
}
#[cfg(test)]
mod test {
use rocket::{http::Status, local::asynchronous::Client};
use sqlx::SqlitePool;
use crate::testdb;
#[sqlx::test]
fn test_kiosk_stat() {
let db = testdb!();
let rocket = rocket::build().manage(db.clone());
let rocket = crate::tera::config(rocket);
let client = Client::tracked(rocket).await.unwrap();
// "Log in"
let req = client.get("/log/kiosk/ekrv2019/Linz");
let _ = req.dispatch().await;
// `/stat` should be viewable
let req = client.get("/stat");
let response = req.dispatch().await;
assert_eq!(response.status(), Status::Ok);
let text = response.into_string().await.unwrap();
assert!(text.contains("Statistik"));
}
}

View File

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

5
staging-diff.sql Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
{% import "includes/macros" as macros %}
{% import "includes/forms/boat" as boat %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-lg w-full dark:text-white">
<h1 class="h1">Mail</h1>
<div class="grid ">
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Mail versenden</h2>
<form action="/admin/mail"
method="post"
enctype="multipart/form-data"
class="grid gap-3 p-3">
{{ macros::select(label="Gruppe", data=roles, name="role_id") }}
{{ macros::input(label="Betreff", name="subject", type="text", required=true) }}
<div class="">
<label for="content" class=" text-sm text-gray-600 dark:text-white ">Inhalt</label>
<textarea id="content" name="body" rows="4" cols="50" class="input rounded-md"></textarea>
</div>
<input type="file" name="files" multiple />
<input type="submit" class="btn btn-primary" value="Abschicken" />
</form>
</div>
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Mitglieds-Beitrags-Info</h2>
<div class="p-3 grid gap-3">
<a class="btn btn-primary" href="/admin/mail/fee/test">Test-Mail an mich versenden</a>
<a class="btn btn-alert"
href="/admin/mail/fee"
onclick="return confirm('Hast du die Gebührenauflistung geprüft und willst du die Mail an alle ausschicken?');">
An ALLE Mitglieder versenden
</a>
</div>
</div>
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow mt-5"
role="alert">
<h2 class="h2">Unfreundliche Zahlungsaufforderung</h2>
<div class="p-3 grid gap-3">
<a class="btn btn-primary" href="/admin/mail/fee-final/test">Test-Mail an mich versenden</a>
<a class="btn btn-alert"
href="/admin/mail/fee-final"
onclick="return confirm('Hast du die Gebührenauflistung geprüft, gecheckt ob alle die bereits bezahlt haben auch eingetragen wurden und willst du die Mail an alle, die noch nicht bezahlt haben ausschicken?');">
An ALLE Mitglieder versenden, die noch nicht bezahlt haben
</a>
</div>
</div>
</div>
</div>
{% endblock content %}

Some files were not shown because too many files have changed in this diff Show More