124 Commits

Author SHA1 Message Date
b2157a31c5 Merge pull request 'user merging should only be done by admins' (#1182) from user-upd into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 17m44s
CI/CD Pipeline / deploy-staging (push) Successful in 7m57s
CI/CD Pipeline / deploy-main (push) Has been skipped
Update Cargo Dependencies / update-dependencies (push) Successful in 1m33s
Reviewed-on: #1182
2026-01-08 20:23:08 +01:00
2ed22d6440 user merging should only be done by admins
Some checks failed
CI/CD Pipeline / test (push) Failing after 21m7s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
2026-01-08 20:22:17 +01:00
bfb3ae4b6e Merge pull request 'user-upd' (#1180) from user-upd into staging
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1180
2026-01-08 20:15:41 +01:00
3fcf24958b show all users on ranking board; be able to merge users
Some checks failed
CI/CD Pipeline / test (push) Has started running
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2026-01-08 20:14:57 +01:00
f8ea6d5aa5 Merge pull request 'new sort option' (#1179) from new-sort-option into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 19m56s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 7m34s
Reviewed-on: #1179
2026-01-05 13:09:24 +01:00
88a3e5f2d0 Merge pull request 'Merge pull request 'force an action on important notifications' (#1177) from force-action-on-important-notifications into main' (#1178) from new-sort-option into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 17m44s
CI/CD Pipeline / deploy-staging (push) Successful in 7m49s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1178
2026-01-05 13:09:12 +01:00
Philipp Hofer
9f9ec2f812 new sort option
All checks were successful
CI/CD Pipeline / test (push) Successful in 19m41s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
2026-01-05 13:08:40 +01:00
e5c9f30dd5 Merge pull request 'force an action on important notifications' (#1177) from force-action-on-important-notifications into main
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1177
2026-01-03 22:12:11 +01:00
32c250536d Merge pull request 'force-action-on-important-notifications' (#1176) from force-action-on-important-notifications into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 17m51s
CI/CD Pipeline / deploy-staging (push) Successful in 7m56s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1176
2026-01-03 22:11:55 +01:00
0ccd59f8a7 force an action on important notifications
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2026-01-03 22:11:11 +01:00
b9d0e2a2dc Merge pull request 'show-full-stats' (#1172) from show-full-stats into staging
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
Reviewed-on: #1172
2026-01-03 21:49:14 +01:00
fe0761a4c8 Merge pull request 'add manual deploy option' (#1175) from manual-deploy into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 17m25s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1175
2026-01-03 21:43:07 +01:00
b597898bdf Merge pull request 'manual-deploy' (#1174) from manual-deploy into staging
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1174
2026-01-03 21:43:02 +01:00
7971cedf39 add manual deploy option
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2026-01-03 21:42:22 +01:00
761e99ae8d Merge pull request 'be able to show total km of each rower' (#1173) from show-full-stats into main
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1173
2026-01-03 21:27:57 +01:00
2aa6def560 be able to show total km of each rower
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2026-01-03 21:26:20 +01:00
48e1ee0d4c Merge pull request 'handle-deleted-boats' (#1170) from handle-deleted-boats into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 18m25s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 28m8s
Reviewed-on: #1170
2026-01-03 14:18:37 +01:00
e90555214a Merge pull request 'No need to pay for deleted boats' (#1169) from handle-deleted-boats into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 19m20s
CI/CD Pipeline / deploy-staging (push) Successful in 26m29s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1169
2026-01-03 14:04:39 +01:00
a891fb4803 No need to pay for deleted boats
All checks were successful
CI/CD Pipeline / test (push) Successful in 36m37s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
2026-01-03 13:27:55 +01:00
ec6c31848d Merge pull request 'yearly cleanup of roles; fixes #941' (#1161) from yeaerly-cleanup into main
Some checks failed
CI/CD Pipeline / test (push) Failing after 26m29s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1161
2025-11-21 10:35:16 +01:00
c92c5526c3 Merge pull request 'yeaerly-cleanup' (#1160) from yeaerly-cleanup into staging
Some checks failed
CI/CD Pipeline / test (push) Failing after 24m59s
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Update Cargo Dependencies / update-dependencies (push) Successful in 1m27s
Reviewed-on: #1160
2025-11-21 10:35:07 +01:00
3148d744e6 yearly cleanup of roles; fixes #941
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-11-21 10:32:59 +01:00
43d9dcc31a more-robust-ui-tests (#1158)
All checks were successful
CI/CD Pipeline / test (push) Successful in 23m4s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Co-authored-by: Philipp Hofer <philipp.hofer@mag.linz.at>
Reviewed-on: #1158
Co-authored-by: Philipp Hofer <philipp@hofer.link>
Co-committed-by: Philipp Hofer <philipp@hofer.link>
2025-11-20 19:21:11 +01:00
5c1d8876be more-robust-ui-tests (#1157)
All checks were successful
CI/CD Pipeline / test (push) Successful in 20m22s
CI/CD Pipeline / deploy-staging (push) Successful in 34m20s
CI/CD Pipeline / deploy-main (push) Has been skipped
Update Cargo Dependencies / update-dependencies (push) Successful in 2m17s
Co-authored-by: Philipp Hofer <philipp.hofer@mag.linz.at>
Reviewed-on: #1157
Co-authored-by: Philipp Hofer <philipp@hofer.link>
Co-committed-by: Philipp Hofer <philipp@hofer.link>
2025-11-20 19:20:43 +01:00
24fe027f7b Merge pull request 'bank-name-mention' (#1156) from bank-name-mention into main
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1156
2025-11-20 08:19:22 +01:00
e89c5c7439 Merge pull request 'Update src/model/mail.rs' (#1155) from bank-name-mention into staging
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1155
2025-11-20 08:19:19 +01:00
b605f82af7 Update src/model/mail.rs
Some checks failed
CI/CD Pipeline / deploy-main (push) Blocked by required conditions
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-11-20 08:16:30 +01:00
1add5c2a2a Merge pull request 'enable self-enrollment to ergo challenge' (#1148) from allow-ergo-entry into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 15m55s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 8m53s
Reviewed-on: #1148
2025-10-07 19:09:23 +02:00
a59d8c0331 Merge pull request 'enable self-enrollment to ergo challenge' (#1147) from allow-ergo-entry into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 18m23s
CI/CD Pipeline / deploy-staging (push) Successful in 10m49s
CI/CD Pipeline / deploy-main (push) Has been skipped
Update Cargo Dependencies / update-dependencies (push) Successful in 1m22s
Reviewed-on: #1147
2025-10-07 19:08:49 +02:00
567f31dd3d enable self-enrollment to ergo challenge
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-10-07 19:08:20 +02:00
eec485dced Merge pull request 'allow ergo entry' (#1146) from allow-ergo-entry into main
Some checks failed
CI/CD Pipeline / test (push) Failing after 2m28s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1146
2025-10-07 18:54:03 +02:00
71760a500f Merge pull request 'allow-ergo-entry' (#1145) from allow-ergo-entry into staging
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
Reviewed-on: #1145
2025-10-07 18:53:41 +02:00
b48b689aeb allow ergo entry
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-10-07 18:53:13 +02:00
9f57cbaa71 Merge pull request 'add-ergo-role' (#1143) from add-ergo-role into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 15m49s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 8m49s
Reviewed-on: #1143
2025-10-07 16:53:06 +02:00
a1b18d6f92 Merge pull request 'nicer formatting' (#1144) from add-ergo-role into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 19m13s
CI/CD Pipeline / deploy-staging (push) Successful in 9m6s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1144
2025-10-07 16:52:57 +02:00
Philipp Hofer
284a853344 nicer formatting
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-10-07 16:52:01 +02:00
465a42acac Merge pull request 'also show button again' (#1142) from add-ergo-role into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 17m8s
CI/CD Pipeline / deploy-staging (push) Successful in 9m6s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1142
2025-10-07 16:24:37 +02:00
Philipp Hofer
ebce600356 also show button again
All checks were successful
CI/CD Pipeline / test (push) Successful in 19m25s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
2025-10-07 16:04:44 +02:00
6e418b6f2f Merge pull request 'fix-cii' (#1140) from fix-cii into staging
Some checks failed
Update Cargo Dependencies / update-dependencies (push) Successful in 2m19s
CI/CD Pipeline / test (push) Has started running
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1140
2025-10-02 09:02:39 +02:00
328a8e3e35 Merge pull request 'fix-cii' (#1139) from fix-cii into main
Some checks failed
CI/CD Pipeline / test (push) Failing after 41m48s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1139
2025-10-02 09:02:28 +02:00
bfb95610f6 fix ci?
All checks were successful
CI/CD Pipeline / test (push) Successful in 32m54s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
2025-10-02 08:40:46 +02:00
68674dd1c5 debug
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-10-02 08:38:03 +02:00
9a16ce0c21 Merge pull request 'restore-fix-ci' (#1138) from restore-fix-ci into staging
Some checks failed
CI/CD Pipeline / test (push) Failing after 17m53s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1138
2025-10-02 00:35:29 +02:00
16689318eb Merge pull request 'Restore content from fix-ci-finally' (#1137) from restore-fix-ci into main
Some checks failed
CI/CD Pipeline / test (push) Failing after 21m49s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1137
2025-10-02 00:35:21 +02:00
b12ea81bbf Restore content from fix-ci-finally
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-10-02 00:34:12 +02:00
49a638d595 Merge pull request 'replace-main-with-fix' (#1136) from replace-main-with-fix into main
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1136
2025-10-02 00:32:24 +02:00
452d257c7a Merge pull request 'replace-main-with-fix' (#1135) from replace-main-with-fix into staging
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1135
2025-10-02 00:32:15 +02:00
599eec0e43 Replace main content with fix-ci-finally
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-10-02 00:29:28 +02:00
433c914c4a fix ci
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-10-02 00:25:43 +02:00
0338351eef reduce npm warnings 2025-10-01 23:46:49 +02:00
e1803aea3e Merge pull request 'update deps, fix ci' (#1134) from no-ergo into main
Some checks failed
CI/CD Pipeline / test (push) Failing after 51m37s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1134
2025-10-01 22:41:40 +02:00
6f491e20e5 Merge pull request 'update deps, fix ci' (#1133) from no-ergo into staging
Some checks failed
CI/CD Pipeline / test (push) Failing after 44m35s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1133
2025-10-01 22:41:32 +02:00
7f26710a40 fix ci?
Some checks failed
CI/CD Pipeline / test (push) Has started running
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-10-01 22:24:13 +02:00
9203c61541 fix ci?
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-10-01 22:07:21 +02:00
3a57a1334d update npm mods
Some checks failed
CI/CD Pipeline / test (push) Failing after 17m54s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
2025-10-01 21:44:24 +02:00
72c19d7a75 Merge pull request 'no ergo yet' (#1132) from no-ergo into main
Some checks failed
CI/CD Pipeline / test (push) Failing after 16m2s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1132
2025-10-01 20:25:08 +02:00
8b25076599 Merge pull request 'no-ergo' (#1131) from no-ergo into staging
Some checks failed
CI/CD Pipeline / test (push) Failing after 17m9s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1131
2025-10-01 20:24:26 +02:00
a44f8b445c no ergo yet
Some checks failed
CI/CD Pipeline / test (push) Failing after 25m9s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
2025-10-01 20:23:09 +02:00
5ec457fea7 Merge pull request 'log-frontend-test-logs' (#1119) from log-frontend-test-logs into main
Some checks failed
CI/CD Pipeline / test (push) Failing after 16m52s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1119
2025-07-24 11:51:49 +02:00
3ce95ecb49 Merge pull request 'log-frontend-test-logs' (#1118) from log-frontend-test-logs into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 21m14s
CI/CD Pipeline / deploy-staging (push) Successful in 9m52s
CI/CD Pipeline / deploy-main (push) Has been skipped
Update Cargo Dependencies / update-dependencies (push) Successful in 1m38s
Reviewed-on: #1118
2025-07-24 11:46:53 +02:00
4fcd34cfa9 generate html report
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-07-24 11:23:39 +02:00
d64f6f61ba reduce useless compiler warnings 2025-07-24 11:23:32 +02:00
5934bbe666 Merge pull request 'get errors' (#1117) from log-frontend-test-logs into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 21m13s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 8m6s
Reviewed-on: #1117
2025-07-24 11:01:53 +02:00
f08764c3d1 Merge pull request 'get errors' (#1116) from log-frontend-test-logs into staging
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1116
2025-07-24 11:01:33 +02:00
b7cc01ff1c get errors
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-07-24 11:00:49 +02:00
e9a78db048 Merge pull request 'families only have to pay einschriebgebuehr once + half price applies' (#1115) from fix-family-fees into main
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1115
2025-07-24 08:55:43 +02:00
b52e3160d5 Merge pull request 'fix-family-fees' (#1114) from fix-family-fees into staging
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
CI/CD Pipeline / test (push) Failing after 33m18s
Reviewed-on: #1114
2025-07-24 08:55:30 +02:00
0996a81d52 families only have to pay einschriebgebuehr once + half price applies
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-07-24 08:52:26 +02:00
25df7a935c Merge pull request '[BUGFIX] set max items in choices.js after searching' (#1113) from show-daniels into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 24m12s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 15m49s
Reviewed-on: #1113
2025-07-20 12:02:13 +02:00
6f7077adf4 Merge pull request 'show-daniels' (#1112) from show-daniels into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 23m33s
CI/CD Pipeline / deploy-staging (push) Successful in 11m40s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1112
2025-07-20 12:01:55 +02:00
Marie Birner
55c0647b55 [BUGFIX] set max items in choices.js after searching
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-07-20 11:57:45 +02:00
627a515a42 Merge pull request 'upda' (#1111) from upda into main
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1111
2025-07-20 11:33:56 +02:00
4b2107d0f6 Merge pull request 'upda' (#1109) from upda into staging
Some checks failed
CI/CD Pipeline / test (push) Has started running
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1109
2025-07-20 11:33:04 +02:00
1c6421139d fördernde user can 1x row
Some checks failed
CI/CD Pipeline / test (push) Failing after 22m16s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
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
All checks were successful
CI/CD Pipeline / test (push) Successful in 23m12s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 10m30s
Reviewed-on: #1108
2025-07-19 13:24:12 +02:00
de544b9c98 Merge pull request 'fix path' (#1107) from new-server into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 22m37s
CI/CD Pipeline / deploy-staging (push) Successful in 10m55s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1107
2025-07-19 13:23:56 +02:00
3f76e5be78 fix path
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-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
All checks were successful
CI/CD Pipeline / test (push) Successful in 20m44s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 9m19s
Reviewed-on: #1106
2025-07-19 10:54:02 +02:00
302ff3c8a3 Merge pull request 'it's okay to not have this already' (#1105) from new-server into staging
Some checks failed
CI/CD Pipeline / test (push) Successful in 19m38s
CI/CD Pipeline / deploy-staging (push) Failing after 8m29s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1105
2025-07-19 10:53:26 +02:00
b15050cd63 it's okay to not have this already
Some checks failed
CI/CD Pipeline / deploy-staging (push) Blocked by required conditions
CI/CD Pipeline / deploy-main (push) Blocked by required conditions
CI/CD Pipeline / test (push) Has been cancelled
2025-07-19 10:52:19 +02:00
2907ed5caf Merge pull request 'fix path' (#1104) from new-server into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 19m57s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 10m41s
Reviewed-on: #1104
2025-07-19 07:04:41 +02:00
657b378169 Merge pull request 'fix path' (#1103) from new-server into staging
Some checks failed
CI/CD Pipeline / test (push) Successful in 22m21s
CI/CD Pipeline / deploy-staging (push) Failing after 7m53s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1103
2025-07-19 07:04:28 +02:00
bc8cd88af4 fix path
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-07-19 07:03:12 +02:00
539d299c1a Merge pull request 'move to new server' (#1102) from new-server into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 19m39s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 9m28s
Reviewed-on: #1102
2025-07-18 18:58:14 +02:00
cb65f24f67 Merge pull request 'new-server' (#1101) from new-server into staging
Some checks failed
CI/CD Pipeline / test (push) Successful in 23m30s
CI/CD Pipeline / deploy-staging (push) Failing after 6m50s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1101
2025-07-18 18:57:33 +02:00
f0936c7784 move to new server
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-07-18 18:57:09 +02:00
59478a5ee1 Merge pull request 'fix tests' (#1099) from fix-tetss into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 23m18s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1099
2025-07-17 21:49:20 +02:00
2bb2942a0f Merge pull request 'fix-tetss' (#1098) from fix-tetss into staging
Some checks failed
CI/CD Pipeline / test (push) Successful in 21m41s
CI/CD Pipeline / deploy-staging (push) Failing after 7m1s
CI/CD Pipeline / deploy-main (push) Has been skipped
Update Cargo Dependencies / update-dependencies (push) Successful in 1m28s
Reviewed-on: #1098
2025-07-17 21:49:09 +02:00
2f5d483bff fix tests
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-07-17 21:48:31 +02:00
6b78f31aa4 Merge pull request 'kiosk-allow-foerdernde' (#1096) from kiosk-allow-foerdernde into staging
Some checks failed
CI/CD Pipeline / test (push) Failing after 26m22s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1096
2025-07-13 21:42:09 +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
Some checks failed
CI/CD Pipeline / test (push) Failing after 25m29s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #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
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-07-13 19:49:46 +02:00
c7f1702663 Merge pull request 'foerderne-user-can-do-trips' (#1093) from foerderne-user-can-do-trips into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 25m49s
CI/CD Pipeline / deploy-staging (push) Successful in 35m1s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1093
2025-07-13 09:54:48 +02:00
51c7cf28f8 Merge pull request 'foerderne user can do trips on the water' (#1094) from foerderne-user-can-do-trips into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 27m36s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 35m11s
Reviewed-on: #1094
2025-07-13 09:26:57 +02:00
80eca1a3b2 foerderne user can do trips on the water
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
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
Some checks failed
CI/CD Pipeline / test (push) Failing after 32m16s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1090
2025-07-03 11:03:50 +02:00
ccff9a3752 Merge pull request 'bootsman-to-cox' (#1089) from bootsman-to-cox into staging
All checks were successful
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
CI/CD Pipeline / test (push) Successful in 39m55s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m27s
Reviewed-on: #1089
2025-07-03 11:03:40 +02:00
a534568a39 allow to change from 'bootsfuehrer' to 'cox'
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-07-03 11:02:59 +02:00
b4c04cbdd8 Merge pull request 'updates-meeting' (#1085) from updates-meeting into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 20m54s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1085
2025-06-17 22:53:40 +02:00
aac99c86fa Merge pull request 'updates-meeting' (#1084) from updates-meeting into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 19m45s
CI/CD Pipeline / deploy-staging (push) Successful in 10m17s
CI/CD Pipeline / deploy-main (push) Has been skipped
Update Cargo Dependencies / update-dependencies (push) Successful in 1m33s
Reviewed-on: #1084
2025-06-17 22:53:07 +02:00
1f0bfb04e4 more updates after meeting
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-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
All checks were successful
CI/CD Pipeline / test (push) Successful in 16m53s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 7m13s
Reviewed-on: #1080
2025-06-13 11:08:03 +02:00
2e13acc0b0 Merge pull request 'fix-ci' (#1079) from fix-ci into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 18m8s
CI/CD Pipeline / deploy-staging (push) Successful in 25m26s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1079
2025-06-13 11:07:48 +02:00
0a31410ca5 fix(?) auto ci
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
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
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1076
2025-06-09 08:36:49 +02:00
6a59634de3 Merge pull request 'log-trip-creation' (#1075) from log-trip-creation into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 19m20s
CI/CD Pipeline / deploy-staging (push) Successful in 25m7s
CI/CD Pipeline / deploy-main (push) Has been skipped
Update Cargo Dependencies / update-dependencies (push) Successful in 1m30s
Reviewed-on: #1075
2025-06-09 08:35:22 +02:00
d3b2d78f9f log if a cox creates a trip
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-06-09 08:33:50 +02:00
155adce2e9 Merge pull request 'add license' Fixes #1064' (#1071) from upd into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 14m4s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 7m0s
Reviewed-on: #1071
2025-05-29 16:06:34 +02:00
63a32f02bf Merge pull request 'add license' Fixes #1064' (#1070) from upd into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 14m11s
CI/CD Pipeline / deploy-staging (push) Successful in 7m2s
CI/CD Pipeline / deploy-main (push) Has been skipped
Update Cargo Dependencies / update-dependencies (push) Successful in 1m7s
Reviewed-on: #1070
2025-05-29 16:06:28 +02:00
9548cb4f0b add license' Fixes #1064
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-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
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1069
2025-05-29 16:02:59 +02:00
429f0c1ddc Merge pull request 'upd' (#1068) from upd into staging
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1068
2025-05-29 16:02:37 +02:00
d5a92d8f79 promote calendar in welcome-mail; even more robust name checking
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-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
All checks were successful
CI/CD Pipeline / test (push) Successful in 16m17s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 13m21s
Reviewed-on: #1066
2025-05-29 12:12:54 +02:00
0354e4e190 Merge pull request 'fix tests' (#1067) from use-proper-role into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 13m46s
CI/CD Pipeline / deploy-staging (push) Successful in 14m1s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1067
2025-05-29 12:12:39 +02:00
7a2743046d fix tests
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-05-29 12:11:48 +02:00
7935d1837f Merge pull request 'use-proper-role' (#1065) from use-proper-role into staging
Some checks failed
CI/CD Pipeline / test (push) Has started running
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
Reviewed-on: #1065
2025-05-29 12:11:13 +02:00
7027145a9a use proper role instead of manully validating role
Some checks failed
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
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
All checks were successful
CI/CD Pipeline / test (push) Successful in 15m3s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 7m7s
Reviewed-on: #1063
2025-05-27 08:54:50 +02:00
f769af279b Merge pull request 'fix-uppercase-non-ascii-name' (#1062) from fix-uppercase-non-ascii-name into staging
All checks were successful
CI/CD Pipeline / test (push) Successful in 14m10s
CI/CD Pipeline / deploy-staging (push) Successful in 7m2s
CI/CD Pipeline / deploy-main (push) Has been skipped
Reviewed-on: #1062
2025-05-27 08:54:45 +02:00
c6a2b529c3 allow users name to start with ÄÜÖ
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy-staging (push) Has been cancelled
CI/CD Pipeline / deploy-main (push) Has been cancelled
2025-05-27 08:54:15 +02:00
b0b2ad2148 Merge pull request 'improve text' (#1061) from nicer-text into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 13m47s
CI/CD Pipeline / deploy-staging (push) Has been skipped
CI/CD Pipeline / deploy-main (push) Successful in 7m12s
Reviewed-on: #1061
2025-05-26 23:42:53 +02:00
38 changed files with 1801 additions and 574 deletions

View File

@@ -17,6 +17,9 @@ jobs:
- name: Run Test DB Script
run: ./test_db.sh
- name: Test
run: npm --version
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
@@ -25,15 +28,15 @@ jobs:
cargo build
cd frontend && npm install && npm run build
- name: Frontend tests
run: cd frontend && npx playwright install && npx playwright test --workers 1 --reporter line
run: cd frontend && npx playwright install && npx playwright test --workers 1 --reporter html,line
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 30
- name: Backend tests
run: cargo test --verbose
#- uses: actions/upload-artifact@v3
# if: always()
# with:
# name: playwright-report
# path: frontend/playwright-report/
# retention-days: 30
deploy-staging:
runs-on: ubuntu-latest
@@ -63,16 +66,16 @@ jobs:
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
scp -C target/$CARGO_TARGET/release/rot $SSH_USER@$SSH_HOST:/home/rowing-staging/rot-updating
scp -C target/$CARGO_TARGET/release/rot $SSH_USER@$SSH_HOST:/root/rowing-staging/rot-updating
scp -C staging-diff.sql $SSH_USER@$SSH_HOST:/home/rowing-staging/
scp -C -r static $SSH_USER@$SSH_HOST:/home/rowing-staging/
scp -C -r templates $SSH_USER@$SSH_HOST:/home/rowing-staging/
scp -C -r svelte $SSH_USER@$SSH_HOST:/home/rowing-staging/
ssh $SSH_USER@$SSH_HOST 'sudo systemctl stop rotstaging'
ssh $SSH_USER@$SSH_HOST 'rm /home/rowing-staging/db.sqlite && cp /home/rowing/db.sqlite /home/rowing-staging/db.sqlite && mkdir -p /home/rowing-staging/svelte/build && mkdir -p /home/rowing-staging/data-ergo/thirty && mkdir -p /home/rowing-staging/data-ergo/dozen && sqlite3 /home/rowing-staging/db.sqlite < /home/rowing-staging/staging-diff.sql'
ssh $SSH_USER@$SSH_HOST 'mv /home/rowing-staging/rot-updating /home/rowing-staging/rot'
ssh $SSH_USER@$SSH_HOST 'sudo systemctl start rotstaging'
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 }}
@@ -106,14 +109,14 @@ jobs:
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
scp -C target/$CARGO_TARGET/release/rot $SSH_USER@$SSH_HOST:/home/rowing/rot-updating
scp -C -r static $SSH_USER@$SSH_HOST:/home/rowing/
scp -C -r templates $SSH_USER@$SSH_HOST:/home/rowing/
scp -C -r svelte $SSH_USER@$SSH_HOST:/home/rowing/
ssh $SSH_USER@$SSH_HOST 'mkdir -p /home/rowing/svelte/build && mkdir -p /home/rowing/data-ergo/thirty && mkdir -p /home/rowing/data-ergo/dozen'
ssh $SSH_USER@$SSH_HOST 'sudo systemctl stop rot'
ssh $SSH_USER@$SSH_HOST 'mv /home/rowing/rot-updating /home/rowing/rot'
ssh $SSH_USER@$SSH_HOST 'sudo systemctl start rot'
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

@@ -31,7 +31,7 @@ jobs:
- Run `cargo upgrade` to update version requirements in Cargo.toml
- Run `cargo update` to update Cargo.lock
branch: update-cargo-dependencies
delete-branch: true
delete-branch: false
- name: Create Pull Request Main
uses: https://git.hofer.link/philipp/create-pull-request@18ef1fdad70eec569ab10292c1fa79c1b5296370

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.

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

15
force-prod-deploy.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
cargo b -r --target x86_64-unknown-linux-musl
strip target/x86_64-unknown-linux-musl/release/rot
cd frontend && npm install && npm run build
cd ..
scp -C target/x86_64-unknown-linux-musl/release/rot row-server:/root/rowing-prod/rot-updating
scp -C -r static row-server:/root/rowing-prod/
scp -C -r templates row-server:/root/rowing-prod/
scp -C -r svelte row-server:/root/rowing-prod/
ssh row-server 'mkdir -p /root/rowing-prod/svelte/build && mkdir -p /root/rowing-prod/data-ergo/thirty && mkdir -p /root/rowing-prod/data-ergo/dozen'
ssh row-server 'sudo systemctl stop rowing-prod'
ssh row-server 'mv /root/rowing-prod/rot-updating /root/rowing-prod/rot'
ssh row-server 'sudo systemctl start rowing-prod'

View File

@@ -413,6 +413,7 @@ function initNewChoice(select: HTMLInputElement) {
steering_person.setAttribute("required", "required");
}
const choice = new Choices(select, {
searchResultLimit: 100,
searchFields: ["label", "value", "customProperties.searchableText"],
removeItemButton: true,
loadingText: "Wird geladen...",

View File

@@ -16,7 +16,7 @@
"postcss": "^8.4.21",
"sass": "^1.60.0",
"tailwindcss": "^3.3.1",
"typescript": "^4.9.5",
"typescript": "^5.9.3",
"vite": "^4.2.0",
"vite-plugin-static-copy": "^0.13.1"
},

View File

@@ -1,4 +1,9 @@
import { test, expect } from "@playwright/test";
import { test, expect, Page } from "@playwright/test";
import { resetDatabase, login } from "./helpers";
test.beforeEach(async () => {
await resetDatabase();
});
test("cox can create and delete trip", async ({ page }) => {
await page.goto("/auth");
@@ -16,22 +21,13 @@ test("cox can create and delete trip", async ({ page }) => {
await page.getByRole("spinbutton").fill("5");
await page.getByRole("button", { name: "Erstellen", exact: true }).click();
await expect(page.locator("body")).toContainText("18:00 Uhr (cox) Details");
await page.goto("/planned");
await page.getByRole('link', { name: 'Details' }).nth(1).click();
await page.getByRole("link", { name: "Termin löschen" }).click();
await expect(page.locator("body")).toContainText("Erfolgreich gelöscht!");
});
// TODO: group -> cox can create trips
// TODO: cox can help/register at trips/events
test.describe("cox can edit trips", () => {
let sharedPage: Page;
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
async function createTrip(page: Page) {
await page.goto("/auth");
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("cox");
@@ -46,151 +42,101 @@ test.describe("cox can edit trips", () => {
await page.locator("#sidebar #planned_starting_time").press("Tab");
await page.getByRole("spinbutton").fill("5");
await page.getByRole("button", { name: "Erstellen", exact: true }).click();
}
sharedPage = page;
});
test("edit remarks", async ({ page }) => {
await createTrip(page);
test("edit remarks", async () => {
await sharedPage.goto("/planned");
await sharedPage.getByRole('link', { name: 'Details' }).nth(1).click();
await sharedPage.locator("#sidebar #notes").click();
await sharedPage.locator("#sidebar #notes").fill("Meine Anmerkung");
await sharedPage.getByRole("button", { name: "Speichern" }).click();
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
await page.goto("/planned");
await page.getByRole('link', { name: 'Details' }).nth(1).click();
await page.locator("#sidebar #notes").click();
await page.locator("#sidebar #notes").fill("Meine Anmerkung");
await page.getByRole("button", { name: "Speichern" }).click();
await page.getByRole("link", { name: "Details" }).nth(1).click();
await expect(page.locator("#sidebar")).toContainText(
"Meine Anmerkung",
);
await sharedPage
.getByRole("button", { name: "Ausfahrt erstellen schließen" })
.click();
});
test("add and remove guest", async () => {
await sharedPage.goto("/planned");
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await sharedPage.locator("#sidebar #user_note").click();
await sharedPage.locator("#sidebar #user_note").fill("Mein Gast");
await sharedPage.getByRole("button", { name: "Gast hinzufügen" }).click();
await expect(sharedPage.locator("body")).toContainText(
test("add and remove guest", async ({ page }) => {
await createTrip(page);
await page.goto("/planned");
await page.getByRole("link", { name: "Details" }).nth(1).click();
await page.locator("#sidebar #user_note").click();
await page.locator("#sidebar #user_note").fill("Mein Gast");
await page.getByRole("button", { name: "Gast hinzufügen" }).click();
await expect(page.locator("body")).toContainText(
"Erfolgreich angemeldet!",
);
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
await page.getByRole("link", { name: "Details" }).nth(1).click();
await expect(page.locator("#sidebar")).toContainText(
"Freie Plätze: 4",
);
await expect(sharedPage.locator("#sidebar")).toContainText(
await expect(page.locator("#sidebar")).toContainText(
"Mein Gast (Gast) Abmelden",
);
await expect(
sharedPage.getByRole("link", { name: "Termin löschen" }),
page.getByRole("link", { name: "Termin löschen" }),
).not.toBeVisible();
await sharedPage.getByRole("link", { name: "Abmelden" }).click();
await expect(sharedPage.locator("body")).toContainText(
await page.getByRole("link", { name: "Abmelden" }).click();
await expect(page.locator("body")).toContainText(
"Erfolgreich abgemeldet!",
);
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
await page.getByRole("link", { name: "Details" }).nth(1).click();
await expect(page.locator("#sidebar")).toContainText(
"Freie Plätze: 5",
);
await expect(sharedPage.locator("#sidebar")).toContainText(
await expect(page.locator("#sidebar")).toContainText(
"Keine Ruderer angemeldet",
);
await expect(
sharedPage.getByRole("link", { name: "Termin löschen" }),
page.getByRole("link", { name: "Termin löschen" }),
).toBeVisible();
await sharedPage
.getByRole("button", { name: "Ausfahrt erstellen schließen" })
.click();
});
test("change amount rower", async () => {
await sharedPage.goto("/planned");
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await expect(sharedPage.locator("#sidebar")).toContainText(
test("change amount rower", async ({ page }) => {
await createTrip(page);
await page.goto("/planned");
await page.getByRole("link", { name: "Details" }).nth(1).click();
await expect(page.locator("#sidebar")).toContainText(
"Freie Plätze: 5",
);
await sharedPage.getByRole("spinbutton").click();
await sharedPage.getByRole("spinbutton").fill("3");
await sharedPage.getByRole("button", { name: "Speichern" }).click();
await expect(sharedPage.locator("body")).toContainText(
await page.getByRole("spinbutton").click();
await page.getByRole("spinbutton").fill("3");
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page.locator("body")).toContainText(
"Ausfahrt erfolgreich aktualisiert.",
);
});
test("call off trip", async () => {
test("call off trip", async ({ page }) => {
await createTrip(page);
// Someone registers...
await sharedPage.goto("/auth/logout");
await sharedPage.goto("/auth");
await sharedPage.getByPlaceholder("Name").click();
await sharedPage.getByPlaceholder("Name").fill("rower");
await sharedPage.getByPlaceholder("Name").press("Tab");
await sharedPage.getByPlaceholder("Passwort").fill("rower");
await sharedPage.getByPlaceholder("Passwort").press("Enter");
await page.goto("/auth/logout");
await page.waitForURL("/auth");
await login(page, "rower", "rower");
await sharedPage.goto("/planned");
await sharedPage.getByRole('link', { name: 'Mitrudern' }).nth(1).click();
await page.goto("/planned");
await page.getByRole('link', { name: 'Mitrudern' }).nth(1).click();
// Login as cox again
await sharedPage.goto("/auth/logout");
await sharedPage.goto("/auth");
await sharedPage.getByPlaceholder("Name").click();
await sharedPage.getByPlaceholder("Name").fill("cox");
await sharedPage.getByPlaceholder("Name").press("Tab");
await sharedPage.getByPlaceholder("Passwort").fill("cox");
await sharedPage.getByPlaceholder("Passwort").press("Enter");
await page.goto("/auth/logout");
await page.waitForURL("/auth");
await login(page, "cox", "cox");
await sharedPage.goto("/planned");
await page.goto("/planned");
// ... now I can cancel trip
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await sharedPage.getByRole("button", { name: "Ausfahrt absagen" }).click();
await expect(sharedPage.locator("body")).toContainText(
// Now cancel the trip
await page.getByRole("link", { name: "Details" }).nth(1).click();
await page.getByRole("button", { name: "Ausfahrt absagen" }).click();
await expect(page.locator("body")).toContainText(
"Ausfahrt erfolgreich aktualisiert.",
);
await expect(sharedPage.locator("body")).toContainText("(Absage cox)");
// Done with the test -> cancel the cancellation of the trip, otherwise the afterAll function below fails
await sharedPage.getByRole("link", { name: "Details" }).nth(1).click();
await sharedPage.getByRole("spinbutton").click();
await sharedPage.getByRole("spinbutton").fill("3");
await sharedPage.getByRole("button", { name: "Speichern" }).click();
// deregistering
await sharedPage.goto("/auth/logout");
await sharedPage.goto("/auth");
await sharedPage.getByPlaceholder("Name").click();
await sharedPage.getByPlaceholder("Name").fill("rower");
await sharedPage.getByPlaceholder("Name").press("Tab");
await sharedPage.getByPlaceholder("Passwort").fill("rower");
await sharedPage.getByPlaceholder("Passwort").press("Enter");
await sharedPage.goto("/planned");
await sharedPage.getByRole('link', { name: 'Abmelden' }).click();
// now cox can delete trip again in afterAll
await sharedPage.goto("/auth/logout");
await sharedPage.goto("/auth");
await sharedPage.getByPlaceholder("Name").click();
await sharedPage.getByPlaceholder("Name").fill("cox");
await sharedPage.getByPlaceholder("Name").press("Tab");
await sharedPage.getByPlaceholder("Passwort").fill("cox");
await sharedPage.getByPlaceholder("Passwort").press("Enter");
});
test.afterAll(async () => {
await sharedPage.goto("/planned");
await sharedPage.getByRole('link', { name: 'Details' }).nth(1).click();
await sharedPage.getByRole("link", { name: "Termin löschen" }).click();
await sharedPage.close();
await expect(page.locator("body")).toContainText("(Absage cox)");
});
// TODO: 'Immer anzeigen' (also verify the functionality), 'Gesperrt' + type

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

@@ -0,0 +1,29 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { Page } from '@playwright/test';
const execAsync = promisify(exec);
export async function resetDatabase(): Promise<void> {
await execAsync('cd .. && ./reset_test_data.sh');
}
export async function login(page: Page, username: string, password: string): Promise<void> {
// Clear cookies to ensure clean state
await page.context().clearCookies();
// Navigate to auth page and wait for it to fully load
await page.goto("/auth", { waitUntil: 'load' });
await page.waitForLoadState('networkidle');
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill(username);
await page.getByPlaceholder("Passwort").click();
await page.getByPlaceholder("Passwort").fill(password);
// Wait for navigation after form submission
await Promise.all([
page.waitForURL(/\/(planned|log|$)/, { timeout: 10000 }),
page.getByPlaceholder("Passwort").press("Enter")
]);
}

View File

@@ -1,4 +1,9 @@
import { test, expect } from "@playwright/test";
import { resetDatabase } from "./helpers";
test.beforeEach(async () => {
await resetDatabase();
});
test("Cox can start and cancel trip", async ({ page }, testInfo) => {
await page.goto("/auth");
@@ -34,12 +39,6 @@ test("Cox can start and cancel trip", async ({ page }, testInfo) => {
"Ausfahrt erfolgreich hinzugefügt",
);
await expect(page.locator("body")).toContainText("Joe");
await page.getByRole("link", { name: "Joe" }).click();
page.once("dialog", (dialog) => {
dialog.accept().catch(() => {});
});
await page.getByRole("link", { name: "Löschen" }).click();
});
test("Cox can start and finish trip", async ({ page }, testInfo) => {
@@ -102,28 +101,6 @@ test("Cox can start and finish trip", async ({ page }, testInfo) => {
await expect(page.locator('body')).toContainText('(cox2)');
await expect(page.locator('body')).toContainText('Ottensheim (25 km)');
await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2');
//Ausloggen...
await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click();
await page.getByRole('link', { name: 'Ausloggen' }).click();
// Login as admin
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("main");
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("admin");
await page.getByPlaceholder("Passwort").press("Enter");
await page.goto("/log/show");
await page.getByRole('link', { name: 'Joe' }).nth(1).click();
page.once("dialog", (dialog) => {
dialog.accept().catch(() => {});
});
await page.getByRole('link', { name: 'Löschen' }).click();
//Ausloggen...
await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click();
await page.getByRole('link', { name: 'Ausloggen' }).click();
});
test("Kiosk can start and cancel trip", async ({ page }, testInfo) => {
@@ -151,12 +128,6 @@ test("Kiosk can start and cancel trip", async ({ page }, testInfo) => {
"Ausfahrt erfolgreich hinzugefügt",
);
await expect(page.locator("body")).toContainText("Joe");
await page.getByRole("link", { name: "Joe" }).click();
page.once("dialog", (dialog) => {
dialog.accept().catch(() => {});
});
await page.getByRole("link", { name: "Löschen" }).click();
});
test("Kiosk can start and finish trip", async ({ page }, testInfo) => {
@@ -210,29 +181,6 @@ test("Kiosk can start and finish trip", async ({ page }, testInfo) => {
await expect(page.locator('body')).toContainText('Joe');
await expect(page.locator('body')).toContainText('Ottensheim (25 km)');
await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2');
//Ausloggen...
await page.context().clearCookies();
await page.goto("/auth");
// Login as admin
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("main");
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("admin");
await page.getByPlaceholder("Passwort").press("Enter");
await page.goto("/log/show");
await page.getByRole('link', { name: 'Joe' }).nth(1).click();
page.once("dialog", (dialog) => {
dialog.accept().catch(() => {});
});
await page.getByRole('link', { name: 'Löschen' }).click();
//Ausloggen...
await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click();
await page.getByRole('link', { name: 'Ausloggen' }).click();
});
test("Cox can start and finish trip with cox steering only", async ({ page }, testInfo) => {
@@ -286,29 +234,6 @@ test("Cox can start and finish trip with cox steering only", async ({ page }, te
await page.goto('/log/show');
await expect(page.locator('body')).toContainText('cox_only_steering_boat');
await expect(page.locator('body')).toContainText('Ottensheim (25 km)');
//Ausloggen...
await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click();
await page.getByRole('link', { name: 'Ausloggen' }).click();
// Login as admin
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("main");
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("admin");
await page.getByPlaceholder("Passwort").press("Enter");
await page.goto("/log/show");
await page.getByRole("link", { name: "cox_only_steering_boat" }).click();
page.once("dialog", (dialog) => {
dialog.accept().catch(() => {});
});
await page.getByRole('link', { name: 'Löschen' }).click();
//Ausloggen...
await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click();
await page.getByRole('link', { name: 'Ausloggen' }).click();
});
test("Kiosk can start and finish trip in one stop", async ({ page }, testInfo) => {
@@ -355,27 +280,4 @@ test("Kiosk can start and finish trip in one stop", async ({ page }, testInfo) =
await expect(page.locator('body')).toContainText('(cox2)');
await expect(page.locator('body')).toContainText('a (1 km)');
await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2');
//Ausloggen...
await page.context().clearCookies();
await page.goto("/auth");
// Login as admin
await page.getByPlaceholder("Name").click();
await page.getByPlaceholder("Name").fill("main");
await page.getByPlaceholder("Name").press("Tab");
await page.getByPlaceholder("Passwort").fill("admin");
await page.getByPlaceholder("Passwort").press("Enter");
await page.goto("/log/show");
await page.getByRole('link', { name: 'Joe' }).nth(1).click();
page.once("dialog", (dialog) => {
dialog.accept().catch(() => {});
});
await page.getByRole('link', { name: 'Löschen' }).click();
//Ausloggen...
await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click();
await page.getByRole('link', { name: 'Ausloggen' }).click();
});

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "rowt",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

19
reset_test_data.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -e
DB_FILE="db.sqlite"
# Clear all data and reseed
sqlite3 "$DB_FILE" << 'EOF'
PRAGMA writable_schema = 1;
DELETE FROM sqlite_sequence;
PRAGMA writable_schema = 0;
PRAGMA foreign_keys = OFF;
EOF
# Get all tables and delete from them
sqlite3 "$DB_FILE" "SELECT 'DELETE FROM ' || name || ';' FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';" | sqlite3 "$DB_FILE"
# Re-enable foreign keys and reseed
sqlite3 "$DB_FILE" "PRAGMA foreign_keys = ON;"
sqlite3 "$DB_FILE" < seeds.sql

View File

@@ -1,8 +1,8 @@
use std::ops::DerefMut;
use chrono::NaiveDateTime;
use rocket::FromForm;
use rocket::serde::{Deserialize, Serialize};
use rocket::FromForm;
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
use crate::model::boathouse::Boathouse;

View File

@@ -1,7 +1,10 @@
use rocket::serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
use crate::tera::board::boathouse::FormBoathouseToAdd;
use crate::{
model::{log::Log, user::AllowedToUpdateBoathouse},
tera::board::boathouse::FormBoathouseToAdd,
};
use super::boat::Boat;
@@ -114,7 +117,11 @@ impl Boathouse {
BoathouseAisles::from(db, boathouses).await
}
pub async fn create(db: &SqlitePool, data: FormBoathouseToAdd) -> Result<(), String> {
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,
@@ -125,6 +132,17 @@ impl Boathouse {
.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(())
}
@@ -135,10 +153,20 @@ impl Boathouse {
.ok()
}
pub async fn delete(&self, db: &SqlitePool) {
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

@@ -12,7 +12,7 @@ use super::{
notification::Notification,
role::Role,
rower::Rower,
user::User,
user::{User, VorstandUser},
};
use crate::model::user::VecUser;
@@ -193,11 +193,6 @@ impl LogbookWithBoatAndRowers {
}
}
#[derive(Debug, PartialEq)]
pub enum LogbookAdminUpdateError {
NotAllowed,
}
#[derive(Debug, PartialEq)]
pub enum LogbookUpdateError {
NotYourEntry,
@@ -634,16 +629,7 @@ ORDER BY departure DESC
Ok(ret)
}
pub async fn update(
&self,
db: &SqlitePool,
data: LogToUpdate,
user: &User,
) -> Result<(), LogbookAdminUpdateError> {
if !user.has_role(db, "Vorstand").await {
return Err(LogbookAdminUpdateError::NotAllowed);
}
pub async fn update(&self, db: &SqlitePool, data: LogToUpdate, changed_by: &VorstandUser) {
sqlx::query!(
"UPDATE logbook SET boat_id=?, shipmaster=?, steering_person=?, shipmaster_only_steering=?, departure=?, arrival=?, destination=?, distance_in_km=?, comments=?, logtype=? WHERE id=?",
data.boat_id,
@@ -660,7 +646,12 @@ ORDER BY departure DESC
)
.execute(db)
.await.unwrap();
Ok(())
Log::create(
db,
format!("{changed_by} updated log entry={:?} to {:?}", self, data),
)
.await;
}
async fn remove_rowers(&self, db: &mut Transaction<'_, Sqlite>) {

View File

@@ -207,7 +207,7 @@ dein Vereinsbeitrag für das aktuelle Jahr beträgt {}€",
fees.name
))
}
content.push_str("\nBitte überweise diesen auf folgendes Konto: IBAN: AT58 2032 0321 0072 9256. Auf https://app.rudernlinz.at/planned findest du einen QR Code, den du mit deiner Bankapp scannen kannst um alle Eingaben bereits ausgefüllt zu haben.\n\n\
content.push_str("\nBitte überweise diesen auf folgendes Konto: IBAN: AT58 2032 0321 0072 9256 (Name: ASKÖ Ruderverein Donau Linz). Auf https://app.rudernlinz.at/planned findest du einen QR Code, den du mit deiner Bankapp scannen kannst um alle Eingaben bereits ausgefüllt zu haben.\n\n\
Falls die Berechnung nicht stimmt (korrekte Preise findest du unter https://rudernlinz.at/unser-verein/gebuhren/) melde dich bitte bei kassier@rudernlinz.at. @Studenten: Bitte die aktuelle Studienbestätigung an kassier@rudernlinz.at schicken.\n\n\
Wenn du die Vereinsgebühren schon bezahlt hast, kannst du diese Mail einfach ignorieren.\n\n
Beste Grüße\n\
@@ -333,7 +333,7 @@ Dein Vereinsbeitrag für das aktuelle Jahr beträgt {}€",
Gemäß § 7 Abs. 3 lit. c unseres Status behalten wir uns vor, bei ausbleibender Zahlung die Mitgliedschaft zu beenden. Dies möchten wir vermeiden und hoffen auf deine Unterstützung.\n\n\
Bei Fragen oder Problemen stehen wir gerne zur Verfügung.
Bankverbindung: IBAN: AT58 2032 0321 0072 9256 (Unter https://app.rudernlinz.at/planned findest du einen QR Code, den du mit deiner Bankapp scannen kannst um alle Eingaben bereits ausgefüllt zu haben.)
Bankverbindung: IBAN: AT58 2032 0321 0072 9256 (Name: ASKÖ Ruderverein Donau Linz; unter https://app.rudernlinz.at/planned findest du einen QR Code, den du mit deiner Bankapp scannen kannst um alle Eingaben bereits ausgefüllt zu haben.)
Mit freundlichen Grüßen,\n\
Der Vorstand");

View File

@@ -26,6 +26,22 @@ impl Notification {
.await
.ok()
}
pub async fn oldest_unread_with_action(db: &SqlitePool, user_id: i64) -> Option<Self> {
sqlx::query_as!(
Self,
"SELECT id, user_id, message, read_at, created_at, category, link, action_after_reading
FROM notification
WHERE user_id = ? AND read_at IS NULL AND action_after_reading IS NOT NULL
ORDER BY created_at ASC
LIMIT 1",
user_id
)
.fetch_optional(db)
.await
.unwrap()
}
pub async fn create_with_tx(
db: &mut Transaction<'_, Sqlite>,
user: &User,

View File

@@ -1,5 +1,6 @@
use super::Trip;
use crate::model::{
log::Log,
notification::Notification,
planned::{tripdetails::TripDetails, triptype::TripType},
user::{ErgoUser, SteeringUser, User},
@@ -34,6 +35,8 @@ impl Trip {
.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;
}

View File

@@ -8,6 +8,7 @@ use super::{
trip::{Trip, TripWithDetails},
triptype::TripType,
};
use std::fmt::Display;
#[derive(FromRow, Debug, Serialize, Deserialize)]
pub struct TripDetails {
@@ -22,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`

View File

@@ -104,9 +104,11 @@ pub struct Stat {
impl Stat {
pub async fn guest(db: &SqlitePool, year: Option<i32>) -> Stat {
let year = match year {
Some(year) => year,
None => chrono::Local::now().year(),
let year = year.unwrap_or_else(|| chrono::Local::now().year());
let year_filter = if year == 0 {
String::new()
} else {
format!("AND l.arrival LIKE '{}-%'", year)
};
//TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server)
// proper guests
@@ -121,7 +123,7 @@ LEFT JOIN (
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;
WHERE l.distance_in_km IS NOT NULL {year_filter} AND not b.external;
"
))
.fetch_one(db)
@@ -131,21 +133,16 @@ WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%' AND not b.exter
let guest_km: i32 = guests.get(0);
let guest_amount_trips: i32 = guests.get(1);
// e.g. scheckbücher
// e.g. scheckbücher (users without any role)
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'
)
WHERE u.id NOT IN (SELECT user_id FROM user_role)
AND l.distance_in_km IS NOT NULL
AND l.arrival LIKE '{year}-%'
{year_filter}
AND u.name != 'Externe Steuerperson';
"
))
@@ -183,25 +180,20 @@ AND u.name != 'Externe Steuerperson';
}
pub async fn people(db: &SqlitePool, year: Option<i32>) -> Vec<Stat> {
let year = match year {
Some(year) => year,
None => chrono::Local::now().year(),
let year = year.unwrap_or_else(|| chrono::Local::now().year());
let year_filter = if year == 0 {
String::new()
} else {
format!("AND l.arrival LIKE '{}-%'", 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
FROM user 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'
WHERE l.distance_in_km IS NOT NULL {year_filter} AND u.name != 'Externe Steuerperson'
GROUP BY u.name
ORDER BY rowed_km DESC, u.name;
"

View File

@@ -8,7 +8,7 @@ use crate::model::{
notification::Notification,
role::Role,
};
use chrono::NaiveDate;
use chrono::{Datelike, Local, NaiveDate};
use rocket::{fs::TempFile, tokio::io::AsyncReadExt};
use sqlx::SqlitePool;
@@ -342,12 +342,33 @@ impl User {
None,
)
.await;
ActivityBuilder::new(&format!("{updated_by} hat {self} zum normalen Mitglied gemacht (keine Steuerperson/Schiffsführer mehr)"))
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:?}")),
};
@@ -508,6 +529,13 @@ impl User {
}
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
@@ -550,4 +578,32 @@ impl User {
Ok(())
}
pub(crate) async fn has_to_pay_einschreibgebuehr_this_year(&self, db: &SqlitePool) -> bool {
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
{
return true;
}
}
}
}
false
}
pub(crate) fn has_to_pay_only_half(&self) -> bool {
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")
{
let halfprice_startdate =
NaiveDate::from_ymd_opt(Local::now().year(), 7, 1).unwrap();
return member_since_date >= halfprice_startdate;
}
}
false
}
}

View File

@@ -1,10 +1,9 @@
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,
model::family::Family, BOAT_STORAGE, DUAL_MEMBERSHIP, EINSCHREIBGEBUEHR, FAMILY_THREE_OR_MORE,
FAMILY_TWO, FOERDERND, REGULAR, RENNRUDERBEITRAG, SCHECKBUCH, STUDENT_OR_PUPIL, TRIAL_ROWING,
TRIAL_ROWING_REDUCED, UNTERSTUETZEND,
};
use chrono::{Datelike, Local, NaiveDate};
use serde::Serialize;
use sqlx::SqlitePool;
@@ -81,30 +80,52 @@ impl User {
let mut fee = Fee::new();
if let Some(family) = Family::find_by_opt_id(db, self.family_id).await {
let mut einschreibgebuehr = false;
let mut half_price = true;
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);
fee.merge(member.fee_without_families(db, true).await);
if member.has_to_pay_einschreibgebuehr_this_year(db).await {
einschreibgebuehr = true;
}
if !member.has_to_pay_only_half() {
half_price = false;
}
}
if family.amount_family_members(db).await > 2 {
fee.add("Familie 3+ Personen".into(), FAMILY_THREE_OR_MORE);
if half_price {
fee.add(
"Familie 3+ Personen (Halbpreis)".into(),
FAMILY_THREE_OR_MORE / 2,
);
} else {
fee.add("Familie 3+ Personen".into(), FAMILY_THREE_OR_MORE);
}
} else {
fee.add("Familie 2 Personen".into(), FAMILY_TWO);
if half_price {
fee.add("Familie 2 Personen (Halbpreis)".into(), FAMILY_TWO / 2);
} else {
fee.add("Familie 2 Personen".into(), FAMILY_TWO);
}
}
if einschreibgebuehr {
fee.add("Einschreibgebühr (Familie)".into(), EINSCHREIBGEBUEHR);
}
} else {
fee.add_person(self);
if self.has_role(db, "paid").await {
fee.paid();
}
fee.merge(self.fee_without_families(db).await);
fee.merge(self.fee_without_families(db, false).await);
}
Some(fee)
}
async fn fee_without_families(&self, db: &SqlitePool) -> Fee {
async fn fee_without_families(&self, db: &SqlitePool, entry_fee_paid_with_family: bool) -> Fee {
let mut fee = Fee::new();
if !self.has_role(db, "Donau Linz").await
@@ -125,38 +146,24 @@ impl User {
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);
}
}
if self.has_to_pay_only_half() {
fee.add(
format!("{}x Bootsplatz (Halbpreis)", amount_boats),
amount_boats * BOAT_STORAGE / 2,
);
} else {
fee.add(
format!("{}x Bootsplatz", amount_boats),
amount_boats * BOAT_STORAGE,
);
}
}
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_to_pay_einschreibgebuehr_this_year(db).await && !entry_fee_paid_with_family {
fee.add("Einschreibgebühr".into(), EINSCHREIBGEBUEHR);
}
let halfprice = self.has_to_pay_only_half();
if self.has_role(db, "schnupperant").await {
if self.has_role(db, "Student").await || self.has_role(db, "Schüler").await {

490
src/model/user/merge.rs Normal file
View File

@@ -0,0 +1,490 @@
use serde::Serialize;
use sqlx::{Row, Sqlite, SqlitePool, Transaction};
use std::ops::DerefMut;
use super::{ManageUserUser, User};
use crate::model::{activity::ActivityBuilder, stat::Stat};
#[derive(Serialize, Debug, Clone)]
pub struct UserWithKm {
pub id: i64,
pub name: String,
pub total_km: i32,
pub trip_count: i32,
pub deleted: bool,
}
impl UserWithKm {
/// Get all users with their total km stats, sorted by name
pub async fn all(db: &SqlitePool) -> Vec<Self> {
sqlx::query(
"
SELECT u.id, u.name, u.deleted,
COALESCE(CAST(SUM(l.distance_in_km) AS INTEGER), 0) AS total_km,
COUNT(r.logbook_id) AS trip_count
FROM user u
LEFT JOIN rower r ON u.id = r.rower_id
LEFT JOIN logbook l ON r.logbook_id = l.id AND l.distance_in_km IS NOT NULL
WHERE u.name != 'Externe Steuerperson'
GROUP BY u.id
ORDER BY u.name COLLATE NOCASE
",
)
.fetch_all(db)
.await
.unwrap()
.into_iter()
.map(|row| UserWithKm {
id: row.get("id"),
name: row.get("name"),
total_km: row.get("total_km"),
trip_count: row.get("trip_count"),
deleted: row.get("deleted"),
})
.collect()
}
}
#[derive(Serialize, Debug)]
pub struct MergePreview {
pub source_user: User,
pub target_user: User,
pub source_total_km: i32,
pub target_total_km: i32,
pub source_trip_count: i32,
pub target_trip_count: i32,
pub rower_entries_to_transfer: i64,
pub rower_conflicts: i64,
pub role_entries_to_transfer: i64,
pub role_conflicts: i64,
pub user_trip_entries_to_transfer: i64,
pub user_trip_conflicts: i64,
pub logbook_shipmaster_entries: i64,
pub logbook_steering_entries: i64,
pub trip_cox_entries: i64,
pub boat_owner_entries: i64,
pub boat_damage_entries: i64,
pub boat_reservation_entries: i64,
pub trailer_reservation_entries: i64,
pub notification_entries: i64,
}
impl User {
/// Generate a preview of what would happen if source user is merged into target user.
/// Source user will be deleted, target user will receive all references.
pub async fn merge_preview(db: &SqlitePool, source: &User, target: &User) -> MergePreview {
let source_stats = Stat::total_km(db, source).await;
let target_stats = Stat::total_km(db, target).await;
// Rower entries to transfer (no conflict - source is in logbooks target isn't)
let rower_entries_to_transfer = sqlx::query_scalar!(
"SELECT COUNT(*) FROM rower
WHERE rower_id = ?
AND logbook_id NOT IN (SELECT logbook_id FROM rower WHERE rower_id = ?)",
source.id,
target.id
)
.fetch_one(db)
.await
.unwrap();
// Rower conflicts (both users in same logbook - will delete source's entry)
let rower_conflicts = sqlx::query_scalar!(
"SELECT COUNT(*) FROM rower
WHERE rower_id = ?
AND logbook_id IN (SELECT logbook_id FROM rower WHERE rower_id = ?)",
source.id,
target.id
)
.fetch_one(db)
.await
.unwrap();
// Role entries to transfer (no conflict)
let role_entries_to_transfer = sqlx::query_scalar!(
"SELECT COUNT(*) FROM user_role
WHERE user_id = ?
AND role_id NOT IN (SELECT role_id FROM user_role WHERE user_id = ?)",
source.id,
target.id
)
.fetch_one(db)
.await
.unwrap();
// Role conflicts (both have same role - will delete source's entry)
let role_conflicts = sqlx::query_scalar!(
"SELECT COUNT(*) FROM user_role
WHERE user_id = ?
AND role_id IN (SELECT role_id FROM user_role WHERE user_id = ?)",
source.id,
target.id
)
.fetch_one(db)
.await
.unwrap();
// User trip entries to transfer (no conflict)
let user_trip_entries_to_transfer = sqlx::query_scalar!(
"SELECT COUNT(*) FROM user_trip
WHERE user_id = ?
AND trip_details_id NOT IN (SELECT trip_details_id FROM user_trip WHERE user_id = ?)",
source.id,
target.id
)
.fetch_one(db)
.await
.unwrap();
// User trip conflicts
let user_trip_conflicts = sqlx::query_scalar!(
"SELECT COUNT(*) FROM user_trip
WHERE user_id = ?
AND trip_details_id IN (SELECT trip_details_id FROM user_trip WHERE user_id = ?)",
source.id,
target.id
)
.fetch_one(db)
.await
.unwrap();
// Simple counts for other tables
let logbook_shipmaster_entries = sqlx::query_scalar!(
"SELECT COUNT(*) FROM logbook WHERE shipmaster = ?",
source.id
)
.fetch_one(db)
.await
.unwrap();
let logbook_steering_entries = sqlx::query_scalar!(
"SELECT COUNT(*) FROM logbook WHERE steering_person = ?",
source.id
)
.fetch_one(db)
.await
.unwrap();
let trip_cox_entries =
sqlx::query_scalar!("SELECT COUNT(*) FROM trip WHERE cox_id = ?", source.id)
.fetch_one(db)
.await
.unwrap();
let boat_owner_entries =
sqlx::query_scalar!("SELECT COUNT(*) FROM boat WHERE owner = ?", source.id)
.fetch_one(db)
.await
.unwrap();
let boat_damage_entries = sqlx::query_scalar!(
"SELECT COUNT(*) FROM boat_damage
WHERE user_id_created = ? OR user_id_fixed = ? OR user_id_verified = ?",
source.id,
source.id,
source.id
)
.fetch_one(db)
.await
.unwrap();
let boat_reservation_entries = sqlx::query_scalar!(
"SELECT COUNT(*) FROM boat_reservation
WHERE user_id_applicant = ? OR user_id_confirmation = ?",
source.id,
source.id
)
.fetch_one(db)
.await
.unwrap();
let trailer_reservation_entries = sqlx::query_scalar!(
"SELECT COUNT(*) FROM trailer_reservation
WHERE user_id_applicant = ? OR user_id_confirmation = ?",
source.id,
source.id
)
.fetch_one(db)
.await
.unwrap();
let notification_entries = sqlx::query_scalar!(
"SELECT COUNT(*) FROM notification WHERE user_id = ?",
source.id
)
.fetch_one(db)
.await
.unwrap();
MergePreview {
source_user: source.clone(),
target_user: target.clone(),
source_total_km: source_stats.rowed_km,
target_total_km: target_stats.rowed_km,
source_trip_count: source_stats.amount_trips,
target_trip_count: target_stats.amount_trips,
rower_entries_to_transfer,
rower_conflicts,
role_entries_to_transfer,
role_conflicts,
user_trip_entries_to_transfer,
user_trip_conflicts,
logbook_shipmaster_entries,
logbook_steering_entries,
trip_cox_entries,
boat_owner_entries,
boat_damage_entries,
boat_reservation_entries,
trailer_reservation_entries,
notification_entries,
}
}
/// Merge source user into target user, then hard delete source.
/// All foreign key references are transferred from source to target.
/// Returns Ok(()) on success, Err with description on failure.
pub async fn merge_into(
db: &SqlitePool,
source: &User,
target: &User,
merged_by: &ManageUserUser,
) -> Result<(), String> {
// Validation
if source.id == target.id {
return Err("Kann Benutzer nicht mit sich selbst zusammenführen".into());
}
if source.name == "Externe Steuerperson" {
return Err("'Externe Steuerperson' kann nicht zusammengeführt werden".into());
}
if source.on_water(db).await {
return Err(format!(
"{} ist gerade auf dem Wasser und kann nicht zusammengeführt werden",
source.name
));
}
let mut tx = db.begin().await.unwrap();
// Execute merge in transaction
Self::merge_into_tx(&mut tx, source, target).await?;
// Log activity
ActivityBuilder::new(&format!(
"{} hat Benutzer '{}' ({} km, {} Ausfahrten) in '{}' zusammengeführt und gelöscht.",
merged_by.name,
source.name,
Stat::total_km(db, source).await.rowed_km,
Stat::total_km(db, source).await.amount_trips,
target.name
))
.user(target)
.save_tx(&mut tx)
.await;
tx.commit().await.unwrap();
Ok(())
}
async fn merge_into_tx(
tx: &mut Transaction<'_, Sqlite>,
source: &User,
target: &User,
) -> Result<(), String> {
// Step 1: DELETE conflicts (where both users have same FK target)
// Delete rower entries where both users rowed in same logbook
sqlx::query!(
"DELETE FROM rower
WHERE rower_id = ?
AND logbook_id IN (SELECT logbook_id FROM rower WHERE rower_id = ?)",
source.id,
target.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// Delete role entries where both users have same role
sqlx::query!(
"DELETE FROM user_role
WHERE user_id = ?
AND role_id IN (SELECT role_id FROM user_role WHERE user_id = ?)",
source.id,
target.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// Delete user_trip entries where both users in same trip
sqlx::query!(
"DELETE FROM user_trip
WHERE user_id = ?
AND trip_details_id IN (SELECT trip_details_id FROM user_trip WHERE user_id = ?)",
source.id,
target.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// Step 2: UPDATE remaining references
// rower.rower_id
sqlx::query!(
"UPDATE rower SET rower_id = ? WHERE rower_id = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// user_role.user_id
sqlx::query!(
"UPDATE user_role SET user_id = ? WHERE user_id = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// user_trip.user_id
sqlx::query!(
"UPDATE user_trip SET user_id = ? WHERE user_id = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// logbook.shipmaster
sqlx::query!(
"UPDATE logbook SET shipmaster = ? WHERE shipmaster = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// logbook.steering_person
sqlx::query!(
"UPDATE logbook SET steering_person = ? WHERE steering_person = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// trip.cox_id
sqlx::query!(
"UPDATE trip SET cox_id = ? WHERE cox_id = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// boat.owner
sqlx::query!(
"UPDATE boat SET owner = ? WHERE owner = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// boat_damage (3 columns)
sqlx::query!(
"UPDATE boat_damage SET user_id_created = ? WHERE user_id_created = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
sqlx::query!(
"UPDATE boat_damage SET user_id_fixed = ? WHERE user_id_fixed = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
sqlx::query!(
"UPDATE boat_damage SET user_id_verified = ? WHERE user_id_verified = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// boat_reservation (2 columns)
sqlx::query!(
"UPDATE boat_reservation SET user_id_applicant = ? WHERE user_id_applicant = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
sqlx::query!(
"UPDATE boat_reservation SET user_id_confirmation = ? WHERE user_id_confirmation = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// trailer_reservation (2 columns)
sqlx::query!(
"UPDATE trailer_reservation SET user_id_applicant = ? WHERE user_id_applicant = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
sqlx::query!(
"UPDATE trailer_reservation SET user_id_confirmation = ? WHERE user_id_confirmation = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// notification.user_id
sqlx::query!(
"UPDATE notification SET user_id = ? WHERE user_id = ?",
target.id,
source.id
)
.execute(tx.deref_mut())
.await
.unwrap();
// Step 3: Hard delete the source user
sqlx::query!("DELETE FROM user WHERE id = ?", source.id)
.execute(tx.deref_mut())
.await
.unwrap();
Ok(())
}
}

View File

@@ -1,21 +1,20 @@
use std::{fmt::Display, ops::DerefMut};
use argon2::{Argon2, PasswordHasher, password_hash::SaltString};
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
use chrono::{Datelike, Local, NaiveDate};
use log::info;
use rocket::async_trait;
use rocket::{
Request,
http::{Cookie, Status},
request::{FromRequest, Outcome},
time::{Duration, OffsetDateTime},
Request,
};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
use super::activity::{ActivityBuilder, ReasonAuth};
use super::{
Day,
log::Log,
logbook::Logbook,
mail::Mail,
@@ -24,6 +23,7 @@ use super::{
planned::tripdetails::TripDetails,
role::Role,
stat::Stat,
Day,
};
use crate::AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD;
use scheckbuch::ScheckbuchUser;
@@ -33,6 +33,7 @@ pub(crate) mod clubmember;
mod fee;
pub(crate) mod foerdernd;
pub(crate) mod member;
pub mod merge;
pub(crate) mod regular;
pub(crate) mod scheckbuch;
pub(crate) mod schnupperant;
@@ -88,20 +89,30 @@ pub struct UserWithDetails {
pub allowed_to_steer: bool,
pub on_water: bool,
pub roles: Vec<String>,
pub action_notification: Option<Notification>,
}
impl UserWithDetails {
pub async fn from_user(user: User, db: &SqlitePool) -> Self {
let allowed_to_steer = user.allowed_to_steer(db).await;
let action_notification = Notification::oldest_unread_with_action(db, user.id).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,
action_notification,
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)]
@@ -129,7 +140,7 @@ impl User {
pub async fn amount_boats(&self, db: &SqlitePool) -> i64 {
sqlx::query!(
"SELECT COUNT(*) as count FROM boat WHERE owner = ?",
"SELECT COUNT(*) as count FROM boat WHERE owner = ? and deleted = 0",
self.id
)
.fetch_one(db)
@@ -303,14 +314,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, 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
)
@@ -351,6 +362,13 @@ WHERE lower(name)=?
}
pub async fn all_with_order(db: &SqlitePool, sort: &str, asc: bool) -> Vec<Self> {
let allowed_sort_columns = ["last_access", "name", "member_since_date"];
let sort_column = if allowed_sort_columns.contains(&sort) {
sort
} else {
"last_access"
};
let mut query = format!(
"
SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, phone, address, family_id, user_token
@@ -358,7 +376,7 @@ WHERE lower(name)=?
WHERE deleted = 0
ORDER BY {}
",
sort
sort_column
);
if !asc {
query.push_str(" DESC");
@@ -424,7 +442,7 @@ WHERE family_id IS NULL;
"
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
"
)
@@ -502,7 +520,7 @@ ASKÖ Ruderverein Donau Linz", self.name),
.save(db)
.await;
return Err(LoginError::InvalidAuthenticationCombo); //User existed sometime ago; has
//been deleted
//been deleted
}
if let Some(user_pw) = user.pw.as_ref() {
@@ -605,9 +623,9 @@ ASKÖ Ruderverein Donau Linz", self.name),
pub(crate) async fn amount_days_to_show(&self, db: &SqlitePool) -> i64 {
if self.allowed_to_steer(db).await {
let end_of_year = NaiveDate::from_ymd_opt(Local::now().year(), 12, 31).unwrap(); //Ok,
//december
//has 31
//days
//december
//has 31
//days
let days_left_in_year = end_of_year
.signed_duration_since(Local::now().date_naive())
.num_days()
@@ -616,9 +634,9 @@ ASKÖ Ruderverein Donau Linz", self.name),
if days_left_in_year <= 31 {
let end_of_next_year =
NaiveDate::from_ymd_opt(Local::now().year() + 1, 12, 31).unwrap(); //Ok,
//december
//has 31
//days
//december
//has 31
//days
end_of_next_year
.signed_duration_since(Local::now().date_naive())
.num_days()
@@ -788,6 +806,7 @@ macro_rules! special_user {
}
impl $name {
#[allow(dead_code)]
pub fn into_inner(self) -> User {
self.user
}
@@ -849,9 +868,10 @@ 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", -"Unterstützend", -"Förderndes Mitglied"); // TODO:
// remove ->
// RegularUser
special_user!(DonauLinzUser, +"Donau Linz", +"Förderndes Mitglied", -"Unterstützend"); // TODO:
// remove ->
// RegularUser
special_user!(ErgoAdminUser, +"ergo-admin", +"admin");
special_user!(SchnupperBetreuerUser, +"schnupper-betreuer");
special_user!(VorstandUser, +"admin", +"Vorstand");
special_user!(EventUser, +"manage_events");
@@ -859,6 +879,7 @@ 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 {
@@ -965,21 +986,17 @@ mod test {
#[sqlx::test]
fn wrong_pw() {
let pool = testdb!();
assert!(
User::login(&pool, "admin".into(), "admi".into())
.await
.is_err()
);
assert!(User::login(&pool, "admin".into(), "admi".into())
.await
.is_err());
}
#[sqlx::test]
fn wrong_username() {
let pool = testdb!();
assert!(
User::login(&pool, "admi".into(), "admin".into())
.await
.is_err()
);
assert!(User::login(&pool, "admi".into(), "admin".into())
.await
.is_err());
}
#[sqlx::test]
@@ -999,11 +1016,9 @@ mod test {
let pool = testdb!();
let user = User::find_by_id(&pool, 1).await.unwrap();
assert!(
User::login(&pool, "admin".into(), "abc".into())
.await
.is_err()
);
assert!(User::login(&pool, "admin".into(), "abc".into())
.await
.is_err());
user.update_pw(&pool, "abc".into()).await;

View File

@@ -1,8 +1,7 @@
use super::{ManageUserUser, User};
use crate::{
NonEmptyString,
model::{activity::ActivityBuilder, mail::Mail, notification::Notification, role::Role},
special_user,
special_user, NonEmptyString,
};
use chrono::NaiveDate;
use rocket::{async_trait, fs::TempFile, tokio::io::AsyncReadExt};
@@ -79,7 +78,7 @@ impl RegularUser {
mail,
"Willkommen im ASKÖ Ruderverein Donau Linz!",
format!(
"Hallo {0},
"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.
@@ -87,7 +86,7 @@ Um dir den Einstieg zu erleichtern, findest du in unserem Handbuch alle wichtige
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 ('{0}' ohne Anführungszeichen) ein, beim ersten Mal kannst du das Passwortfeld leer lassen. Unter 'Geplante Ausfahrten' kannst du dich jederzeit zu den Ausfahrten anmelden.
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.
@@ -95,10 +94,12 @@ Damit du dich noch mehr verbunden fühlst (:-)), haben wir im Bootshaus ein WLAN
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.name),
ASKÖ Ruderverein Donau Linz", self.user.id, self.user.user_token),
smtp_pw,
).await?;

View File

@@ -1,5 +1,6 @@
mod waterlevel;
mod weather;
mod yearly_role_cleanup;
use std::time::Duration;
@@ -13,7 +14,7 @@ pub fn schedule(db: &SqlitePool, config: &Config) {
let db = db.clone();
let openweathermap_key = config.openweathermap_key.clone();
tokio::task::spawn(async {
tokio::task::spawn(async move {
if let Err(e) = waterlevel::update(&db).await {
log::error!("Water level update error: {e}, trying again next time");
}
@@ -24,8 +25,9 @@ pub fn schedule(db: &SqlitePool, config: &Config) {
let mut sched = JobScheduler::new();
// Every hour
let db_for_hourly = db.clone();
sched.add(Job::new("0 0 * * * * *".parse().unwrap(), move || {
let db_clone = db.clone();
let db_clone = db_for_hourly.clone();
// Use block_in_place to run async code in the synchronous function; TODO: Make it
// nicer one's rust (stable) support async closures
task::block_in_place(|| {
@@ -40,6 +42,19 @@ pub fn schedule(db: &SqlitePool, config: &Config) {
});
}));
// January 1st at midnight - yearly role cleanup
let db_for_yearly = db.clone();
sched.add(Job::new("0 0 0 1 1 * *".parse().unwrap(), move || {
let db_clone = db_for_yearly.clone();
task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
if let Err(e) = yearly_role_cleanup::cleanup_roles(&db_clone).await {
log::error!("Yearly role cleanup error: {e}");
}
});
});
}));
let mut interval = time::interval(Duration::from_secs(60));
loop {
sched.tick();

View File

@@ -0,0 +1,158 @@
use crate::model::{notification::Notification, role::Role};
use sqlx::SqlitePool;
pub async fn cleanup_roles(db: &SqlitePool) -> Result<(), String> {
log::info!("Starting yearly role cleanup...");
let mut tx = db.begin().await.map_err(|e| e.to_string())?;
// Find all roles to remove
let paid_role = Role::find_by_name_tx(&mut tx, "paid")
.await
.ok_or("Role 'paid' not found")?;
let schueler_role = Role::find_by_name_tx(&mut tx, "Schüler")
.await
.ok_or("Role 'Schüler' not found")?;
let student_role = Role::find_by_name_tx(&mut tx, "Student")
.await
.ok_or("Role 'Student' not found")?;
let no_einschreibgebuehr_role = Role::find_by_name_tx(&mut tx, "no-einschreibgebuehr")
.await
.ok_or("Role 'no-einschreibgebuehr' not found")?;
let half_rennrudern_role = Role::find_by_name_tx(&mut tx, "half-rennrudern")
.await
.ok_or("Role 'half-rennrudern' not found")?;
let participated_schnupperkurs_role =
Role::find_by_name_tx(&mut tx, "participated_schnupperkurs")
.await
.ok_or("Role 'participated_schnupperkurs' not found")?;
// Find scheckbuch role (needed to exclude users from "paid" removal -> they have still paid
// for the scheckbuch)
let scheckbuch_role = Role::find_by_name_tx(&mut tx, "scheckbuch")
.await
.ok_or("Role 'scheckbuch' not found")?;
// Remove "paid" role from all users EXCEPT those with scheckbuch role
let paid_removed = sqlx::query!(
"DELETE FROM user_role
WHERE role_id = ?
AND user_id NOT IN (
SELECT user_id FROM user_role WHERE role_id = ?
)",
paid_role.id,
scheckbuch_role.id
)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
// Remove other roles from all users
let schueler_removed =
sqlx::query!("DELETE FROM user_role WHERE role_id = ?", schueler_role.id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
let student_removed = sqlx::query!("DELETE FROM user_role WHERE role_id = ?", student_role.id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
let no_einschreibgebuehr_removed = sqlx::query!(
"DELETE FROM user_role WHERE role_id = ?",
no_einschreibgebuehr_role.id
)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
let half_rennrudern_removed = sqlx::query!(
"DELETE FROM user_role WHERE role_id = ?",
half_rennrudern_role.id
)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
let participated_schnupperkurs_removed = sqlx::query!(
"DELETE FROM user_role WHERE role_id = ?",
participated_schnupperkurs_role.id
)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
// Send notifications to admins and Vorstand
let admin_role = Role::find_by_name_tx(&mut tx, "admin")
.await
.ok_or("Role 'admin' not found")?;
let vorstand_role = Role::find_by_name_tx(&mut tx, "Vorstand")
.await
.ok_or("Role 'Vorstand' not found")?;
let notification_message_admin = format!(
"Jährliche Rollenbereinigung abgeschlossen. Die folgenden Rollen wurden entfernt: \
paid ({} Benutzer, außer Scheckbuch-Mitglieder), \
Schüler/Student ({}/{} Benutzer), \
no-einschreibgebuehr ({} Benutzer), \
half-rennrudern ({} Benutzer), \
participated_schnupperkurs ({} Benutzer). \
Die aktualisierten Gebühren können unter https://app.rudernlinz.at/admin/user/fees eingesehen werden.",
paid_removed,
schueler_removed,
student_removed,
no_einschreibgebuehr_removed,
half_rennrudern_removed,
participated_schnupperkurs_removed
);
let notification_message_vorstand = format!(
"Jährliche Rollenbereinigung abgeschlossen. \
Die aktualisierten Gebühren können unter https://app.rudernlinz.at/admin/user/fees eingesehen werden.",
);
// Notify admins
Notification::create_for_role_tx(
&mut tx,
&admin_role,
&notification_message_admin,
"Systembenachrichtigung",
Some("https://app.rudernlinz.at/admin/user/fees"),
None,
)
.await;
// Notify Vorstand
Notification::create_for_role_tx(
&mut tx,
&vorstand_role,
&notification_message_vorstand,
"Systembenachrichtigung",
Some("https://app.rudernlinz.at/admin/user/fees"),
None,
)
.await;
// Commit transaction
tx.commit().await.map_err(|e| e.to_string())?;
log::info!(
"Yearly role cleanup completed successfully: \
paid={}, Schüler={}, Student={}, no-einschreibgebuehr={}, \
half-rennrudern={}, participated_schnupperkurs={} removals",
paid_removed,
schueler_removed,
student_removed,
no_einschreibgebuehr_removed,
half_rennrudern_removed,
participated_schnupperkurs_removed
);
Ok(())
}

View File

@@ -7,11 +7,11 @@ use crate::{
mail::valid_mails,
role::Role,
user::{
AdminUser, AllowedToEditPaymentStatusUser, ManageUserUser, User, UserWithDetails,
UserWithMembershipPdf, UserWithRolesAndMembershipPdf, VorstandUser,
clubmember::ClubMemberUser, foerdernd::FoerderndUser, member::Member,
regular::RegularUser, scheckbuch::ScheckbuchUser, schnupperant::SchnupperantUser,
schnupperinterest::SchnupperInterestUser, unterstuetzend::UnterstuetzendUser,
AdminUser, AllowedToEditPaymentStatusUser, ManageUserUser, User, UserWithDetails,
UserWithMembershipPdf, UserWithRolesAndMembershipPdf, VorstandUser,
},
},
tera::Config,
@@ -19,7 +19,6 @@ use crate::{
use chrono::NaiveDate;
use futures::future::join_all;
use rocket::{
FromForm, Request, Route, State,
form::Form,
fs::TempFile,
get,
@@ -27,9 +26,9 @@ use rocket::{
post,
request::{FlashMessage, FromRequest, Outcome},
response::{Flash, Redirect},
routes,
routes, FromForm, Request, Route, State,
};
use rocket_dyn_templates::{Template, tera::Context};
use rocket_dyn_templates::{tera::Context, Template};
use sqlx::SqlitePool;
// Custom request guard to extract the Referer header
@@ -65,6 +64,7 @@ async fn index(
let user: User = user.into_inner();
let allowed_to_edit = ManageUserUser::new(db, &user).await.is_some();
let is_admin = AdminUser::new(db, &user).await.is_some();
let users: Vec<UserWithRolesAndMembershipPdf> = join_all(user_futures).await;
let financial = Role::all_cluster(db, "financial").await;
@@ -77,6 +77,7 @@ async fn index(
context.insert("flash", &msg.into_inner());
}
context.insert("allowed_to_edit", &allowed_to_edit);
context.insert("is_admin", &is_admin);
context.insert("users", &users);
context.insert("roles", &roles);
context.insert("financial", &financial);
@@ -111,6 +112,7 @@ async fn index_admin(
context.insert("flash", &msg.into_inner());
}
context.insert("allowed_to_edit", &allowed_to_edit);
context.insert("is_admin", &true);
context.insert("users", &users);
context.insert("roles", &roles);
context.insert("financial", &financial);
@@ -307,6 +309,97 @@ async fn delete(db: &State<SqlitePool>, admin: ManageUserUser, user: i32) -> Fla
}
}
use crate::model::user::merge::UserWithKm;
#[get("/user/merge?<source>&<target>")]
async fn merge_page(
db: &State<SqlitePool>,
admin: ManageUserUser,
flash: Option<FlashMessage<'_>>,
source: Option<i32>,
target: Option<i32>,
) -> Template {
let users_with_km = UserWithKm::all(db).await;
let admin_user: User = admin.into_inner();
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("users", &users_with_km);
// If both source and target are selected, show preview
if let (Some(source_id), Some(target_id)) = (source, target) {
if source_id != target_id {
if let (Some(source_user), Some(target_user)) = (
User::find_by_id(db, source_id).await,
User::find_by_id(db, target_id).await,
) {
let preview = User::merge_preview(db, &source_user, &target_user).await;
context.insert("source_user", &source_user);
context.insert("target_user", &target_user);
context.insert("preview", &preview);
}
}
}
context.insert("selected_source", &source);
context.insert("selected_target", &target);
context.insert(
"loggedin_user",
&UserWithDetails::from_user(admin_user, db).await,
);
Template::render("admin/user/merge", context.into_json())
}
#[derive(FromForm, Debug)]
pub struct MergeForm {
source_id: i32,
target_id: i32,
}
#[post("/user/merge", data = "<data>")]
async fn merge_execute(
db: &State<SqlitePool>,
admin: ManageUserUser,
data: Form<MergeForm>,
) -> Flash<Redirect> {
let Some(source_user) = User::find_by_id(db, data.source_id).await else {
return Flash::error(
Redirect::to("/admin/user/merge"),
format!("User mit ID {} existiert nicht", data.source_id),
);
};
let Some(target_user) = User::find_by_id(db, data.target_id).await else {
return Flash::error(
Redirect::to("/admin/user/merge"),
format!("Ziel-User mit ID {} existiert nicht", data.target_id),
);
};
let source_name = source_user.name.clone();
match User::merge_into(db, &source_user, &target_user, &admin).await {
Ok(()) => Flash::success(
Redirect::to(format!("/admin/user/{}", data.target_id)),
format!(
"Benutzer '{}' erfolgreich in '{}' zusammengeführt",
source_name, target_user.name
),
),
Err(e) => Flash::error(
Redirect::to(format!(
"/admin/user/merge?source={}&target={}",
data.source_id, data.target_id
)),
e,
),
}
}
#[derive(FromForm, Debug)]
pub struct MailUpdateForm {
mail: String,
@@ -357,7 +450,7 @@ async fn add_note(
match user.add_note(db, &admin, &data.note).await {
Ok(_) => Flash::success(
Redirect::to(format!("/admin/user/{}", user.id)),
"Notiz hinzugefügt",
"Notiz hinzugefügt. Du findest sie ab sofort unter 'Aktivitäten'.",
),
Err(e) => Flash::error(Redirect::to(format!("/admin/user/{}", user.id)), e),
}
@@ -1438,6 +1531,9 @@ pub fn routes() -> Vec<Route> {
view,
resetpw,
delete,
// Merge
merge_page,
merge_execute,
fees,
fees_paid,
scheckbuch,

View File

@@ -1,17 +1,16 @@
use crate::model::{
boat::Boat,
boathouse::Boathouse,
user::{AdminUser, UserWithDetails, VorstandUser},
user::{AllowedToUpdateBoathouse, UserWithDetails, VorstandUser},
};
use rocket::{
FromForm, Route, State,
form::Form,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
routes,
routes, FromForm, Route, State,
};
use rocket_dyn_templates::{Template, tera::Context};
use rocket_dyn_templates::{tera::Context, Template};
use sqlx::SqlitePool;
#[get("/boathouse")]
@@ -38,6 +37,11 @@ async fn index(
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,
@@ -57,36 +61,29 @@ pub struct FormBoathouseToAdd {
async fn new<'r>(
db: &State<SqlitePool>,
data: Form<FormBoathouseToAdd>,
_admin: AdminUser,
user: AllowedToUpdateBoathouse,
) -> Flash<Redirect> {
match Boathouse::create(db, data.into_inner()).await {
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>, _admin: AdminUser, boathouse_id: i32) -> Flash<Redirect> {
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).await;
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"),
}
}
//#[post("/boat/new", data = "<data>")]
//async fn create(
// db: &State<SqlitePool>,
// data: Form<BoatToAdd<'_>>,
// _admin: AdminUser,
//) -> Flash<Redirect> {
// match Boat::create(db, data.into_inner()).await {
// Ok(_) => Flash::success(Redirect::to("/admin/boat"), "Boot hinzugefügt"),
// Err(e) => Flash::error(Redirect::to("/admin/boat"), e),
// }
//}
pub fn routes() -> Vec<Route> {
routes![index, new, delete]

View File

@@ -1,8 +1,7 @@
use std::env;
use chrono::Utc;
use chrono::{Datelike, Utc};
use rocket::{
FromForm, Route, State,
form::Form,
fs::TempFile,
get,
@@ -10,18 +9,19 @@ use rocket::{
post,
request::FlashMessage,
response::{Flash, Redirect},
routes,
routes, FromForm, Route, State,
};
use rocket_dyn_templates::{Template, context};
use rocket_dyn_templates::{context, Template};
use serde::Serialize;
use sqlx::SqlitePool;
use tera::Context;
use crate::model::{
activity::ActivityBuilder,
log::Log,
notification::Notification,
role::Role,
user::{AdminUser, User, UserWithDetails},
user::{AdminUser, ErgoAdminUser, User, UserWithDetails},
};
#[derive(Serialize)]
@@ -59,7 +59,7 @@ async fn send(db: &State<SqlitePool>, _user: AdminUser) -> Template {
}
#[get("/reset")]
async fn reset(db: &State<SqlitePool>, _user: AdminUser) -> Flash<Redirect> {
async fn reset(db: &State<SqlitePool>, _user: ErgoAdminUser) -> Flash<Redirect> {
sqlx::query!("UPDATE user SET dirty_thirty = NULL, dirty_dozen = NULL;")
.execute(db.inner())
.await
@@ -74,7 +74,7 @@ async fn reset(db: &State<SqlitePool>, _user: AdminUser) -> Flash<Redirect> {
#[get("/<challenge>/user/<user_id>/new?<new>")]
async fn update(
db: &State<SqlitePool>,
_admin: AdminUser,
_admin: ErgoAdminUser,
challenge: &str,
user_id: i64,
new: &str,
@@ -146,47 +146,61 @@ pub struct UserAdd {
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 :-)",
// )
//}
#[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 info@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
sqlx::query!(
"INSERT INTO user_role(user_id, role_id) VALUES (?, ?)",
user.id,
ergo.id
)
.execute(db.inner())
.await
.unwrap();
ActivityBuilder::new(&format!(
"{user} nimmt an der Ergo-Challenge teil und hat gerade die Daten eingegeben."
))
.user(&user)
.save(db)
.await;
Flash::success(
Redirect::to("/ergo"),
"Du hast deine Daten erfolgreich eingegeben. Viel Spaß beim Schwitzen :-)",
)
}
#[derive(FromForm, Debug)]
pub struct ErgoToAdd<'a> {
@@ -359,10 +373,7 @@ async fn new_dozen(
}
pub fn routes() -> Vec<Route> {
routes![
index, new_thirty, new_dozen, send, reset, update,
// new_user
]
routes![index, new_thirty, new_dozen, send, reset, update, new_user]
}
#[cfg(test)]

View File

@@ -1,7 +1,6 @@
use std::net::IpAddr;
use rocket::{
Request, Route, State,
form::Form,
get,
http::{Cookie, CookieJar},
@@ -10,8 +9,9 @@ use rocket::{
response::{Flash, Redirect},
routes,
time::{Duration, OffsetDateTime},
Request, Route, State,
};
use rocket_dyn_templates::{Template, context};
use rocket_dyn_templates::{context, Template};
use sqlx::SqlitePool;
use tera::Context;
@@ -22,8 +22,8 @@ use crate::{
distance::Distance,
log::Log,
logbook::{
LogToAdd, LogToFinalize, LogToUpdate, Logbook, LogbookAdminUpdateError,
LogbookCreateError, LogbookDeleteError, LogbookUpdateError,
LogToAdd, LogToFinalize, LogToUpdate, Logbook, LogbookCreateError, LogbookDeleteError,
LogbookUpdateError,
},
logtype::LogType,
planned::trip::Trip,
@@ -47,12 +47,46 @@ impl<'r> FromRequest<'r> for KioskCookie {
}
#[get("/", rank = 2)]
async fn index(
async fn index_loggedin(
db: &State<SqlitePool>,
flash: Option<FlashMessage<'_>>,
user: DonauLinzUser,
) -> Template {
let mut context = Context::new();
let boats = Boat::for_user(db, &user).await;
context.insert("boats", &boats);
context.insert(
"loggedin_user",
&UserWithDetails::from_user(user.into_inner(), db).await,
);
let context = index(db, flash, context).await;
Template::render("log", context.into_json())
}
#[get("/")]
async fn index_kiosk(
db: &State<SqlitePool>,
flash: Option<FlashMessage<'_>>,
_kiosk: KioskCookie,
) -> Template {
let mut context = Context::new();
let boats = Boat::all(db).await;
context.insert("boats", &boats);
context.insert("show_kiosk_header", &true);
let context = index(db, flash, context).await;
Template::render("kiosk", context.into_json())
}
async fn index(db: &SqlitePool, flash: Option<FlashMessage<'_>>, mut context: Context) -> Context {
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
let mut coxes: Vec<UserWithDetails> = futures::future::join_all(
User::cox(db)
@@ -61,9 +95,7 @@ async fn index(
.map(|user| UserWithDetails::from_user(user, db)),
)
.await;
coxes.retain(|u| {
u.roles.contains(&"Donau Linz".into()) || u.roles.contains(&"scheckbuch".into())
});
coxes.retain(|u| u.roles.contains(&"Donau Linz".into()));
let mut users: Vec<UserWithDetails> = futures::future::join_all(
User::all(db)
@@ -72,23 +104,13 @@ async fn index(
.map(|user| UserWithDetails::from_user(user, db)),
)
.await;
users.retain(|u| {
u.roles.contains(&"Donau Linz".into())
|| u.roles.contains(&"scheckbuch".into())
|| u.user.name == "Externe Steuerperson"
});
users.retain(|u| u.allowed_to_row());
let logtypes = LogType::all(db).await;
let distances = Distance::all(db).await;
let on_water = Logbook::on_water(db).await;
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("boats", &boats);
context.insert("planned_trips", &Trip::get_for_today(db).await);
context.insert(
"reservations",
@@ -97,14 +119,10 @@ async fn index(
context.insert("coxes", &coxes);
context.insert("users", &users);
context.insert("logtypes", &logtypes);
context.insert(
"loggedin_user",
&UserWithDetails::from_user(user.into_inner(), db).await,
);
context.insert("on_water", &on_water);
context.insert("distances", &distances);
Template::render("log", context.into_json())
context
}
#[get("/show", rank = 3)]
@@ -179,63 +197,6 @@ async fn new_kiosk(
Redirect::to("/log")
}
#[get("/")]
async fn kiosk(
db: &State<SqlitePool>,
flash: Option<FlashMessage<'_>>,
_kiosk: KioskCookie,
) -> Template {
let boats = Boat::all(db).await;
let mut coxes: Vec<UserWithDetails> = futures::future::join_all(
User::cox(db)
.await
.into_iter()
.map(|user| UserWithDetails::from_user(user, db)),
)
.await;
coxes.retain(|u| {
u.roles.contains(&"Donau Linz".into()) || u.roles.contains(&"scheckbuch".into())
});
let mut users: Vec<UserWithDetails> = futures::future::join_all(
User::all(db)
.await
.into_iter()
.map(|user| UserWithDetails::from_user(user, db)),
)
.await;
users.retain(|u| {
u.roles.contains(&"Donau Linz".into()) || u.roles.contains(&"scheckbuch".into())
});
let logtypes = LogType::all(db).await;
let distances = Distance::all(db).await;
let on_water = Logbook::on_water(db).await;
let mut context = Context::new();
if let Some(msg) = flash {
context.insert("flash", &msg.into_inner());
}
context.insert("planned_trips", &Trip::get_for_today(db).await);
context.insert("boats", &boats);
context.insert(
"reservations",
&BoatReservation::all_future_with_groups(db).await,
);
context.insert("coxes", &coxes);
context.insert("users", &users);
context.insert("logtypes", &logtypes);
context.insert("on_water", &on_water);
context.insert("distances", &distances);
context.insert("show_kiosk_header", &true);
Template::render("kiosk", context.into_json())
}
async fn create_logbook(
db: &SqlitePool,
data: Form<LogToAdd>,
@@ -394,27 +355,12 @@ async fn update(
);
};
match logbook.update(db, data.clone(), &user.user).await {
Ok(()) => {
Log::create(
db,
format!(
"User {} updated log entry={:?} to {:?}",
&user.name, logbook, data
),
)
.await;
logbook.update(db, data.clone(), &user).await;
Flash::success(
Redirect::to("/log/show"),
"Logbucheintrag erfolgreich bearbeitet".to_string(),
)
}
Err(LogbookAdminUpdateError::NotAllowed) => Flash::error(
Redirect::to("/log/show"),
"Du hast keine Erlaubnis, diesen Logbucheintrag zu bearbeiten!".to_string(),
),
}
Flash::success(
Redirect::to("/log/show"),
"Logbucheintrag erfolgreich bearbeitet".to_string(),
)
}
async fn home_logbook(
@@ -583,11 +529,11 @@ async fn delete_kiosk(
pub fn routes() -> Vec<Route> {
routes![
index,
index_loggedin,
index_kiosk,
create,
create_kiosk,
home,
kiosk,
home_kiosk,
new_kiosk,
show,
@@ -606,7 +552,7 @@ mod test {
use sqlx::SqlitePool;
use crate::model::logbook::Logbook;
use crate::tera::{User, log::Boat};
use crate::tera::{log::Boat, User};
use crate::testdb;
#[sqlx::test]

View File

@@ -2,7 +2,7 @@ use std::{fs::OpenOptions, io::Write};
use chrono::{Datelike, Local};
use rocket::{
Build, Data, FromForm, Request, Rocket, State, catch, catchers,
catch, catchers,
fairing::{AdHoc, Fairing, Info, Kind},
form::Form,
fs::FileServer,
@@ -13,6 +13,7 @@ use rocket::{
response::{Flash, Redirect},
routes,
time::{Duration, OffsetDateTime},
Build, Data, FromForm, Request, Rocket, State,
};
use rocket_dyn_templates::Template;
use serde::Deserialize;
@@ -20,7 +21,6 @@ use sqlx::SqlitePool;
use tera::Context;
use crate::{
SCHECKBUCH,
model::{
logbook::Logbook,
notification::Notification,
@@ -28,6 +28,7 @@ use crate::{
role::Role,
user::{User, UserWithDetails},
},
SCHECKBUCH,
};
pub(crate) mod admin;
@@ -330,13 +331,11 @@ mod test {
assert_eq!(response.status(), Status::Ok);
assert!(
response
.into_string()
.await
.unwrap()
.contains("Ruderassistent")
);
assert!(response
.into_string()
.await
.unwrap()
.contains("Ruderassistent"));
}
#[sqlx::test]

View File

@@ -4,6 +4,11 @@
<div class="max-w-screen-lg w-full">
<h1 class="h1">Users</h1>
{% if allowed_to_edit %}
{% if is_admin %}
<div class="mt-5 flex gap-3">
<a href="/admin/user/merge" class="btn btn-dark">Benutzer zusammenführen</a>
</div>
{% endif %}
<details class="mt-5 bg-gray-200 dark:bg-primary-600 p-3 rounded-md">
<summary class="px-3 cursor-pointer text-md font-bold text-primary-950 dark:text-white">
Neue Person hinzufügen
@@ -163,6 +168,14 @@
<a href="?sort=name"
class="block px-4 py-2 hover:bg-gray-100 hover:text-secondary-950">Name Z-A</a>
</li>
<li>
<a href="?sort=member_since_date&asc"
class="block px-4 py-2 hover:bg-gray-100 hover:text-secondary-950">Mitglied seit (älteste)</a>
</li>
<li>
<a href="?sort=member_since_date"
class="block px-4 py-2 hover:bg-gray-100 hover:text-secondary-950">Mitglied seit (neueste)</a>
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,141 @@
{% import "includes/macros" as macros %}
{% extends "base" %}
{% block content %}
<div class="max-w-screen-xl w-full">
<div class="mb-5 lg:mb-0">
<a href="/admin/user" class="link link-primary link-no-underline">&larr; Userverwaltung</a>
</div>
<h1 class="h1">Benutzer zusammenführen</h1>
<p class="text-gray-600 dark:text-gray-300 mb-6">
Wähle zwei Benutzer aus: Der erste (Quelle) wird gelöscht und alle Daten werden zum zweiten (Ziel) übertragen.
</p>
<div class="grid lg:grid-cols-2 gap-6 mb-6">
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md shadow p-4">
<h2 class="text-lg font-bold mb-3 text-red-600 dark:text-red-400">Quelle (wird gelöscht)</h2>
<form method="get" id="source-form">
{% if selected_target %}
<input type="hidden" name="target" value="{{ selected_target }}" />
{% endif %}
<select name="source" class="input rounded-md w-full" onchange="this.form.submit()">
<option value="">-- Benutzer auswählen --</option>
{% for user in users %}
<option value="{{ user.id }}" {% if selected_source == user.id %}selected{% endif %}>
{{ user.name }}{% if user.deleted %} [gelöscht]{% endif %} ({{ user.total_km }} km)
</option>
{% endfor %}
</select>
</form>
</div>
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md shadow p-4">
<h2 class="text-lg font-bold mb-3 text-green-600 dark:text-green-400">Ziel (bleibt erhalten)</h2>
<form method="get" id="target-form">
{% if selected_source %}
<input type="hidden" name="source" value="{{ selected_source }}" />
{% endif %}
<select name="target" class="input rounded-md w-full" onchange="this.form.submit()">
<option value="">-- Benutzer auswählen --</option>
{% for user in users %}
<option value="{{ user.id }}" {% if selected_target == user.id %}selected{% endif %}>
{{ user.name }}{% if user.deleted %} [gelöscht]{% endif %} ({{ user.total_km }} km)
</option>
{% endfor %}
</select>
</form>
</div>
</div>
{% if preview %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md shadow p-6 mb-6">
<h2 class="text-lg font-bold mb-4">Vorschau der Änderungen</h2>
<div class="grid sm:grid-cols-3 gap-6 mb-6">
<div class="border border-red-300 dark:border-red-700 rounded-md p-4 bg-red-50 dark:bg-red-900/20">
<h3 class="font-semibold text-red-700 dark:text-red-400 mb-2">
{{ source_user.name }}
<span class="text-sm font-normal block">(wird gelöscht)</span>
</h3>
<ul class="text-sm space-y-1">
<li><strong>{{ preview.source_total_km }}</strong> km</li>
<li><strong>{{ preview.source_trip_count }}</strong> Ausfahrten</li>
</ul>
</div>
<div class="flex items-center justify-center text-4xl text-gray-400">
&rarr;
</div>
<div class="border border-green-300 dark:border-green-700 rounded-md p-4 bg-green-50 dark:bg-green-900/20">
<h3 class="font-semibold text-green-700 dark:text-green-400 mb-2">
{{ target_user.name }}
<span class="text-sm font-normal block">(bleibt)</span>
</h3>
<ul class="text-sm space-y-1">
<li><strong>{{ preview.target_total_km }}</strong> km</li>
<li><strong>{{ preview.target_trip_count }}</strong> Ausfahrten</li>
</ul>
</div>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md p-4 mb-4">
<h3 class="font-semibold mb-2">Nach Zusammenführung:</h3>
<p class="text-lg">
<strong>{{ target_user.name }}</strong> wird haben:
<strong>{{ preview.source_total_km + preview.target_total_km }}</strong> km,
<strong>{{ preview.source_trip_count + preview.target_trip_count - preview.rower_conflicts }}</strong> Ausfahrten
</p>
</div>
{% set total_to_transfer = preview.rower_entries_to_transfer + preview.role_entries_to_transfer + preview.user_trip_entries_to_transfer + preview.logbook_shipmaster_entries + preview.logbook_steering_entries %}
{% if total_to_transfer > 0 %}
<div class="mb-4">
<h3 class="font-semibold mb-2">Daten die übertragen werden:</h3>
<ul class="text-sm list-disc ml-6 space-y-1">
{% if preview.rower_entries_to_transfer > 0 %}
<li>{{ preview.rower_entries_to_transfer }} Ausfahrten</li>
{% endif %}
{% if preview.role_entries_to_transfer > 0 %}
<li>{{ preview.role_entries_to_transfer }} Rollen</li>
{% endif %}
{% if preview.logbook_shipmaster_entries > 0 %}
<li>{{ preview.logbook_shipmaster_entries }} Logbuch-Einträge (als Schiffsführer)</li>
{% endif %}
{% if preview.logbook_steering_entries > 0 %}
<li>{{ preview.logbook_steering_entries }} Logbuch-Einträge (als Steuerperson)</li>
{% endif %}
</ul>
</div>
{% endif %}
{% set total_conflicts = preview.rower_conflicts + preview.role_conflicts + preview.user_trip_conflicts %}
{% if total_conflicts > 0 %}
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-700 rounded-md p-3 mb-4">
<p class="text-yellow-800 dark:text-yellow-300 font-semibold">
{{ total_conflicts }} doppelte Einträge werden entfernt
</p>
<ul class="text-sm text-yellow-700 dark:text-yellow-400 list-disc ml-6 mt-1">
{% if preview.rower_conflicts > 0 %}
<li>{{ preview.rower_conflicts }} Ausfahrten (beide waren im selben Boot)</li>
{% endif %}
{% if preview.role_conflicts > 0 %}
<li>{{ preview.role_conflicts }} Rollen (beide haben dieselbe Rolle)</li>
{% endif %}
</ul>
</div>
{% endif %}
<form action="/admin/user/merge" method="post" class="flex gap-4">
<input type="hidden" name="source_id" value="{{ source_user.id }}" />
<input type="hidden" name="target_id" value="{{ target_user.id }}" />
<a href="/admin/user/merge" class="btn btn-secondary flex-1 text-center">Abbrechen</a>
<button type="submit"
class="btn btn-alert flex-1"
onclick="return confirm('Bist du sicher? {{ source_user.name }} wird unwiderruflich gelöscht und alle Daten zu {{ target_user.name }} übertragen!')">
Zusammenführen
</button>
</form>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -53,6 +53,21 @@
{% include "includes/footer" %}
{% endif %}
{% include "dynamics/sidebar" %}
{% if loggedin_user and loggedin_user.action_notification %}
<dialog id="action-notification-modal" class="max-w-screen-sm dark:bg-primary-600 dark:text-white rounded-md">
<div class="p-4">
<small class="text-gray-600 dark:text-gray-100">
<strong>{{ loggedin_user.action_notification.category }}</strong>
</small>
<div class="my-4">{{ loggedin_user.action_notification.message }}</div>
<a href="/notification/{{ loggedin_user.action_notification.id }}/read" class="btn btn-dark w-full mt-3">
&#10003;
<span class="sr-only">Notification gelesen</span>
</a>
</div>
</dialog>
<script>document.getElementById('action-notification-modal').showModal();</script>
{% endif %}
<script src="/public/main.js"></script>
</body>
</html>

View File

@@ -7,12 +7,12 @@
{% set place = boathouse[aisle_name][side_name].boats %}
{% if place[level] %}
{{ place[level].boat.name }}
{% if "admin" in loggedin_user.roles %}
{% if allowed_to_edit %}
<a class="btn btn-primary absolute end-0"
href="/board/boathouse/{{ place[level].boathouse_id }}/delete">X</a>
{% endif %}
{% elif boats | length > 0 %}
{% if "admin" in loggedin_user.roles %}
{% if allowed_to_edit %}
<details>
<summary>Kein Boot</summary>
<form action="/board/boathouse" method="post" class="grid gap-3">

View File

@@ -15,10 +15,7 @@
class="link-primary">Überblick der Challenges</a>
</li>
<li class="py-1">
Eintragung ist jederzeit möglich, alle Daten die bis Sonntag 23:59 hier hochgeladen wurden, werden gesammelt an die Ister Ergo Challenge geschickt
<li class="py-1">
Montag &rarr; gemeinsames Training; bitte um <a href="/planned" class="link-primary">Anmeldung</a>, damit jeder einen Ergo hat
</li>
Eintragung ist jederzeit möglich, wenn du sie auch an die offizielle Liste schicken willst, kannst du das <a href="https://data.ergochallenge.at/" target="_blank" style="text-decoration: underline">hier</a> machen
<li class="py-1">
<a href="https://data.ergochallenge.at"
target="_blank"
@@ -194,7 +191,7 @@
</div>
</details>
</div>
{% if "admin" in loggedin_user.roles %}
{% if "admin" in loggedin_user.roles or "ergo-admin" in loggedin_user.roles %}
<div class="bg-white dark:bg-primary-900 text-black dark:text-white rounded-md block shadow grid gap-3">
<h2 class="h2">Update</h2>
<details class="p-2">
@@ -233,6 +230,14 @@
</ol>
</div>
</details>
<div class="mt-3 text-right">
<a href="/ergo/reset"
class="w-28 btn btn-alert"
onclick="return confirm('Willst du wirklich alle Ergo-Eingaben löschen?');">
{% include "includes/delete-icon" %}
Einträge löschen
</a>
</div>
</div>
</div>
{% endif %}

View File

@@ -78,11 +78,12 @@
var queryParams = new URLSearchParams(window.location.search);
return queryParams.get('year');
}
function populateYears() {
var select = document.getElementById('yearSelect');
var currentYear = new Date().getFullYear();
var selectedYear = getYearFromURL() || currentYear;
for (var year = 1977; year <= currentYear; year++) {
var option = document.createElement('option');
option.value = option.textContent = year;
@@ -91,13 +92,21 @@
}
select.appendChild(option);
}
var gesamtOption = document.createElement('option');
gesamtOption.value = 0;
gesamtOption.textContent = 'GESAMT';
if (selectedYear == 0) {
gesamtOption.selected = true;
}
select.appendChild(gesamtOption);
}
function changeYear() {
var selectedYear = document.getElementById('yearSelect').value;
window.location.href = '?year=' + selectedYear;
}
populateYears();
</script>
{% endblock content %}