Compare commits

...

117 Commits

Author SHA1 Message Date
gitea-actions
fcaa3730ce Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 23m17s
CI/CD Pipeline / deploy (push) Has been skipped
2026-03-13 02:04:03 +00:00
fbb55dd829 Merge pull request 'Update Cargo dependencies' (#131) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 8m29s
CI/CD Pipeline / deploy (push) Failing after 41m24s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m45s
Reviewed-on: #131
2026-03-10 16:46:12 +01:00
gitea-actions
908853be9b Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 24m49s
CI/CD Pipeline / deploy (push) Has been skipped
2026-03-06 02:03:25 +00:00
490df19e96 Merge pull request 'Update Cargo dependencies' (#130) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 14m55s
CI/CD Pipeline / deploy (push) Failing after 35m35s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m24s
Reviewed-on: #130
2026-03-04 20:13:00 +01:00
gitea-actions
6d50752fa1 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 30m42s
CI/CD Pipeline / deploy (push) Has been skipped
2026-02-27 02:04:15 +00:00
6cd2b5f10f Merge pull request 'Update Cargo dependencies' (#129) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 17m7s
CI/CD Pipeline / deploy (push) Failing after 1h5m5s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m32s
Reviewed-on: #129
2026-02-25 08:01:44 +01:00
gitea-actions
e8ef82763f Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 33m42s
CI/CD Pipeline / deploy (push) Has been skipped
2026-02-20 02:14:35 +00:00
9d917663a3 Merge pull request 'Update Cargo dependencies' (#128) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 13m28s
CI/CD Pipeline / deploy (push) Failing after 44m0s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m18s
Reviewed-on: #128
2026-02-17 14:55:21 +01:00
gitea-actions
b894394049 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 24m9s
CI/CD Pipeline / deploy (push) Has been skipped
2026-02-13 02:06:34 +00:00
66ce59fa24 Merge pull request 'Update Cargo dependencies' (#127) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 19m31s
CI/CD Pipeline / deploy (push) Failing after 49m41s
Update Cargo Dependencies / update-dependencies (push) Failing after 1m14s
Reviewed-on: #127
2026-01-12 12:01:13 +01:00
gitea-actions
c8801d35e3 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 25m24s
CI/CD Pipeline / deploy (push) Has been skipped
2026-01-09 02:07:21 +00:00
82e7d56da4 Merge pull request 'Update Cargo dependencies' (#126) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 13m30s
CI/CD Pipeline / deploy (push) Failing after 35m33s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m14s
Reviewed-on: #126
2026-01-04 08:40:06 +01:00
gitea-actions
bc61631b6b Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 27m21s
CI/CD Pipeline / deploy (push) Has been skipped
2026-01-02 02:06:27 +00:00
fc437d5f8b Merge pull request 'Update Cargo dependencies' (#125) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 12m15s
CI/CD Pipeline / deploy (push) Failing after 35m51s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m9s
Reviewed-on: #125
2025-12-26 08:06:36 +01:00
gitea-actions
290663476b Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 21m18s
CI/CD Pipeline / deploy (push) Has been skipped
2025-12-26 02:04:35 +00:00
f475fcba9e Merge pull request 'Update Cargo dependencies' (#124) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 7m21s
CI/CD Pipeline / deploy (push) Failing after 43m6s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m11s
Reviewed-on: #124
2025-12-19 07:34:30 +01:00
gitea-actions
899751b6a2 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 37m54s
CI/CD Pipeline / deploy (push) Has been skipped
2025-12-19 02:05:07 +00:00
3f7891c170 Merge pull request 'Update Cargo dependencies' (#123) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 30m25s
CI/CD Pipeline / deploy (push) Failing after 47m32s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m19s
Reviewed-on: #123
2025-12-12 07:11:22 +01:00
gitea-actions
1d4f42d4fb Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 19m6s
CI/CD Pipeline / deploy (push) Has been skipped
2025-12-12 02:05:57 +00:00
d8472d9d09 Merge pull request 'Update Cargo dependencies' (#122) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 11m59s
CI/CD Pipeline / deploy (push) Failing after 31m34s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m24s
Reviewed-on: #122
2025-12-06 08:32:19 +01:00
gitea-actions
aa0c40ce48 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 23m10s
CI/CD Pipeline / deploy (push) Has been skipped
2025-12-05 02:05:41 +00:00
de0f816978 Merge pull request 'Update Cargo dependencies' (#121) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 7m45s
CI/CD Pipeline / deploy (push) Failing after 32m39s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m6s
Reviewed-on: #121
2025-11-28 12:05:50 +01:00
gitea-actions
07cb8325a7 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 31m18s
CI/CD Pipeline / deploy (push) Has been skipped
2025-11-28 02:07:06 +00:00
0dc8be0a8f Merge pull request 'Update Cargo dependencies' (#120) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 11m3s
CI/CD Pipeline / deploy (push) Failing after 35m3s
Update Cargo Dependencies / update-dependencies (push) Failing after 54s
Reviewed-on: #120
2025-11-14 07:04:33 +01:00
gitea-actions
03e558d2ba Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 25m28s
CI/CD Pipeline / deploy (push) Has been skipped
2025-11-14 02:04:18 +00:00
86c8272f1f Merge pull request 'Update Cargo dependencies' (#119) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 9m49s
CI/CD Pipeline / deploy (push) Failing after 58m16s
Update Cargo Dependencies / update-dependencies (push) Successful in 56s
Reviewed-on: #119
2025-11-07 08:33:07 +01:00
gitea-actions
b37724d3bc Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 42m33s
CI/CD Pipeline / deploy (push) Has been skipped
2025-11-07 02:02:32 +00:00
7fbb95699b Merge pull request 'Update Cargo dependencies' (#118) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 22m37s
CI/CD Pipeline / deploy (push) Failing after 42m32s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m24s
Reviewed-on: #118
2025-10-31 09:01:23 +01:00
gitea-actions
0756f5900c Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 47m53s
CI/CD Pipeline / deploy (push) Has been skipped
2025-10-31 02:02:01 +00:00
74ff8ea996 Merge pull request 'Update Cargo dependencies' (#117) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 18m40s
CI/CD Pipeline / deploy (push) Failing after 35m57s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m8s
Reviewed-on: #117
2025-10-27 14:57:00 +01:00
gitea-actions
0f89f0d3f5 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 36m47s
CI/CD Pipeline / deploy (push) Has been skipped
2025-10-24 02:01:34 +00:00
369a099a8d Merge pull request 'Update Cargo dependencies' (#116) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 17m26s
CI/CD Pipeline / deploy (push) Failing after 37m19s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m18s
Reviewed-on: #116
2025-10-17 07:57:25 +02:00
gitea-actions
3f76f8d823 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 31m6s
CI/CD Pipeline / deploy (push) Has been skipped
2025-10-17 02:01:42 +00:00
5581f2245a Merge pull request 'Update Cargo dependencies' (#115) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 17m31s
CI/CD Pipeline / deploy (push) Failing after 41m7s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m19s
Reviewed-on: #115
2025-10-10 22:51:52 +02:00
gitea-actions
6b2f8fc005 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 28m27s
CI/CD Pipeline / deploy (push) Has been skipped
2025-10-10 02:03:16 +00:00
e572f557f8 Merge pull request 'Update Cargo dependencies' (#114) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 16m27s
CI/CD Pipeline / deploy (push) Failing after 47m53s
Update Cargo Dependencies / update-dependencies (push) Successful in 59s
Reviewed-on: #114
2025-10-03 22:47:47 +02:00
gitea-actions
0238815003 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 42m58s
CI/CD Pipeline / deploy (push) Has been skipped
2025-10-03 02:07:34 +00:00
44a71ab79f Merge pull request 'Update Cargo dependencies' (#113) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 10m1s
CI/CD Pipeline / deploy (push) Failing after 33m55s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m14s
Reviewed-on: #113
2025-09-26 08:29:45 +02:00
gitea-actions
22b8dcb6b8 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 26m33s
CI/CD Pipeline / deploy (push) Has been skipped
2025-09-26 02:03:56 +00:00
527c82ea12 Merge pull request 'Update Cargo dependencies' (#112) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 29m32s
CI/CD Pipeline / deploy (push) Failing after 37m43s
Update Cargo Dependencies / update-dependencies (push) Successful in 56s
Reviewed-on: #112
2025-09-24 10:23:33 +02:00
gitea-actions
04ad2bac85 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 41m5s
CI/CD Pipeline / deploy (push) Has been skipped
2025-09-19 02:04:43 +00:00
2658b8a21c Merge pull request 'Update Cargo dependencies' (#111) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 22m29s
CI/CD Pipeline / deploy (push) Failing after 43m51s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m12s
Reviewed-on: #111
2025-09-15 07:04:51 +02:00
gitea-actions
eae69e1d0b Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 40m14s
CI/CD Pipeline / deploy (push) Has been skipped
2025-09-12 02:01:28 +00:00
1128e2fd29 Merge pull request 'Update Cargo dependencies' (#110) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 21m57s
CI/CD Pipeline / deploy (push) Failing after 37m2s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m11s
Reviewed-on: #110
2025-09-08 10:33:18 +02:00
gitea-actions
bab0a13cf0 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 30m31s
CI/CD Pipeline / deploy (push) Has been skipped
2025-09-05 02:03:30 +00:00
275a0e407b Merge pull request 'Update Cargo dependencies' (#109) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 18m6s
CI/CD Pipeline / deploy (push) Failing after 47m37s
Update Cargo Dependencies / update-dependencies (push) Failing after 1m3s
Reviewed-on: #109
2025-08-26 12:59:02 +02:00
gitea-actions
105060a0f1 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 27m23s
CI/CD Pipeline / deploy (push) Has been skipped
2025-08-22 02:03:33 +00:00
b0b53a9c69 Merge pull request 'Update Cargo dependencies' (#108) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 12m21s
CI/CD Pipeline / deploy (push) Failing after 33m56s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m11s
Reviewed-on: #108
2025-08-19 14:13:19 +02:00
gitea-actions
94df20c26f Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 28m1s
CI/CD Pipeline / deploy (push) Has been skipped
2025-08-15 02:03:39 +00:00
fb81866033 Merge pull request 'Update Cargo dependencies' (#107) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 24m54s
CI/CD Pipeline / deploy (push) Failing after 32m45s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m12s
Reviewed-on: #107
2025-08-14 17:22:40 +02:00
gitea-actions
745cb37a53 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 27m48s
CI/CD Pipeline / deploy (push) Has been skipped
2025-08-08 02:03:58 +00:00
aa458f53df Merge pull request 'Update Cargo dependencies' (#106) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 16m0s
CI/CD Pipeline / deploy (push) Failing after 43m45s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m17s
Reviewed-on: #106
2025-08-01 18:27:04 +02:00
gitea-actions
3e976d161b Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 22m59s
CI/CD Pipeline / deploy (push) Has been skipped
2025-08-01 02:11:28 +00:00
c7dd97ef82 Merge pull request 'Update Cargo dependencies' (#105) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 14m18s
CI/CD Pipeline / deploy (push) Failing after 36m56s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m23s
Reviewed-on: #105
2025-07-25 09:40:23 +02:00
gitea-actions
842871c423 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 22m1s
CI/CD Pipeline / deploy (push) Has been skipped
2025-07-25 02:04:40 +00:00
e6172ec163 Merge pull request 'Update Cargo dependencies' (#104) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 15m11s
CI/CD Pipeline / deploy (push) Failing after 46m25s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m1s
Reviewed-on: #104
2025-07-18 21:54:14 +02:00
gitea-actions
12f123c7df Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 13m56s
CI/CD Pipeline / deploy (push) Has been skipped
2025-07-18 02:05:18 +00:00
bcf9baa1f0 Merge pull request 'Update Cargo dependencies' (#103) from update-cargo-dependencies into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 16m25s
CI/CD Pipeline / deploy (push) Successful in 36m59s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m18s
Reviewed-on: #103
2025-07-11 08:25:51 +02:00
gitea-actions
71bb35d47e Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 26m50s
CI/CD Pipeline / deploy (push) Has been skipped
2025-07-11 02:02:00 +00:00
6ced525310 Merge pull request 'Update Cargo dependencies' (#102) from update-cargo-dependencies into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 10m23s
CI/CD Pipeline / deploy (push) Successful in 21m48s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m20s
Reviewed-on: #102
2025-07-04 09:25:35 +02:00
gitea-actions
1cfee40b01 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 29m42s
CI/CD Pipeline / deploy (push) Has been skipped
2025-07-04 02:04:41 +00:00
ec105d1f4a Merge pull request 'Update Cargo dependencies' (#101) from update-cargo-dependencies into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 29m53s
CI/CD Pipeline / deploy (push) Successful in 43m34s
Update Cargo Dependencies / update-dependencies (push) Successful in 1m4s
Reviewed-on: #101
2025-06-27 10:54:44 +02:00
gitea-actions
721435dacd Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 23m47s
CI/CD Pipeline / deploy (push) Has been skipped
2025-06-27 02:04:48 +00:00
9411e366bb Merge pull request 'Update Cargo dependencies' (#100) from update-cargo-dependencies into main
Some checks failed
CI/CD Pipeline / test (push) Successful in 13m39s
CI/CD Pipeline / deploy (push) Successful in 31m49s
Update Cargo Dependencies / update-dependencies (push) Failing after 1m0s
Reviewed-on: #100
2025-06-13 10:59:47 +02:00
gitea-actions
5b8278c8d8 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 24m27s
CI/CD Pipeline / deploy (push) Has been skipped
2025-06-13 02:52:00 +00:00
e1069a2160 Merge pull request 'Update Cargo dependencies' (#99) from update-cargo-dependencies into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 14m7s
CI/CD Pipeline / deploy (push) Successful in 19m0s
Update Cargo Dependencies / update-dependencies (push) Successful in 50s
Reviewed-on: #99
2025-06-06 11:26:20 +02:00
gitea-actions
49419c027a Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 21m4s
CI/CD Pipeline / deploy (push) Has been skipped
2025-06-06 02:55:41 +00:00
73ecfc3494 Merge pull request 'Update Cargo dependencies' (#98) from update-cargo-dependencies into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 11m4s
CI/CD Pipeline / deploy (push) Successful in 29m32s
Update Cargo Dependencies / update-dependencies (push) Successful in 54s
Reviewed-on: #98
2025-05-30 11:40:49 +02:00
gitea-actions
383bd41762 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 20m36s
CI/CD Pipeline / deploy (push) Has been skipped
2025-05-30 02:55:37 +00:00
1ede24fd32 Merge pull request 'Update Cargo dependencies' (#97) from update-cargo-dependencies into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 7m38s
CI/CD Pipeline / deploy (push) Successful in 34m26s
Update Cargo Dependencies / update-dependencies (push) Successful in 52s
Reviewed-on: #97
2025-05-23 09:52:11 +02:00
gitea-actions
59ff71dedc Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 24m5s
CI/CD Pipeline / deploy (push) Has been skipped
2025-05-23 02:02:34 +00:00
c0e0fedc52 dont crash if changing admin name to existing name
All checks were successful
CI/CD Pipeline / test (push) Successful in 10m56s
CI/CD Pipeline / deploy (push) Successful in 6m32s
Update Cargo Dependencies / update-dependencies (push) Successful in 53s
2025-05-18 19:41:23 +02:00
03c34c5c66 fix string
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-18 19:37:23 +02:00
3abb449f22 show station available info, even if no lat/lng is set 2025-05-18 19:37:15 +02:00
3a99946787 lint
All checks were successful
CI/CD Pipeline / test (push) Successful in 9m48s
CI/CD Pipeline / deploy (push) Successful in 6m13s
2025-05-17 21:06:31 +02:00
Marie Birner
7ebff2628e [BUGFIX] minor frontend issues
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-17 20:56:18 +02:00
8d763d92ab Merge branch 'main' of ssh://git.hofer.link:2222/star/star
All checks were successful
CI/CD Pipeline / test (push) Successful in 9m50s
CI/CD Pipeline / deploy (push) Successful in 5m54s
2025-05-17 18:42:16 +02:00
0ab98d4ed9 adapt to mac 2025-05-17 18:41:57 +02:00
50cc71250a Merge pull request 'Update Cargo dependencies' (#87) from update-cargo-dependencies into main
All checks were successful
CI/CD Pipeline / test (push) Successful in 11m28s
CI/CD Pipeline / deploy (push) Successful in 24m38s
Reviewed-on: #87
2025-05-16 08:52:50 +02:00
gitea-actions
97617dabd6 Update Cargo dependencies
All checks were successful
CI/CD Pipeline / test (push) Successful in 29m50s
CI/CD Pipeline / deploy (push) Has been skipped
2025-05-16 02:01:25 +00:00
bdfa2cc4dc consistent use of star
All checks were successful
CI/CD Pipeline / test (push) Successful in 10m46s
CI/CD Pipeline / deploy (push) Successful in 35m18s
2025-05-15 23:02:43 +02:00
c1ad6c443d add contributiojn section
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-15 23:01:59 +02:00
da309e65ad add readme + watch script
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-15 22:57:29 +02:00
ff02048326 clippy
All checks were successful
CI/CD Pipeline / test (push) Successful in 11m0s
CI/CD Pipeline / deploy (push) Successful in 5m55s
2025-05-15 20:53:40 +02:00
0e6eedcb21 highscore table: fix first row+column + make striped + allow full screen; Fixes #85; Fixes #66
Some checks failed
CI/CD Pipeline / deploy (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-05-15 20:52:16 +02:00
0b13c34369 remove useless model package
All checks were successful
CI/CD Pipeline / test (push) Successful in 10m37s
CI/CD Pipeline / deploy (push) Successful in 5m36s
2025-05-15 19:48:04 +02:00
97dbd4fcae Merge pull request 'use-sqliteconnection' (#86) from use-sqliteconnection into main
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Reviewed-on: star/stationslauf#86
2025-05-15 19:43:49 +02:00
a46cf6ed97 pedantic clippy
Some checks failed
CI/CD Pipeline / deploy (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-05-15 19:40:52 +02:00
2a0098b0cb pedantic clippy 2025-05-15 19:40:16 +02:00
69aed3be27 cargo clippy
All checks were successful
CI/CD Pipeline / test (push) Successful in 11m30s
CI/CD Pipeline / deploy (push) Has been skipped
2025-05-15 19:27:39 +02:00
79d22a0ad1 use sqlite connection instead of db pool, removes the need for 2 function (pool + tx)
Some checks failed
CI/CD Pipeline / deploy (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-05-15 19:26:05 +02:00
059976cb98 use thiserror
All checks were successful
CI/CD Pipeline / test (push) Successful in 10m18s
CI/CD Pipeline / deploy (push) Successful in 5m6s
2025-05-14 22:49:25 +02:00
97c176dbd5 add tests for crew station size updates
Some checks failed
CI/CD Pipeline / test (push) Successful in 10m23s
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-14 22:36:08 +02:00
f49af30c62 if station gets updated to be crewless -> re-distribute their current starting teams
All checks were successful
CI/CD Pipeline / test (push) Successful in 11m16s
CI/CD Pipeline / deploy (push) Successful in 5m3s
2025-05-14 22:01:31 +02:00
e9168e3440 proper symbols
All checks were successful
CI/CD Pipeline / test (push) Successful in 10m49s
CI/CD Pipeline / deploy (push) Successful in 5m46s
2025-05-14 17:30:42 +02:00
365340f956 proper showing of logo; Fixes #40
All checks were successful
CI/CD Pipeline / test (push) Successful in 29m4s
CI/CD Pipeline / deploy (push) Successful in 37m37s
2025-05-14 15:52:21 +02:00
1ae879e234 display mapping of starting teams+station; Fixes #78
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-14 15:38:07 +02:00
6078161b2c also show final message, when station run has not officialy ended, but station is done
Some checks failed
CI/CD Pipeline / deploy (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-05-14 15:26:55 +02:00
8d023e5dce print final message; Fixes #82
Some checks failed
CI/CD Pipeline / deploy (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-05-14 15:20:54 +02:00
5503be439c add more desc; Fixes #59
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-14 15:17:48 +02:00
bd4ec88ed9 mention why this can't fail
Some checks failed
CI/CD Pipeline / deploy (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-05-14 15:11:57 +02:00
b9eef60872 show which admin user is logged in; Fixes #43
Some checks failed
CI/CD Pipeline / deploy (push) Has been cancelled
CI/CD Pipeline / test (push) Has been cancelled
2025-05-14 15:11:02 +02:00
9cfeaa77cd only show time for lost teams; Fixes #61
All checks were successful
CI/CD Pipeline / test (push) Successful in 22m58s
CI/CD Pipeline / deploy (push) Successful in 35m13s
2025-05-14 09:24:24 +02:00
80e16754a3 add team-is-here button for first teams
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-14 09:21:17 +02:00
e066f3d065 group teams by routes; Fixes #68
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-14 09:07:59 +02:00
41311beb72 Don't show 'Teams bei dir' when there is no one at that station; Fixes #71
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-14 08:53:56 +02:00
7500f818a6 replace progress bar with when station is done; Fixes #72
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-14 08:51:52 +02:00
42a20c1f73 show amount of missing teams at station-progress-bar; Fixes #80
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-14 08:45:34 +02:00
37114e299a add auto-update
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-14 08:32:42 +02:00
51d3b91dbb limit max db size to 40mb; Fixes #76
All checks were successful
CI/CD Pipeline / test (push) Successful in 12m10s
CI/CD Pipeline / deploy (push) Successful in 6m30s
2025-05-12 23:23:18 +02:00
e575051010 one more check
All checks were successful
CI/CD Pipeline / test (push) Successful in 12m32s
CI/CD Pipeline / deploy (push) Successful in 6m12s
2025-05-10 15:34:48 +02:00
c8fde325db add funny text after station is done. Preparation for #70 2025-05-10 14:21:03 +02:00
934d23ab2f mention that you can undo wrong clicks; Fixes #67 2025-05-10 13:46:29 +02:00
f71bcf35c3 minor wording improvement 2025-05-10 13:45:10 +02:00
a73e5259ae proper labels @ buttons
All checks were successful
CI/CD Pipeline / test (push) Successful in 13m47s
CI/CD Pipeline / deploy (push) Successful in 7m11s
2025-05-09 09:31:37 +02:00
07e19c968b fix typo
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
2025-05-09 09:20:20 +02:00
8ba69aa074 update deps
All checks were successful
CI/CD Pipeline / test (push) Successful in 26m35s
CI/CD Pipeline / deploy (push) Successful in 30m36s
2025-05-06 22:50:53 +02:00
29 changed files with 2523 additions and 1441 deletions

View File

@@ -72,4 +72,5 @@ jobs:
ssh $SSH_USER@$SSH_HOST 'mv /home/startest/star-test-updating /home/startest/star-test'
ssh $SSH_USER@$SSH_HOST 'rm /home/startest/db.sqlite'
scp -C db.sqlite $SSH_USER@$SSH_HOST:/home/startest/db.sqlite
ssh $SSH_USER@$SSH_HOST 'sqlite3 /home/startest/db.sqlite "PRAGMA max_page_count = 10000;"'
ssh $SSH_USER@$SSH_HOST 'sudo systemctl start startest'

View File

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

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/target
db.sqlite
.history

1875
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ dotenv = "0.15"
maud = { version = "0.27", features = ["axum"] }
serde = "1.0"
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "macros", "chrono"] }
tokio = { version = "1.44", features = ["macros", "rt-multi-thread"] }
tokio = { version = "1.50", features = ["macros", "rt-multi-thread"] }
tower-sessions = "0.14"
rust-i18n = "3"
thiserror = "2.0"
@@ -29,7 +29,7 @@ zune-inflate = { version = "0.2", default-features = false, features = [
"std",
] }
tar = "0.4"
ureq = "3.0"
ureq = "3.2"
time = "0.3"
typst-kit = { version = "0.13", features = ["embed-fonts", "vendor-openssl"] }
typst-pdf = "0.13"

View File

@@ -1,14 +1,84 @@
# Stationslauf
# STAR (STAtion Run)
![Logo](assets/logo-horizontal.svg)
## Demo-Instance
STAR is a streamlined web application for organizing and managing station-based team events. Perfect for rallies, orienteering events, team-building activities, educational tours, and scavenger hunts.
## Overview
STAR is a single-binary application that helps event organizers manage teams as they progress through a series of stations along predefined routes. The app provides:
- **Real-time tracking** of team movements between stations
- **Rating system** for evaluating team performance
- **Flexible route planning** with multiple possible paths
- **Map integration** for station locations
- **QR code generation** for easy station access
## Key Features
- **Team Management**: Create teams, assign routes, track progress, and monitor ratings
- **Station Control**: Station supervisors can record when teams arrive, start activities, and depart
- **Route Planning**: Define custom routes with specific station sequences
- **Performance Rating**: Score teams at each station and generate overall rankings
- **Admin Dashboard**: Comprehensive overview of event progress and team status
- **Multi-language Support**: Currently in German with i18n infrastructure
## For Station Supervisors
Station supervisors can:
1. Check in teams when they arrive at a station
2. Record when teams begin their activities
3. Mark when teams complete the station and leave
4. Rate team performance and add notes
5. View which teams should be arriving next
## Getting Started
### Demo Instance
- [startest.it-results.at](https://startest.it-results.at)
- DB resets on every commit/deployment
- Default admin: "a"/"123"
- Database resets on every commit/deployment
- Default admin credentials: "a"/"123"
## Localization tests
To test if there are any errors in the localization string, use the [i18n-checker](https://git.hofer.link/philipp/i18n-checker): e.g. `cargo r -r -- --locale-file /home/ph/p/stationslauf/locales/de-AT.yml --rust-src-to-check /home/ph/p/stationslauf/src`
### Development
## Marketing
- single-binary (+ db + .env)
- Teams werden automatisch (start)stationen zugewiesen
- Install ```inotifywatch``` on your system
- Use ```./watch.sh``` for automatic re-compilation upon changes
### Localization Testing
To test for errors in localization strings:
```bash
cargo r -r -- --locale-file /path/to/stationslauf/locales/de-AT.yml --rust-src-to-check /path/to/stationslauf/src
```
## Technical Details
- **Implementation**: Single-binary application (plus database and .env file)
- **Automatic Assignment**: Teams are automatically assigned to start stations to balance workload
- **PDF Generation**: Create printable documents with login QR codes for station supervisors
- **Maps Integration**: Navigation and station location visualization
## Project Structure
- **Teams**: Groups of participants following specific routes
- **Stations**: Checkpoints where teams perform activities and get rated
- **Routes**: Defined paths connecting stations in specific sequences
- **Admins**: Users who can manage the entire system, create entities, and view results
## License
STAR is licensed under the European Union Public License (EUPL) 1.2, a free software license. This license allows you to use, modify, and distribute the software. If you distribute modified versions, you must release your changes under a compatible open-source license. The EUPL 1.2 provides compatibility with several other open-source licenses.
## Hosting options
You're welcome to self-host STAR on your own servers or infrastructure. This gives you complete control over your deployment, data, and customizations. Should you share your modified version, the EUPL 1.2 simply asks that you contribute your improvements back to the open-source community.
Alternatively, if you'd prefer a hassle-free experience without managing servers and updates, I can host the application for you. This option provides you with support and maintenance while letting you focus on organizing your event rather than technical details. You can find more information at https://star.it-results.at
## Contribution
Got ideas to make STAR even better? I'd love to hear from you! Whether it's a small suggestion, a bug report, or a brilliant new feature concept, please drop me a line at philipp@hofer.link.
If you're interested in contributing code, documentation, or other improvements directly, I'm happy to set up a Gitea account here to make collaboration smooth and easy. Just reach out and we can get you started.
Your feedback and contributions help make this project better for everyone—so don't be shy! Let's make STAR the best station-based event management tool together.

View File

@@ -11,6 +11,12 @@
color: #1b5e20;
}
.danger {
background-color: #b71c1c;
color: #ffffff;
border: none;
}
/* no background on leaflet marker */
.leaflet-container [role="button"],
.leaflet-container [type="button"],
@@ -23,6 +29,12 @@
box-shadow: unset;
}
.logo,.logo-inv {
height: 5em;
max-width: 100%;
margin: auto;
}
[data-theme="light"],
:root:not([data-theme="dark"]),
:host(:not([data-theme="dark"])) {
@@ -42,3 +54,59 @@
.logo {display:none};
.logo-inv {display:block};
}
/*STICKY TABLE*/
.sticky-table {
position: relative;
}
.sticky-table thead th {
position: sticky;
top: 0;
z-index: 2;
}
.sticky-table tbody td:first-child,
.sticky-table thead th:first-child {
position: sticky;
left: 0;
background-color: var(--pico-background-color); /* Background color for first column */
z-index: 1;
}
table.sticky-table tbody tr:nth-child(2n+1) td:first-child,
table.sticky-table tbody tr:nth-child(2n+1) th:first-child
{
/* background-color: red; /* Background color for first column */
background-color: var(--pico-background-color);
}
/* For the top-left cell - needs higher z-index */
.sticky-table thead th:first-child {
z-index: 3;
}
.flex-center-between {
display: flex;
align-items: center;
justify-content: space-between;
}
summary.flex-center-between::after {
display: none;
}
.ml-1 {
margin-left: 5px;
}
.mr-1 {
margin-right: 5px;
}
.mb-0 {
margin-bottom: 0;
}

View File

@@ -21,7 +21,6 @@ confirm_end_run: "Willst du den Stationslauf wirklich beenden? Jedes Team wird d
confirm_restart_run: "Willst du den Stationslauf wirklich wieder aufnehmen?"
run_ended: "Stationslauf erfolgreich beendet"
run_restarted: "Stationslauf erfolgreich wieder aufgenommen"
come_home_with_these_groups: "Gruppen mitnehmen"
station_info: "Schön, dass du uns als Stationsbetreuer hilfst."
info_crewless_station: "Wenn das eine unbemannte Station ist, wähle hier 0 Personen aus. Dann werden dieser Station keine Startteams zugeteilt und es wird kein PDF generiert. Ansonsten gib die geplante Anzahl an Stationsbetreuern ein."
info_currently_crewless_station: "Das ist aktuell eine unbemannte Station. Dieser Station werden keine Startteams zugeteilt und es wird kein Stations-PDF erzeugt. Wenn diese Station doch Stationsbetreuer hat, gib hier dessen Anzahl ein."
@@ -29,6 +28,8 @@ info_currently_crewful_station: "Das ist aktuell eine bemannte Station. Dieser S
time: "Uhrzeit"
google_maps_navigation: "Google Maps Navigation..."
highscore: "Highscore"
station_should_take_these_teams_home: "Der Stationslauf ist vorbei, die Stationen sollten diese Teams zurück zum Start mitnehmen:"
station_should_take_these_teams_to_first_station: "Die Stationen sollen diese Teams zur ersten Station mitnehmen:"
#
@@ -53,7 +54,8 @@ button_station_ready: "Sobald du bei deiner Station bist und bereit zu starten b
station_not_yet_ready: "Bin mit der Station doch noch nicht bereit..."
one_team_should_come_to_station: "Insgesamt sollte 1 Team zu deiner Station kommen."
n_teams_should_come_to_station: "Insgesamt sollten %{amount} Teams zu deiner Station kommen."
team_on_the_way_to_your_station: "Team %{team} ist seit %{time} auf dem Weg zu deiner Station."
team_on_the_way_to_your_station: "Das Team %{team} ist seit %{time} auf dem Weg zu deiner Station."
station_done: "Puh. 😮‍💨 Das war vermutlich viel Arbeit, aber jetzt ist es vorbei. ✅🎉"
team_is_here: "Team ist da"
info_single_team_not_yet_rated: "Noch keine Punkte für diese Gruppe vergeben ⤵️"
info_multiple_teams_not_yet_rated: "Noch keine Punkte für diese Gruppen vergeben ⤵️"
@@ -88,7 +90,7 @@ state_rated: "Schon bewertet"
state_rated_icon: "✅"
since_time: "seit %{time}"
left_at: "um %{time} gegangen"
arrived_at_started_at_left_at: "um %{arrived} eingetroffen, um %{active} gestarted und um %{left} gegangen"
arrived_at_started_at_left_at: "um %{arrived} eingetroffen, um %{active} gestartet und um %{left} gegangen"
team_finished: "Team fertig"
team_starting: "Team startet"
notes: "Notizen"
@@ -96,6 +98,7 @@ save_notes: "Notizen speichern"
confirm_station_cancel_team_active: "Bist du sicher, dass das Team %{team} noch nicht bei dir arbeitet? Das Team wird zurück auf die Warte-Position gesetzt"
confirm_station_cancel_team_waiting: "Bist du sicher, dass das Team %{team} noch nicht bei dir ist? Das kann _NICHT_ mehr rückgängig gemacht werden."
confirm_station_cancel_team_finished: "Bist du sicher, dass das Team noch nicht bei dir fertig ist? Das Team wird zurück auf die Arbeits-Position gesetzt."
confirm_station_cancel_team_rated: "Bist du sicher, dass das Team noch nicht bei dir fertig ist? Das Team wird zurück auf die Arbeits-Position gesetzt und die aktuelle Bewertung gelöscht."
#
@@ -110,14 +113,16 @@ confirm_station_cancel_team_finished: "Bist du sicher, dass das Team noch nicht
station: "Station"
stations: "Stationen"
stations_expl_without_first_word: "sind festgelegte Orte mit spezifischen Aufgaben."
station_expl_for_everyone: "<p>In diesem Tool solltest du diese 3 Dinge vermerken:</p> <ol> <li> <b>Ein Team kommt zu deiner Station:</b> Du wählst das entsprechende Team aus und klickst auf <em>Team ist da</em>. Das Team ist nun im Wartemodus (⏳). </li> <li> <b>Das Team beginnt mit der Aufgabe bei deiner Station:</b> Du klickst beim entsprechenden Team auf <em>Team startet</em>. Das Team ist nun im aktiven Modus (🎬). </li> <li> <b>Das Team hat deine Station beendet und ist gegangen:</b> Du klickst beim entsprechenden Team auf <em>Team fertig</em>. Bitte schau, dass du das immer zeitnah erledigst, damit die nächste Station informiert werden kann, dass ein Team auf dem Weg ist. </li> </ol> <p>Zu jedem Zeitpunkt kannst du mit Klick auf ✏️ Notizen zu den Teams machen. In aller Ruhe kannst du unter dem Punkt <em>Zu bewerten</em> die Teams, die schon bei dir waren, bewerten.</p>"
station_notes_expl: "Diese Notizen werden nur hier angezeigt. Du kannst diese verwenden um zB Kontaktmöglichkeiten (Telefonnummer) zu hinterlegen."
station_expl_for_everyone: "<p>In diesem Tool solltest du diese 3 Dinge vermerken:</p> <ol> <li> <b>Ein Team kommt zu deiner Station:</b> Du wählst das entsprechende Team aus und klickst auf <em>Team ist da</em>. Das Team ist nun im Wartemodus (⏳). </li> <li> <b>Das Team beginnt mit der Aufgabe bei deiner Station:</b> Du klickst beim entsprechenden Team auf <em>Team startet</em>. Das Team ist nun im aktiven Modus (🎬). </li> <li> <b>Das Team hat deine Station beendet und ist gegangen:</b> Du klickst beim entsprechenden Team auf <em>Team fertig</em>. Bitte schau, dass du das immer zeitnah erledigst, damit die nächste Station informiert werden kann, dass ein Team auf dem Weg ist. </li> </ol> <p>Zu jedem Zeitpunkt kannst du mit Klick auf ✏️ Notizen zu den Teams machen und das Team wieder entfernen, solltest du dich verklickt haben. In aller Ruhe kannst du unter dem Punkt <em>Zu bewerten</em> die Teams, die schon bei dir waren, bewerten.</p>"
nonexisting_station: "Station mit ID %{id} existiert nicht."
station_url: "Stations-Link"
station_url_info: "Diesen Link nur Betreuern der Station %{station} geben! Mit diesem Link erhält man die Berechtigung, Teams zu bewerten."
login_link: "Login-Link"
station_name_edit: "Routennamen bearbeiten"
station_name_edit: "Stationsnamen bearbeiten"
go_to_stations: "Zu den Stationen"
crewless_station: "Station ohne Stationsbetreuer"
last_station_has_to_be_crewful: "Die letzte Station der Route muss Stationsbetreuer haben, sonst gibt's keine erste Station für die Teams"
amount_crew: "Anzahl Stationsbetreuer"
last_access_crew: "Letzter Zugriff eines Stationsbetreuers"
not_loggedin_yet: "Noch nicht eingeloggt :-("
@@ -144,7 +149,7 @@ station_delete_succ: "Station %{name} erfolgreich gelöscht"
station_delete_err_already_used: "Station %{name} konnte nicht gelöscht werden, da sie bereits verwendet wird (%{err})"
station_has_not_rated_team_yet: "Station hat Team noch nicht bewertet" # should be short -> tooltip
station_move_up: "%{name} nach vor reihen" # should be short -> tooltip
generate_station_pdf: "Stations PDF generieren"
generate_station_pdf: "Stations-Dokument zum Ausdrucken der Login-Links (QR Code)"
station_new_name: "Station %{old} heißt ab sofort %{new}."
station_new_notes: "Notizen für die Station %{station} erfolgreich bearbeitet"
station_new_crew_amount: "Anzahl an Betreuer für die Station %{station} erfolgreich bearbeitet"
@@ -182,6 +187,7 @@ team: "Team"
teams: "Teams"
teams_expl_without_first_word: "sind eine Menge an Personen, die verschiedene <a href='/admin/station'>Stationen</a> ablaufen. Welche Stationen, entscheidet sich je nachdem, welcher <a href='/admin/route'>Route</a> sie zugewiesen sind."
nonexisting_team: "Team mit ID %{id} existiert nicht."
team_notes_expl: "Diese Notizen werden den Stationen angezeigt, die diese Teams am Anfang mitnehmen sollen."
select_team: "Team auswählen"
new_team: "Neues Team"
edit_teamname: "Teamname bearbeiten"
@@ -192,6 +198,9 @@ not_yet_done: "nocht nicht fertig"
team_created: "Team %{team} erstellt"
team_deleted: "Team %{team} gelöscht"
add_new_note: "Neue Notiz hinzufügen"
team_waiting_step_back: "Team doch nicht da"
team_active_step_back: "Team doch nicht gestartet"
team_done_step_back: "Team doch nicht fertig"
team_not_created_duplicate_name: "Team %{team} konnte nicht erstellt werden, da es bereits ein Team mit diesem Namen gibt (%{err})"
team_not_created_no_station_in_route: "Team %{team} konnte nicht erstellt werden, da in der angegebenen Route %{route} keine Station vorkommt und daher die Startstation nicht festgelegt werden kann."
team_not_deleted_already_in_use: "Team %{team} kann nicht gelöscht werden, da es bereits verwendet wird. (%{err})"
@@ -211,7 +220,7 @@ last_contact_team: "Letzter Stationskontakt der Teams"
not_yet_seen: "Noch nicht gesehen"
no_teams: "Es gibt noch keine Teams."
route_needed_before_creating_teams: "Bevor du ein Team erstellen kannst, musst du zumindest eine Route erstellen, die das Team gehen kann."
have_i_lost_groups: "Hab ich eine Gruppe verloren? 😳"
have_i_lost_teams: "Hab ich ein Team verloren? 😳"
confirm_delete_team: "Bist du sicher, dass das Team gelöscht werden soll? Das kann _NICHT_ mehr rückgängig gemacht werden."
@@ -242,6 +251,7 @@ edit_username: "Username bearbeiten"
new_admin_link: "Passwort vergessen: Neuen Loginlink generieren"
confirm_new_admin_link: "Bist du sicher, dass du einen neuen Passwort-Link generieren willst? Mit dem alten Passwort kann man sich dann nicht mehr einloggen."
new_user_name: "Admin %{old} heißt ab sofort %{new}"
user_name_already_exists: "Es gibt bereits einen Admin %{new}."
succ_new_admin_link: "Neuer Loginlink für User %{user} wurde generiert"
new_admin: "Neuer Admin"
confirm_admin_delete: "Bist du sicher, dass der User gelöscht werden soll? Das kann _NICHT_ mehr rückgängig gemacht werden."

View File

@@ -1,18 +1,23 @@
use crate::{auth::Backend, models::rating::Rating, page, suc, AppState, Station};
use crate::{
AppState, PageBuilder, Station,
auth::{AuthSession, Backend},
rating::Rating,
suc,
};
use axum::{
Router,
extract::State,
response::{IntoResponse, Redirect},
routing::get,
Router,
};
use axum_login::login_required;
use maud::{html, Markup};
use maud::{Markup, PreEscaped, html};
use rand::{
distr::{Distribution, Uniform},
rng,
};
use route::Route;
use sqlx::SqlitePool;
use sqlx::{SqliteConnection, SqlitePool};
use std::sync::Arc;
use team::Team;
use tower_sessions::Session;
@@ -31,7 +36,9 @@ fn generate_random_alphanumeric(length: usize) -> String {
}
async fn highscore(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
let routes = Route::all(&db).await;
let db = &mut *db.acquire().await.unwrap();
let routes = Route::all(db).await;
let content = html! {
h1 {
@@ -42,26 +49,25 @@ async fn highscore(State(db): State<Arc<SqlitePool>>, session: Session) -> Marku
details open[idx==0] {
summary { (route.name) }
div class="overflow-auto" {
table {
div class="overflow-auto" style="max-height: 100vh" {
table class="striped sticky-table" {
thead {
tr {
td { (t!("team")) }
@for station in route.stations(&db).await {
td {
th { (t!("team")) }
@for station in route.stations(db).await {
th {
(station)
}
}
td { (t!("total_points")) }
td { (t!("rank")) }
td { (t!("team")) }
th { (t!("total_points")) }
th { (t!("rank")) }
}
}
tbody {
@let mut rank = 0;
@let mut amount_teams_iterated = 0;
@let mut prev_points = i64::MAX;
@for team in route.teams_ordered_by_points(&db).await {
@for team in route.teams_ordered_by_points(db).await {
@let mut total_points = 0;
({ amount_teams_iterated += 1;"" })
tr {
@@ -70,9 +76,9 @@ async fn highscore(State(db): State<Arc<SqlitePool>>, session: Session) -> Marku
(team.name)
}
}
@for station in route.stations(&db).await {
@for station in route.stations(db).await {
td {
@if let Some(rating) = Rating::find_by_team_and_station(&db, &team, &station).await {
@if let Some(rating) = Rating::find_by_team_and_station(db, &team, &station).await {
@if let (Some(notes), Some(points)) = (rating.notes, rating.points) {
({total_points += points;""})
em data-placement="bottom" data-tooltip=(notes) { (points) }
@@ -96,11 +102,6 @@ async fn highscore(State(db): State<Arc<SqlitePool>>, session: Session) -> Marku
(rank)
"."
}
td {
a href=(format!("/admin/team/{}", team.id)) {
(team.name)
}
}
}
}
}
@@ -110,7 +111,10 @@ async fn highscore(State(db): State<Arc<SqlitePool>>, session: Session) -> Marku
}
};
page(content, session, false).await
PageBuilder::new(content, session)
.full_page()
.markup()
.await
}
#[derive(PartialEq)]
@@ -121,7 +125,7 @@ pub enum RunStatus {
}
impl RunStatus {
pub async fn curr(db: &SqlitePool) -> Self {
pub async fn curr(db: &mut SqliteConnection) -> Self {
let stations = Station::all(db).await;
if stations.is_empty() {
return RunStatus::NoStationsYet;
@@ -134,15 +138,26 @@ impl RunStatus {
}
}
async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
let status = RunStatus::curr(&db).await;
async fn index(
State(db): State<Arc<SqlitePool>>,
session: Session,
auth_session: AuthSession,
) -> Markup {
let db = &mut *db.acquire().await.unwrap();
let user = auth_session
.user
.expect("Can only be called by loggedin people");
let status = RunStatus::curr(db).await;
let content = html! {
nav {
ul {
img class="logo" src="/logo-hor.svg" style="max-width: 100%; width: 25em; margin:auto;";
img class="logo-inv" src="/logo-hor-inv.svg" width="max-width: 100%; width: 25em; margin:auto;";
img class="logo" src="/logo-hor.svg";
img class="logo-inv" src="/logo-hor-inv.svg";
}
ul {
"👋" (user.name)""
(PreEscaped("&nbsp;"))
a href="/auth/logout" {
(t!("logout"))
}
@@ -188,24 +203,60 @@ async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
}
},
RunStatus::Active => {
@let stations = Station::all(db).await;
a href="/admin/end-run" onclick=(format!("return confirm('{}');", t!("confirm_end_run"))) {
button style="background-color: red;" {
(t!("end_run"))
}
}
},
RunStatus::HasEnded => {
@let stations = Station::all(&db).await;
a href="/admin/restart-run" onclick=(format!("return confirm('{}');", t!("confirm_restart_run"))) {
button style="background-color: red;" {
(t!("restart_run"))
}
p {
(t!("station_should_take_these_teams_to_first_station"))
}
table {
thead {
tr {
th { (t!("stations")) }
th { (t!("come_home_with_these_groups")) }
th { (t!("teams")) }
}
}
tbody {
@for station in stations {
tr {
td {
(station)
@if let Some(notes) = &station.notes {
article {
(notes)
}
}
}
td {
ol {
@for team in Team::all_with_first_station(db, &station).await {
li { (team) }
}
}
}
}
}
}
}
},
RunStatus::HasEnded => {
@let stations = Station::all(db).await;
a href="/admin/restart-run" onclick=(format!("return confirm('{}');", t!("confirm_restart_run"))) {
button style="background-color: red;" {
(t!("restart_run"))
}
}
p {
(t!("station_should_take_these_teams_home"))
}
table {
thead {
tr {
th { (t!("stations")) }
th { (t!("teams")) }
}
}
tbody {
@@ -214,7 +265,7 @@ async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
td { (station) }
td {
ol {
@for team in Team::all_with_last_station(&db, &station).await {
@for team in Team::all_with_last_station(db, &station).await {
li { (team) }
}
}
@@ -226,16 +277,21 @@ async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
}
}
};
page(content, session, false).await
PageBuilder::new(content, session).markup().await
}
async fn end_run(State(db): State<Arc<SqlitePool>>, session: Session) -> impl IntoResponse {
Team::end_run(&db).await;
let db = &mut *db.acquire().await.unwrap();
Team::end_run(db).await;
suc!(session, t!("run_ended"));
Redirect::to("/admin")
}
async fn restart_run(State(db): State<Arc<SqlitePool>>, session: Session) -> impl IntoResponse {
Team::restart_run(&db).await;
let db = &mut *db.acquire().await.unwrap();
Team::restart_run(db).await;
suc!(session, t!("run_restarted"));
Redirect::to("/admin")
}

View File

@@ -1,44 +1,43 @@
use crate::{
admin::{station::Station, team::Team},
AppState,
admin::{station::Station, team::Team},
};
use axum::Router;
use futures::future::join_all;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Row, SqlitePool};
use sqlx::{FromRow, Row, SqliteConnection};
mod web;
#[derive(FromRow, Debug, Serialize, Deserialize)]
#[derive(FromRow, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub(crate) struct Route {
pub(crate) id: i64,
pub(crate) name: String,
}
impl Route {
pub(crate) async fn all(db: &SqlitePool) -> Vec<Self> {
pub(crate) async fn all(db: &mut SqliteConnection) -> Vec<Self> {
sqlx::query_as::<_, Self>("SELECT id, name FROM route;")
.fetch_all(db)
.await
.unwrap()
}
pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> {
pub async fn find_by_id(db: &mut SqliteConnection, id: i64) -> Option<Self> {
sqlx::query_as!(Self, "SELECT id, name FROM route WHERE id = ?", id)
.fetch_one(db)
.await
.ok()
}
async fn create(db: &SqlitePool, name: &str) -> Result<(), String> {
sqlx::query!("INSERT INTO route(name) VALUES (?)", name)
.execute(db)
pub(crate) async fn create(db: &mut SqliteConnection, name: &str) -> Result<Self, String> {
let route = sqlx::query!("INSERT INTO route(name) VALUES (?) RETURNING id", name)
.fetch_one(&mut *db)
.await
.map_err(|e| e.to_string())?;
Ok(())
Ok(Self::find_by_id(db, route.id).await.expect("just created"))
}
async fn update_name(&self, db: &SqlitePool, name: &str) {
async fn update_name(&self, db: &mut SqliteConnection, name: &str) {
sqlx::query!("UPDATE route SET name = ? WHERE id = ?", name, self.id)
.execute(db)
.await
@@ -47,7 +46,7 @@ impl Route {
pub(crate) async fn add_station(
&self,
db: &SqlitePool,
db: &mut SqliteConnection,
station: &Station,
) -> Result<(), String> {
sqlx::query!(
@@ -70,7 +69,7 @@ impl Route {
Ok(())
}
async fn delete_station(&self, db: &SqlitePool, station: &Station) -> bool {
async fn delete_station(&self, db: &mut SqliteConnection, station: &Station) -> bool {
let result = sqlx::query!(
"DELETE FROM route_station WHERE route_id = ? AND station_id = ?",
self.id,
@@ -83,13 +82,13 @@ impl Route {
result.rows_affected() > 0
}
async fn move_station_higher(&self, db: &SqlitePool, station: &Station) -> bool {
async fn move_station_higher(&self, db: &mut SqliteConnection, station: &Station) -> bool {
let result = sqlx::query!(
"UPDATE route_station SET pos = pos-3 WHERE route_id = ? AND station_id = ?",
self.id,
station.id
)
.execute(db)
.execute(&mut *db)
.await
.unwrap();
@@ -125,7 +124,7 @@ DROP TABLE temp_pos;",
true
}
async fn delete(&self, db: &SqlitePool) -> Result<(), String> {
async fn delete(&self, db: &mut SqliteConnection) -> Result<(), String> {
sqlx::query!("DELETE FROM route WHERE id = ?", self.id)
.execute(db)
.await
@@ -133,7 +132,7 @@ DROP TABLE temp_pos;",
Ok(())
}
pub(crate) async fn stations(&self, db: &SqlitePool) -> Vec<Station> {
pub(crate) async fn stations(&self, db: &mut SqliteConnection) -> Vec<Station> {
// TODO: switch to macro
sqlx::query_as::<_, Station>(
"
@@ -150,7 +149,7 @@ DROP TABLE temp_pos;",
.unwrap()
}
pub(crate) async fn crewless_stations(&self, db: &SqlitePool) -> Vec<Station> {
pub(crate) async fn crewless_stations(&self, db: &mut SqliteConnection) -> Vec<Station> {
self.stations(db)
.await
.into_iter()
@@ -158,7 +157,7 @@ DROP TABLE temp_pos;",
.collect()
}
pub(crate) async fn crewful_stations(&self, db: &SqlitePool) -> Vec<Station> {
pub(crate) async fn crewful_stations(&self, db: &mut SqliteConnection) -> Vec<Station> {
self.stations(db)
.await
.into_iter()
@@ -166,7 +165,7 @@ DROP TABLE temp_pos;",
.collect()
}
async fn stations_not_in_route(&self, db: &SqlitePool) -> Vec<Station> {
async fn stations_not_in_route(&self, db: &mut SqliteConnection) -> Vec<Station> {
// TODO: switch to macro
sqlx::query_as::<_, Station>(
"
@@ -186,15 +185,18 @@ DROP TABLE temp_pos;",
.unwrap()
}
pub(crate) async fn teams(&self, db: &SqlitePool) -> Vec<Team> {
pub(crate) async fn teams(&self, db: &mut SqliteConnection) -> Vec<Team> {
Team::all_with_route(db, self).await
}
pub(crate) async fn teams_ordered_by_points(&self, db: &SqlitePool) -> Vec<Team> {
pub(crate) async fn teams_ordered_by_points(&self, db: &mut SqliteConnection) -> Vec<Team> {
let teams = Team::all_with_route(db, self).await;
// First, collect all the points
let points_futures: Vec<_> = teams.iter().map(|team| team.get_curr_points(db)).collect();
let points = join_all(points_futures).await;
let mut points = Vec::new();
for team in &teams {
points.push(team.get_curr_points(&mut *db).await);
}
// Create pairs of (team, points)
let mut team_with_points: Vec<_> = teams.into_iter().zip(points).collect();
@@ -206,7 +208,10 @@ DROP TABLE temp_pos;",
team_with_points.into_iter().map(|(team, _)| team).collect()
}
pub(crate) async fn get_next_first_station(&self, db: &SqlitePool) -> Option<Station> {
pub(crate) async fn get_next_first_station(
&self,
db: &mut SqliteConnection,
) -> Option<Station> {
let Ok(row) = sqlx::query(&format!(
"
SELECT
@@ -222,7 +227,7 @@ DROP TABLE temp_pos;",
LIMIT 1",
self.id
))
.fetch_one(db)
.fetch_one(&mut *db)
.await
else {
return None; // No station for route exists
@@ -236,7 +241,11 @@ DROP TABLE temp_pos;",
)
}
pub async fn next_station(&self, db: &SqlitePool, target_station: &Station) -> Option<Station> {
pub async fn next_station(
&self,
db: &mut SqliteConnection,
target_station: &Station,
) -> Option<Station> {
let stations = Station::all(db).await;
for station in stations {
if let Some(prev_station) = self.prev_station(db, &station).await {
@@ -248,11 +257,19 @@ DROP TABLE temp_pos;",
None
}
pub async fn prev_station(&self, db: &SqlitePool, station: &Station) -> Option<Station> {
pub async fn prev_station(
&self,
db: &mut SqliteConnection,
station: &Station,
) -> Option<Station> {
if station.crewless() {
return None;
}
if self.stations_not_in_route(db).await.contains(station) {
return None;
}
if self.stations(db).await.len() <= 1 {
return None;
}

View File

@@ -1,19 +1,21 @@
use super::Route;
use crate::{admin::station::Station, er, page, suc, AppState};
use crate::{AppState, PageBuilder, admin::station::Station, er, suc};
use axum::{
Form, Router,
extract::State,
response::{IntoResponse, Redirect},
routing::{get, post},
Form, Router,
};
use maud::{html, Markup, PreEscaped};
use maud::{Markup, PreEscaped, html};
use serde::Deserialize;
use sqlx::SqlitePool;
use std::sync::Arc;
use tower_sessions::Session;
async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
let routes = Route::all(&db).await;
let db = &mut *db.acquire().await.unwrap();
let routes = Route::all(db).await;
let content = html! {
h1 {
@@ -28,7 +30,7 @@ async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
ol {
@for route in &routes{
li {
@if route.stations(&db).await.is_empty() {
@if route.stations(db).await.is_empty() {
em data-tooltip=(t!("route_has_no_station_assigned")) {
"⚠️"
}
@@ -58,7 +60,8 @@ async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
}
}
};
page(content, session, false).await
PageBuilder::new(content, session).markup().await
}
#[derive(Deserialize)]
@@ -71,8 +74,10 @@ async fn create(
session: Session,
Form(form): Form<CreateForm>,
) -> impl IntoResponse {
match Route::create(&db, &form.name).await {
Ok(()) => suc!(session, t!("route_create_succ", name = form.name)),
let db = &mut *db.acquire().await.unwrap();
match Route::create(db, &form.name).await {
Ok(_) => suc!(session, t!("route_create_succ", name = form.name)),
Err(e) => er!(
session,
t!("route_create_err_duplicate_name", name = form.name, err = e)
@@ -87,12 +92,14 @@ async fn delete(
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> impl IntoResponse {
let Some(route) = Route::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(route) = Route::find_by_id(db, id).await else {
er!(session, t!("nonexisting_route", id = id));
return Redirect::to("/admin/route");
};
match route.delete(&db).await {
match route.delete(db).await {
Ok(()) => suc!(session, t!("route_delete_succ", name = route.name)),
Err(e) => er!(
session,
@@ -108,15 +115,17 @@ async fn view(
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Markup, impl IntoResponse> {
let Some(route) = Route::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(route) = Route::find_by_id(db, id).await else {
er!(session, t!("nonexisting_route", id = id));
return Err(Redirect::to("/admin/route"));
};
let cur_stations = route.stations(&db).await;
let stations_not_in_route = route.stations_not_in_route(&db).await;
let cur_stations = route.stations(db).await;
let stations_not_in_route = route.stations_not_in_route(db).await;
let teams = route.teams(&db).await;
let teams = route.teams(db).await;
let content = html! {
h1 {
@@ -207,7 +216,8 @@ async fn view(
}
};
Ok(page(content, session, false).await)
Ok(PageBuilder::new(content, session).markup().await)
}
#[derive(Deserialize)]
@@ -220,12 +230,14 @@ async fn update_name(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateNameForm>,
) -> impl IntoResponse {
let Some(route) = Route::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(route) = Route::find_by_id(db, id).await else {
er!(session, t!("nonexisting_route", id = id));
return Redirect::to("/admin/route");
};
route.update_name(&db, &form.name).await;
route.update_name(db, &form.name).await;
suc!(
session,
@@ -245,16 +257,18 @@ async fn add_station(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<AddStationForm>,
) -> impl IntoResponse {
let Some(route) = Route::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(route) = Route::find_by_id(db, id).await else {
er!(session, t!("nonexisting_route", id = id));
return Redirect::to("/admin/route");
};
let Some(station) = Station::find_by_id(&db, form.station).await else {
let Some(station) = Station::find_by_id(&mut *db, form.station).await else {
er!(session, t!("nonexisting_station", id = form.station));
return Redirect::to(&format!("/admin/route/{id}"));
};
match route.add_station(&db, &station).await {
match route.add_station(db, &station).await {
Ok(()) => suc!(
session,
t!(
@@ -282,16 +296,18 @@ async fn delete_station(
session: Session,
axum::extract::Path((route_id, station_id)): axum::extract::Path<(i64, i64)>,
) -> impl IntoResponse {
let Some(route) = Route::find_by_id(&db, route_id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(route) = Route::find_by_id(db, route_id).await else {
er!(session, t!("nonexisting_route", id = route_id));
return Redirect::to("/admin/route");
};
let Some(station) = Station::find_by_id(&db, station_id).await else {
let Some(station) = Station::find_by_id(&mut *db, station_id).await else {
er!(session, t!("nonexisting_station", id = station_id));
return Redirect::to(&format!("/admin/route/{route_id}"));
};
if route.delete_station(&db, &station).await {
if route.delete_station(db, &station).await {
suc!(
session,
t!(
@@ -319,16 +335,18 @@ async fn move_station_higher(
session: Session,
axum::extract::Path((route_id, station_id)): axum::extract::Path<(i64, i64)>,
) -> impl IntoResponse {
let Some(route) = Route::find_by_id(&db, route_id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(route) = Route::find_by_id(db, route_id).await else {
er!(session, t!("nonexisting_route", id = route_id));
return Redirect::to("/admin/route");
};
let Some(station) = Station::find_by_id(&db, station_id).await else {
let Some(station) = Station::find_by_id(&mut *db, station_id).await else {
er!(session, t!("nonexisting_station", id = station_id));
return Redirect::to(&format!("/admin/route/{route_id}"));
};
if route.move_station_higher(&db, &station).await {
if route.move_station_higher(db, &station).await {
suc!(
session,
t!(

View File

@@ -1,16 +1,16 @@
use super::{generate_random_alphanumeric, team::Team};
use crate::{
admin::route::Route,
models::rating::{Rating, TeamsAtStationLocation},
AppState,
admin::route::Route,
rating::{Rating, TeamsAtStationLocation},
};
use axum::Router;
use chrono::{DateTime, Local, NaiveDateTime, Utc};
use futures::{stream, StreamExt};
use maud::{html, Markup, Render};
use maud::{Markup, Render, html};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
use sqlx::{FromRow, SqliteConnection};
pub(crate) mod model;
pub(crate) mod print;
mod typst;
mod web;
@@ -19,7 +19,7 @@ mod web;
pub(crate) struct Station {
pub(crate) id: i64,
pub(crate) name: String,
notes: Option<String>,
pub(crate) notes: Option<String>,
pub(crate) amount_people: Option<i64>,
last_login: Option<NaiveDateTime>,
pub(crate) pw: String,
@@ -50,7 +50,7 @@ impl Render for Station {
}
impl Station {
pub(crate) async fn all(db: &SqlitePool) -> Vec<Self> {
pub(crate) async fn all(db: &mut SqliteConnection) -> Vec<Self> {
sqlx::query_as::<_, Self>(
"SELECT id, name, notes, amount_people, last_login, ready, pw, lat, lng FROM station;",
)
@@ -59,7 +59,7 @@ impl Station {
.unwrap()
}
pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> {
pub async fn find_by_id(db: &mut SqliteConnection, id: i64) -> Option<Self> {
sqlx::query_as!(
Self,
"SELECT id, name, notes, amount_people, last_login, ready, pw, lat, lng FROM station WHERE id = ?",
@@ -77,13 +77,13 @@ impl Station {
false
}
pub async fn login(db: &SqlitePool, id: i64, code: &str) -> Option<Self> {
pub async fn login(db: &mut SqliteConnection, id: i64, code: &str) -> Option<Self> {
let station = sqlx::query_as!(
Self,
"SELECT id, name, notes, amount_people, last_login, ready, pw, lat, lng FROM station WHERE id = ? AND pw = ?",
id, code
)
.fetch_one(db)
.fetch_one(&mut *db)
.await
.ok()?;
@@ -98,7 +98,7 @@ impl Station {
Some(station)
}
pub async fn switch_ready(&self, db: &SqlitePool) {
pub async fn switch_ready(&self, db: &mut SqliteConnection) {
let new_ready_status = !self.ready;
sqlx::query!(
"UPDATE station SET ready = ? WHERE id = ?",
@@ -110,34 +110,34 @@ impl Station {
.unwrap();
}
pub(crate) async fn create(db: &SqlitePool, name: &str) -> Result<(), String> {
pub(crate) async fn create(db: &mut SqliteConnection, name: &str) -> Result<Self, String> {
let code = generate_random_alphanumeric(8);
let station_id = sqlx::query!(
"INSERT INTO station(name, pw) VALUES (?, ?) RETURNING id",
name,
code
)
.fetch_one(db)
.fetch_one(&mut *db)
.await
.map_err(|e| e.to_string())?;
let station = Station::find_by_id(&mut *db, station_id.id)
.await
.expect("just created");
let mut routes = Route::all(db).await.into_iter();
if let Some(route) = routes.next() {
if routes.next().is_none() {
// Just one route exists -> use it for new station
let station = Station::find_by_id(db, station_id.id)
.await
.expect("just created");
route.add_station(db, &station).await?;
}
}
Ok(())
Ok(station)
}
pub(crate) async fn new_team_waiting(
&self,
db: &SqlitePool,
db: &mut SqliteConnection,
team: &Team,
) -> Result<(), String> {
let teams = TeamsAtStationLocation::for_station(db, self).await;
@@ -157,7 +157,7 @@ impl Station {
pub(crate) async fn team_update(
&self,
db: &SqlitePool,
db: &mut SqliteConnection,
team: &Team,
points: Option<i64>,
notes: Option<String>,
@@ -167,7 +167,7 @@ impl Station {
Some(n) => Some(n),
None => None,
};
let teams = TeamsAtStationLocation::for_station(db, self).await;
let teams = TeamsAtStationLocation::for_station(&mut *db, self).await;
let waiting_teams: Vec<&Team> = teams.waiting.iter().map(|(team, _)| team).collect();
let doing_teams: Vec<&Team> = teams.doing.iter().map(|(team, _)| team).collect();
@@ -194,7 +194,7 @@ impl Station {
pub(crate) async fn remove_team_waiting(
&self,
db: &SqlitePool,
db: &mut SqliteConnection,
team: &Team,
) -> Result<(), String> {
let teams = TeamsAtStationLocation::for_station(db, self).await;
@@ -210,7 +210,11 @@ impl Station {
Ok(())
}
pub(crate) async fn team_starting(&self, db: &SqlitePool, team: &Team) -> Result<(), String> {
pub(crate) async fn team_starting(
&self,
db: &mut SqliteConnection,
team: &Team,
) -> Result<(), String> {
let teams = TeamsAtStationLocation::for_station(db, self).await;
let waiting_teams: Vec<&Team> = teams.waiting.iter().map(|(team, _)| team).collect();
@@ -233,7 +237,7 @@ impl Station {
pub(crate) async fn remove_team_doing(
&self,
db: &SqlitePool,
db: &mut SqliteConnection,
team: &Team,
) -> Result<(), String> {
let teams = TeamsAtStationLocation::for_station(db, self).await;
@@ -260,7 +264,11 @@ impl Station {
Ok(())
}
pub(crate) async fn team_finished(&self, db: &SqlitePool, team: &Team) -> Result<(), String> {
pub(crate) async fn team_finished(
&self,
db: &mut SqliteConnection,
team: &Team,
) -> Result<(), String> {
let teams = TeamsAtStationLocation::for_station(db, self).await;
let doing_teams: Vec<&Team> = teams.doing.iter().map(|(team, _)| team).collect();
@@ -283,7 +291,7 @@ impl Station {
pub(crate) async fn remove_team_left(
&self,
db: &SqlitePool,
db: &mut SqliteConnection,
team: &Team,
) -> Result<(), String> {
let teams = TeamsAtStationLocation::for_station(db, self).await;
@@ -312,63 +320,7 @@ impl Station {
Ok(())
}
async fn update_name(&self, db: &SqlitePool, name: &str) {
sqlx::query!("UPDATE station SET name = ? WHERE id = ?", name, self.id)
.execute(db)
.await
.unwrap();
}
async fn update_notes(&self, db: &SqlitePool, notes: &str) {
sqlx::query!("UPDATE station SET notes = ? WHERE id = ?", notes, self.id)
.execute(db)
.await
.unwrap();
}
async fn update_amount_people(&self, db: &SqlitePool, amount_people: i64) {
sqlx::query!(
"UPDATE station SET amount_people = ? WHERE id = ?",
amount_people,
self.id
)
.execute(db)
.await
.unwrap();
}
async fn update_amount_people_reset(&self, db: &SqlitePool) {
sqlx::query!(
"UPDATE station SET amount_people = NULL WHERE id = ?",
self.id
)
.execute(db)
.await
.unwrap();
}
async fn update_location(&self, db: &SqlitePool, lat: f64, lng: f64) {
sqlx::query!(
"UPDATE station SET lat = ?, lng = ? WHERE id = ?",
lat,
lng,
self.id
)
.execute(db)
.await
.unwrap();
}
async fn update_location_clear(&self, db: &SqlitePool) {
sqlx::query!(
"UPDATE station SET lat = NULL, lng=NULL WHERE id = ?",
self.id
)
.execute(db)
.await
.unwrap();
}
async fn delete(&self, db: &SqlitePool) -> Result<(), String> {
async fn delete(&self, db: &mut SqliteConnection) -> Result<(), String> {
sqlx::query!("DELETE FROM station WHERE id = ?", self.id)
.execute(db)
.await
@@ -376,7 +328,7 @@ impl Station {
Ok(())
}
async fn routes(&self, db: &SqlitePool) -> Vec<Route> {
async fn routes(&self, db: &mut SqliteConnection) -> Vec<Route> {
sqlx::query_as::<_, Route>(
"
SELECT r.id, r.name
@@ -392,7 +344,7 @@ impl Station {
.unwrap()
}
pub async fn is_in_route(&self, db: &SqlitePool, route: &Route) -> bool {
pub async fn is_in_route(&self, db: &mut SqliteConnection, route: &Route) -> bool {
for r in self.routes(db).await {
if r.id == route.id {
return true;
@@ -410,7 +362,7 @@ impl Station {
Some(datetime_utc.with_timezone(&Local))
}
pub(crate) async fn teams(&self, db: &SqlitePool) -> Vec<Team> {
pub(crate) async fn teams(&self, db: &mut SqliteConnection) -> Vec<Team> {
sqlx::query_as::<_, Team>(
"SELECT DISTINCT t.id, t.name, t.notes, t.amount_people, t.first_station_id, t.last_station_id, t.route_id
FROM team t
@@ -424,7 +376,7 @@ ORDER BY LOWER(t.name);",
.unwrap()
}
pub(crate) async fn left_teams(&self, db: &SqlitePool) -> Vec<Team> {
pub(crate) async fn left_teams(&self, db: &mut SqliteConnection) -> Vec<Team> {
sqlx::query_as::<_, Team>(
"SELECT t.id, t.name, t.notes, t.amount_people, t.first_station_id, t.last_station_id, t.route_id
FROM team t
@@ -438,24 +390,20 @@ AND r.left_at IS NOT NULL;",
.unwrap()
}
pub async fn teams_on_the_way(&self, db: &SqlitePool) -> Vec<TeamOnTheWay> {
pub async fn teams_on_the_way(&self, db: &mut SqliteConnection) -> Vec<TeamOnTheWay> {
let mut ret = Vec::new();
let teams = self.teams(db).await;
let teams = self.teams(&mut *db).await;
let missing_teams: Vec<Team> = stream::iter(teams)
.filter_map(|entry| async move {
if entry.been_at_station(db, self).await {
None
} else {
Some(entry)
}
})
.collect()
.await;
let mut missing_teams = Vec::new();
for team in teams {
if !team.been_at_station(&mut *db, self).await {
missing_teams.push(team);
}
}
for team in missing_teams {
let route = team.route(db).await;
let route = team.route(&mut *db).await;
let Some(prev_station) = route.prev_station(db, self).await else {
continue;
};
@@ -476,7 +424,7 @@ AND r.left_at IS NOT NULL;",
}
}
pub async fn some_team_has_last_station_id(db: &SqlitePool) -> bool {
pub async fn some_team_has_last_station_id(db: &mut SqliteConnection) -> bool {
sqlx::query_scalar!("SELECT 1 FROM team WHERE last_station_id IS NOT NULL")
.fetch_optional(db)
.await

View File

@@ -0,0 +1 @@
pub(crate) mod update;

View File

@@ -0,0 +1,136 @@
use crate::Station;
use serde::Serialize;
use sqlx::Acquire;
use sqlx::SqliteConnection;
use thiserror::Error;
#[derive(Error, Debug, PartialEq, Serialize)]
pub(crate) enum UpdateAmountPeopleError {
#[error(
"Last station can't be crewless, if there is already a team with a route where this station is part of, otherwise team can't be assigned a first station."
)]
LastStationCantBeCrewlessIfTeamExists,
}
impl Station {
pub(crate) async fn update_name(&self, db: &mut SqliteConnection, name: &str) {
sqlx::query!("UPDATE station SET name = ? WHERE id = ?", name, self.id)
.execute(db)
.await
.unwrap();
}
pub(crate) async fn update_notes(&self, db: &mut SqliteConnection, notes: &str) {
sqlx::query!("UPDATE station SET notes = ? WHERE id = ?", notes, self.id)
.execute(db)
.await
.unwrap();
}
pub(crate) async fn update_amount_people(
&self,
db: &mut SqliteConnection,
amount_people: i64,
) -> Result<(), UpdateAmountPeopleError> {
let mut transaction = db.begin().await.unwrap();
sqlx::query!(
"UPDATE station SET amount_people = ? WHERE id = ?",
amount_people,
self.id
)
.execute(&mut *transaction)
.await
.unwrap();
if amount_people == 0 {
let teams = self.teams(&mut transaction).await;
for team in teams {
let route = team.route(&mut transaction).await;
let Some(station) = route.get_next_first_station(&mut transaction).await else {
return Err(UpdateAmountPeopleError::LastStationCantBeCrewlessIfTeamExists);
};
team.update_first_station(transaction.as_mut(), &station)
.await;
}
}
transaction.commit().await.unwrap();
Ok(())
}
pub(crate) async fn update_amount_people_reset(&self, db: &mut SqliteConnection) {
sqlx::query!(
"UPDATE station SET amount_people = NULL WHERE id = ?",
self.id
)
.execute(db)
.await
.unwrap();
}
pub(crate) async fn update_location(&self, db: &mut SqliteConnection, lat: f64, lng: f64) {
sqlx::query!(
"UPDATE station SET lat = ?, lng = ? WHERE id = ?",
lat,
lng,
self.id
)
.execute(db)
.await
.unwrap();
}
pub(crate) async fn update_location_clear(&self, db: &mut SqliteConnection) {
sqlx::query!(
"UPDATE station SET lat = NULL, lng=NULL WHERE id = ?",
self.id
)
.execute(db)
.await
.unwrap();
}
}
#[cfg(test)]
mod test {
use crate::{
Station,
admin::{route::Route, station::model::update::UpdateAmountPeopleError, team::Team},
testdb,
};
use sqlx::SqlitePool;
#[sqlx::test]
async fn succ_update_not_last_crewful_station() {
let pool = testdb!();
let db = &mut *pool.acquire().await.unwrap();
let station = Station::create(db, "Teststation").await.unwrap();
let crew_station = Station::create(db, "Bemannte Teststation").await.unwrap();
let route = Route::create(db, "Testroute").await.unwrap();
route.add_station(db, &station).await.unwrap();
route.add_station(db, &crew_station).await.unwrap();
let _ = Team::create(db, "Testteam", &route).await.unwrap();
assert_eq!(station.update_amount_people(db, 0).await, Ok(()));
}
#[sqlx::test]
async fn fail_update_last_crewful_station() {
let pool = testdb!();
let db = &mut *pool.acquire().await.unwrap();
let station = Station::create(db, "Teststation").await.unwrap();
let route = Route::create(db, "Testroute").await.unwrap();
route.add_station(db, &station).await.unwrap();
let _ = Team::create(db, "Testteam", &route).await.unwrap();
assert_eq!(
station.update_amount_people(db, 0).await,
Err(UpdateAmountPeopleError::LastStationCantBeCrewlessIfTeamExists)
);
}
}

View File

@@ -1,10 +1,10 @@
use crate::{admin::station::typst::TypstWrapperWorld, url, AppState, Station};
use crate::{AppState, Station, admin::station::typst::TypstWrapperWorld, url};
use axum::{
Router,
extract::State,
http::{header, StatusCode},
http::{StatusCode, header},
response::IntoResponse,
routing::get,
Router,
};
use sqlx::SqlitePool;
use std::{fmt::Write, sync::Arc};
@@ -73,9 +73,9 @@ pub(crate) async fn station_pdf(stations: Vec<Station>) -> Vec<u8> {
write!(
content,
r#")
r")
#create_card_grid(cards)"#
#create_card_grid(cards)"
)
.unwrap();
@@ -92,7 +92,9 @@ pub(crate) async fn station_pdf(stations: Vec<Station>) -> Vec<u8> {
}
async fn index(State(db): State<Arc<SqlitePool>>) -> impl IntoResponse {
let stations = Station::all(&db).await;
let db = &mut *db.acquire().await.unwrap();
let stations = Station::all(db).await;
let pdf = station_pdf(stations).await;
(

View File

@@ -2,13 +2,13 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use typst::diag::{eco_format, FileError, FileResult, PackageError, PackageResult};
use typst::Library;
use typst::diag::{FileError, FileResult, PackageError, PackageResult, eco_format};
use typst::foundations::{Bytes, Datetime};
use typst::syntax::package::PackageSpec;
use typst::syntax::{FileId, Source};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::Library;
use typst_kit::fonts::{FontSearcher, FontSlot};
/// Main interface that determines the environment for Typst.
@@ -54,15 +54,14 @@ impl TypstWrapperWorld {
source: Source::detached(source),
time: time::OffsetDateTime::now_utc(),
cache_directory: std::env::var_os("CACHE_DIRECTORY")
.map(|os_path| os_path.into())
.unwrap_or(std::env::temp_dir()),
.map_or(std::env::temp_dir(), std::convert::Into::into),
http: ureq::Agent::new_with_defaults(),
files: Arc::new(Mutex::new(HashMap::new())),
}
}
}
/// A File that will be stored in the HashMap.
/// A File that will be stored in the `HashMap`.
#[derive(Clone, Debug)]
struct FileEntry {
bytes: Bytes,
@@ -216,11 +215,7 @@ impl typst::World for TypstWrapperWorld {
}
fn retry<T, E>(mut f: impl FnMut() -> Result<T, E>) -> Result<T, E> {
if let Ok(ok) = f() {
Ok(ok)
} else {
f()
}
if let Ok(ok) = f() { Ok(ok) } else { f() }
}
fn http_successful(status: u16) -> bool {

View File

@@ -1,19 +1,21 @@
use super::model::update::UpdateAmountPeopleError;
use crate::{
AppState, PageBuilder,
admin::{station::Station, team::Team},
er,
models::rating::{Rating, TeamsAtStationLocation},
partials::page,
suc, AppState,
rating::{Rating, TeamsAtStationLocation},
suc,
};
use axum::{
Form, Router,
extract::State,
response::{IntoResponse, Redirect},
routing::{get, post},
Form, Router,
};
use maud::{html, Markup};
use maud::{Markup, html};
use serde::Deserialize;
use sqlx::SqlitePool;
use std::fmt::Write;
use std::{collections::HashMap, sync::Arc};
use tower_sessions::Session;
@@ -27,8 +29,10 @@ async fn create(
session: Session,
Form(form): Form<CreateForm>,
) -> impl IntoResponse {
match Station::create(&db, &form.name).await {
Ok(()) => suc!(session, t!("station_create_succ", name = form.name)),
let db = &mut *db.acquire().await.unwrap();
match Station::create(db, &form.name).await {
Ok(_) => suc!(session, t!("station_create_succ", name = form.name)),
Err(e) => er!(
session,
t!(
@@ -47,12 +51,14 @@ async fn delete(
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::find_by_id(db, id).await else {
er!(session, t!("nonexisting_station", id = id));
return Redirect::to("/admin/station");
};
match station.delete(&db).await {
match station.delete(db).await {
Ok(()) => suc!(session, t!("station_delete_succ", name = station.name)),
Err(e) => er!(
session,
@@ -72,12 +78,14 @@ async fn view(
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Markup, impl IntoResponse> {
let Some(station) = Station::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::find_by_id(db, id).await else {
er!(session, t!("nonexisting_station", id = id));
return Err(Redirect::to("/admin/station"));
};
let ratings = Rating::for_station(&db, &station).await;
let ratings = Rating::for_station(db, &station).await;
// maybe switch to maud-display impl of station
let content = html! {
@@ -100,7 +108,12 @@ async fn view(
table {
tbody {
tr {
th scope="row" { (t!("notes")) };
th scope="row" {
(t!("notes"))
article {
(t!("station_notes_expl"))
}
};
td {
@match station.notes {
Some(ref notes) => {
@@ -220,7 +233,7 @@ async fn view(
tr {
td {
a href=(format!("/admin/team/{}", rating.team_id)) {
(rating.team(&db).await.name)
(rating.team(db).await.name)
}
}
td {
@@ -329,7 +342,10 @@ async fn view(
}
};
Ok(page(content, session, true).await)
Ok(PageBuilder::new(content, session)
.with_leaflet()
.markup()
.await)
}
#[derive(Deserialize)]
@@ -342,12 +358,14 @@ async fn update_name(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateNameForm>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::find_by_id(db, id).await else {
er!(session, t!("nonexisting_station", id = id));
return Redirect::to("/admin/station");
};
station.update_name(&db, &form.name).await;
station.update_name(db, &form.name).await;
suc!(
session,
@@ -367,12 +385,14 @@ async fn update_notes(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateNotesForm>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::find_by_id(db, id).await else {
er!(session, t!("nonexisting_station", id = id));
return Redirect::to("/admin/station");
};
station.update_notes(&db, &form.notes).await;
station.update_notes(db, &form.notes).await;
suc!(session, t!("station_new_notes", station = station.name));
@@ -389,17 +409,22 @@ async fn update_amount_people(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateAmountPeopleForm>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::find_by_id(db, id).await else {
er!(session, t!("nonexisting_station", id = id));
return Redirect::to("/admin/station");
};
station.update_amount_people(&db, form.amount_people).await;
suc!(
session,
t!("station_new_crew_amount", station = station.name)
);
match station.update_amount_people(db, form.amount_people).await {
Ok(()) => suc!(
session,
t!("station_new_crew_amount", station = station.name)
),
Err(UpdateAmountPeopleError::LastStationCantBeCrewlessIfTeamExists) => {
er!(session, t!("last_station_has_to_be_crewful"));
}
}
Redirect::to(&format!("/admin/station/{id}"))
}
@@ -409,12 +434,14 @@ async fn update_amount_people_reset(
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::find_by_id(&mut *db, id).await else {
er!(session, t!("nonexisting_station", id = id));
return Redirect::to("/admin/station");
};
station.update_amount_people_reset(&db).await;
station.update_amount_people_reset(db).await;
suc!(
session,
@@ -429,12 +456,14 @@ async fn quick(
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Markup, impl IntoResponse> {
let Some(station) = Station::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::find_by_id(db, id).await else {
er!(session, t!("nonexisting_station", id = id));
return Err(Redirect::to("/admin/station"));
};
let teams = station.teams(&db).await;
let teams = station.teams(db).await;
// maybe switch to maud-display impl of team
let content = html! {
@@ -461,7 +490,7 @@ async fn quick(
}
}
td {
@if let Some(rating) = Rating::find_by_team_and_station(&db, team, &station).await {
@if let Some(rating) = Rating::find_by_team_and_station(db, team, &station).await {
a href=(format!("/s/{}/{}", station.id, station.pw)){
@if let Some(points) = rating.points {
em data-tooltip=(t!("already_entered")) {
@@ -483,7 +512,10 @@ async fn quick(
input type="submit" value=(t!("save"));
}
};
Ok(page(content, session, true).await)
Ok(PageBuilder::new(content, session)
.with_leaflet()
.markup()
.await)
}
#[derive(Deserialize, Debug)]
@@ -497,7 +529,9 @@ async fn quick_post(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<QuickUpdate>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::find_by_id(db, id).await else {
er!(session, t!("nonexisting_station", id = id));
return Redirect::to("/admin/station");
};
@@ -507,25 +541,27 @@ async fn quick_post(
for (team_id, points) in &form.fields {
let Ok(team_id) = team_id.parse::<i64>() else {
ret.push_str(&format!(
let _ = write!(
ret,
"Skipped team_id={team_id} because this id can't be parsed as i64"
));
);
continue;
};
let Ok(points) = points.parse::<i64>() else {
ret.push_str(&format!(
"Skipped team_id={team_id} because points {} can't be parsed as i64",
points
));
let _ = write!(
ret,
"Skipped team_id={team_id} because {points} points can't be parsed as i64",
);
continue;
};
let Some(team) = Team::find_by_id(&db, team_id).await else {
ret.push_str(&format!(
let Some(team) = Team::find_by_id(db, team_id).await else {
let _ = write!(
ret,
"Skipped team_id={team_id} because this team does not exist"
));
);
continue;
};
if Rating::find_by_team_and_station(&db, &team, &station)
if Rating::find_by_team_and_station(db, &team, &station)
.await
.is_some()
{
@@ -539,7 +575,7 @@ async fn quick_post(
continue;
}
Rating::create_quick(&db, &team, &station, points).await;
Rating::create_quick(db, &team, &station, points).await;
amount_succ += 1;
}
@@ -568,12 +604,14 @@ async fn update_location(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateLocationForm>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::find_by_id(db, id).await else {
er!(session, t!("nonexisting_station", id = id));
return Redirect::to("/admin/station");
};
station.update_location(&db, form.lat, form.lng).await;
station.update_location(db, form.lat, form.lng).await;
suc!(session, t!("location_changed", station = station.name));
@@ -585,12 +623,14 @@ async fn update_location_clear(
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> impl IntoResponse {
let Some(station) = Station::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::find_by_id(db, id).await else {
er!(session, t!("nonexisting_station", id = id));
return Redirect::to("/admin/station");
};
station.update_location_clear(&db).await;
station.update_location_clear(db).await;
suc!(session, t!("location_deleted", station = station.name));
@@ -598,7 +638,9 @@ async fn update_location_clear(
}
async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
let stations = Station::all(&db).await;
let db = &mut *db.acquire().await.unwrap();
let stations = Station::all(db).await;
let content = html! {
h1 {
@@ -632,7 +674,7 @@ async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
}
tbody {
@for station in &stations {
@let status = TeamsAtStationLocation::for_station(&db, station).await;
@let status = TeamsAtStationLocation::for_station(db, station).await;
tr {
td {
@if station.ready {
@@ -640,7 +682,7 @@ async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
small { "🟢 " }
}
}
@if station.routes(&db).await.is_empty() {
@if station.routes(db).await.is_empty() {
a href="/admin/route" {
em data-tooltip=(t!("station_warning_not_assigned_route")) {
"⚠️ "
@@ -658,7 +700,15 @@ async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
}
td {
em data-tooltip=(t!("station_team_progress", arrived=status.total_teams-status.not_yet_here.len() as i64, total=status.total_teams, waiting= status.waiting.len(), active=status.doing.len() )) {
progress value=(status.total_teams-status.not_yet_here.len() as i64) max=(status.total_teams) {}
@if status.not_yet_here.is_empty() {
@if status.waiting.is_empty() && status.doing.is_empty() {
""
}@else{
"🔜"
}
} @else {
progress value=(status.total_teams-status.not_yet_here.len() as i64) max=(status.total_teams) {}
}
}
}
@@ -682,7 +732,7 @@ async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
}
}
};
page(content, session, false).await
PageBuilder::new(content, session).markup().await
}
pub(super) fn routes() -> Router<AppState> {

View File

@@ -1,13 +1,13 @@
use crate::{
admin::{route::Route, station::Station},
models::rating::Rating,
AppState,
admin::{route::Route, station::Station},
rating::Rating,
};
use axum::Router;
use chrono::{DateTime, Local, NaiveDateTime, Utc};
use maud::{html, Markup, Render};
use maud::{Markup, Render, html};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
use sqlx::{FromRow, SqliteConnection};
mod web;
@@ -19,7 +19,7 @@ pub(crate) struct Team {
pub(crate) amount_people: Option<i64>,
first_station_id: i64,
last_station_id: Option<i64>,
route_id: i64,
pub(crate) route_id: i64,
}
impl Render for Team {
@@ -40,7 +40,7 @@ pub(crate) struct LastContactTeam {
}
impl LastContactTeam {
pub(crate) async fn all_sort_missing(db: &SqlitePool) -> Vec<Self> {
pub(crate) async fn all_sort_missing(db: &mut SqliteConnection) -> Vec<Self> {
let rows = sqlx::query_as::<_, (i64, i64, Option<NaiveDateTime>)>(
"SELECT
t.id AS team_id,
@@ -64,13 +64,15 @@ LEFT JOIN station s ON last_contact.station_id = s.id
ORDER BY
last_contact.last_contact_time DESC",
)
.fetch_all(db)
.fetch_all(&mut *db)
.await
.unwrap();
let mut ret = Vec::new();
for (team_id, station_id, last_contact_time) in rows {
ret.push(LastContactTeam {
team: Team::find_by_id(db, team_id).await.expect("db constraints"),
team: Team::find_by_id(&mut *db, team_id)
.await
.expect("db constraints"),
station: Station::find_by_id(db, station_id).await,
last_contact_time,
});
@@ -87,13 +89,14 @@ ORDER BY
}
}
enum CreateError {
#[derive(Debug)]
pub(crate) enum CreateError {
NoStationForRoute,
DuplicateName(String),
}
impl Team {
pub(crate) async fn all(db: &SqlitePool) -> Vec<Self> {
pub(crate) async fn all(db: &mut SqliteConnection) -> Vec<Self> {
sqlx::query_as::<_, Self>(
"SELECT id, name, notes, amount_people, first_station_id, last_station_id, route_id FROM team ORDER BY name;",
)
@@ -102,7 +105,7 @@ impl Team {
.unwrap()
}
pub(crate) async fn all_with_route(db: &SqlitePool, route: &Route) -> Vec<Self> {
pub(crate) async fn all_with_route(db: &mut SqliteConnection, route: &Route) -> Vec<Self> {
sqlx::query_as!(
Team,
"SELECT id, name, notes, amount_people, first_station_id, last_station_id, route_id FROM team WHERE route_id = ?;",
@@ -113,7 +116,10 @@ impl Team {
.unwrap()
}
pub(crate) async fn all_with_first_station(db: &SqlitePool, station: &Station) -> Vec<Self> {
pub(crate) async fn all_with_first_station(
db: &mut SqliteConnection,
station: &Station,
) -> Vec<Self> {
sqlx::query_as!(
Team,
"select id, name, notes, amount_people, first_station_id, last_station_id, route_id from team where first_station_id = ?;",
@@ -124,7 +130,10 @@ impl Team {
.unwrap()
}
pub(crate) async fn all_with_last_station(db: &SqlitePool, station: &Station) -> Vec<Self> {
pub(crate) async fn all_with_last_station(
db: &mut SqliteConnection,
station: &Station,
) -> Vec<Self> {
sqlx::query_as!(
Team,
"select id, name, notes, amount_people, first_station_id, last_station_id, route_id from team where last_station_id = ?;",
@@ -135,7 +144,7 @@ impl Team {
.unwrap()
}
pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> {
pub async fn find_by_id(db: &mut SqliteConnection, id: i64) -> Option<Self> {
sqlx::query_as!(
Self,
"SELECT id, name, notes, amount_people, first_station_id, last_station_id, route_id FROM team WHERE id = ?",
@@ -146,7 +155,11 @@ impl Team {
.ok()
}
async fn create(db: &SqlitePool, name: &str, route: &Route) -> Result<i64, CreateError> {
pub(crate) async fn create(
db: &mut SqliteConnection,
name: &str,
route: &Route,
) -> Result<i64, CreateError> {
// get next station id which has the lowest amount of teams to have in the first place
// assigned
let Some(station) = route.get_next_first_station(db).await else {
@@ -165,14 +178,14 @@ impl Team {
Ok(result.id.unwrap())
}
async fn update_name(&self, db: &SqlitePool, name: &str) {
async fn update_name(&self, db: &mut SqliteConnection, name: &str) {
sqlx::query!("UPDATE team SET name = ? WHERE id = ?", name, self.id)
.execute(db)
.await
.unwrap();
}
async fn update_end_station(&self, db: &SqlitePool, station: &Station) {
async fn update_end_station(&self, db: &mut SqliteConnection, station: &Station) {
sqlx::query!(
"UPDATE team SET last_station_id = ? WHERE id = ?",
station.id,
@@ -183,14 +196,14 @@ impl Team {
.unwrap();
}
async fn update_notes(&self, db: &SqlitePool, notes: &str) {
async fn update_notes(&self, db: &mut SqliteConnection, notes: &str) {
sqlx::query!("UPDATE team SET notes = ? WHERE id = ?", notes, self.id)
.execute(db)
.await
.unwrap();
}
async fn update_amount_people(&self, db: &SqlitePool, amount_people: i64) {
async fn update_amount_people(&self, db: &mut SqliteConnection, amount_people: i64) {
sqlx::query!(
"UPDATE team SET amount_people = ? WHERE id = ?",
amount_people,
@@ -201,7 +214,7 @@ impl Team {
.unwrap();
}
async fn update_route(&self, db: &SqlitePool, route: &Route) -> Result<String, ()> {
async fn update_route(&self, db: &mut SqliteConnection, route: &Route) -> Result<String, ()> {
let Some(station) = route.get_next_first_station(db).await else {
return Err(());
};
@@ -219,7 +232,7 @@ impl Team {
Ok(station.name)
}
async fn update_first_station(&self, db: &SqlitePool, station: &Station) {
pub(crate) async fn update_first_station(&self, db: &mut SqliteConnection, station: &Station) {
sqlx::query!(
"UPDATE team SET first_station_id = ? WHERE id = ?",
station.id,
@@ -230,7 +243,7 @@ impl Team {
.unwrap();
}
async fn update_last_station(&self, db: &SqlitePool, station: &Station) {
async fn update_last_station(&self, db: &mut SqliteConnection, station: &Station) {
sqlx::query!(
"UPDATE team SET last_station_id = ? WHERE id = ?",
station.id,
@@ -241,14 +254,14 @@ impl Team {
.unwrap();
}
async fn update_amount_people_reset(&self, db: &SqlitePool) {
async fn update_amount_people_reset(&self, db: &mut SqliteConnection) {
sqlx::query!("UPDATE team SET amount_people = NULL WHERE id = ?", self.id)
.execute(db)
.await
.unwrap();
}
async fn delete(&self, db: &SqlitePool) -> Result<(), String> {
async fn delete(&self, db: &mut SqliteConnection) -> Result<(), String> {
sqlx::query!("DELETE FROM team WHERE id = ?", self.id)
.execute(db)
.await
@@ -256,13 +269,13 @@ impl Team {
Ok(())
}
pub async fn first_station(&self, db: &SqlitePool) -> Station {
pub async fn first_station(&self, db: &mut SqliteConnection) -> Station {
Station::find_by_id(db, self.first_station_id)
.await
.expect("db constraints")
}
pub async fn last_station(&self, db: &SqlitePool) -> Option<Station> {
pub async fn last_station(&self, db: &mut SqliteConnection) -> Option<Station> {
if let Some(last_station_id) = self.last_station_id {
Station::find_by_id(db, last_station_id).await
} else {
@@ -270,13 +283,13 @@ impl Team {
}
}
pub async fn route(&self, db: &SqlitePool) -> Route {
pub async fn route(&self, db: &mut SqliteConnection) -> Route {
Route::find_by_id(db, self.route_id)
.await
.expect("db constraints")
}
pub async fn get_curr_points(&self, db: &SqlitePool) -> i64 {
pub async fn get_curr_points(&self, db: &mut SqliteConnection) -> i64 {
sqlx::query!(
"SELECT IFNULL(sum(points), 0) as points FROM rating WHERE team_id = ?",
self.id
@@ -287,13 +300,13 @@ impl Team {
.points
}
pub async fn been_at_station(&self, db: &SqlitePool, station: &Station) -> bool {
pub async fn been_at_station(&self, db: &mut SqliteConnection, station: &Station) -> bool {
Rating::find_by_team_and_station(db, self, station)
.await
.is_some()
}
pub(crate) async fn end_station(&self, db: &SqlitePool) -> Station {
pub(crate) async fn end_station(&self, db: &mut SqliteConnection) -> Station {
match LastContactTeam::all_sort_missing(db)
.await
.into_iter()
@@ -334,7 +347,7 @@ impl Team {
}
}
pub async fn end_run(db: &SqlitePool) {
pub async fn end_run(db: &mut SqliteConnection) {
// set `last_station_id` to the next station where `left_at` is not null
let teams = Team::all(db).await;
for team in teams {
@@ -343,7 +356,7 @@ impl Team {
}
}
pub async fn restart_run(db: &SqlitePool) {
pub async fn restart_run(db: &mut SqliteConnection) {
sqlx::query!("UPDATE team SET last_station_id = null")
.execute(db)
.await

View File

@@ -1,20 +1,21 @@
use super::{CreateError, LastContactTeam, Team};
use crate::{
AppState, PageBuilder,
admin::{route::Route, station::Station},
er,
models::rating::Rating,
partials::page,
suc, AppState,
rating::Rating,
suc,
};
use axum::{
Form, Router,
extract::State,
response::{IntoResponse, Redirect},
routing::{get, post},
Form, Router,
};
use maud::{html, Markup, PreEscaped};
use maud::{Markup, PreEscaped, html};
use serde::Deserialize;
use sqlx::SqlitePool;
use sqlx::{SqliteConnection, SqlitePool};
use std::fmt::Write;
use std::{collections::HashMap, sync::Arc};
use tower_sessions::Session;
@@ -29,13 +30,15 @@ async fn create(
session: Session,
Form(form): Form<CreateForm>,
) -> impl IntoResponse {
let Some(route) = Route::find_by_id(&db, form.route_id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(route) = Route::find_by_id(db, form.route_id).await else {
er!(session, t!("nonexisting_route", id = form.route_id));
return Redirect::to("/admin/team");
};
let id = match Team::create(&db, &form.name, &route).await {
let id = match Team::create(db, &form.name, &route).await {
Ok(id) => {
suc!(session, t!("team_created", team = form.name));
id
@@ -70,12 +73,14 @@ async fn delete(
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(team) = Team::find_by_id(db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
match team.delete(&db).await {
match team.delete(db).await {
Ok(()) => suc!(session, t!("team_deleted", team = team.name)),
Err(e) => er!(
session,
@@ -86,7 +91,12 @@ async fn delete(
Redirect::to("/admin/team")
}
async fn quick(db: Arc<SqlitePool>, team: &Team, stations: Vec<Station>, redirect: &str) -> Markup {
async fn quick(
db: &mut SqliteConnection,
team: &Team,
stations: Vec<Station>,
redirect: &str,
) -> Markup {
html! {
h1 {
a href=(format!("/admin/team/{}", team.id)) { "↩️" }
@@ -114,7 +124,7 @@ async fn quick(db: Arc<SqlitePool>, team: &Team, stations: Vec<Station>, redirec
}
}
td {
@if let Some(rating) = Rating::find_by_team_and_station(&db, team, station).await {
@if let Some(rating) = Rating::find_by_team_and_station(db, team, station).await {
a href=(format!("/s/{}/{}", station.id, station.pw)){
@if let Some(points) = rating.points {
em data-tooltip=(t!("already_entered")) {
@@ -142,16 +152,21 @@ async fn quick_crewless(
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Markup, impl IntoResponse> {
let Some(team) = Team::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(team) = Team::find_by_id(db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Err(Redirect::to("/admin/team"));
};
let stations: Vec<Station> = team.route(&db).await.crewless_stations(&db).await;
let stations: Vec<Station> = team.route(db).await.crewless_stations(db).await;
let content = quick(db, &team, stations, "/crewless").await;
Ok(page(content, session, true).await)
Ok(PageBuilder::new(content, session)
.with_leaflet()
.markup()
.await)
}
async fn quick_all(
@@ -159,16 +174,21 @@ async fn quick_all(
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Markup, impl IntoResponse> {
let Some(team) = Team::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(team) = Team::find_by_id(db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Err(Redirect::to("/admin/team"));
};
let stations = team.route(&db).await.stations(&db).await;
let stations = team.route(db).await.stations(db).await;
let content = quick(db, &team, stations, "").await;
Ok(page(content, session, true).await)
Ok(PageBuilder::new(content, session)
.with_leaflet()
.markup()
.await)
}
#[derive(Deserialize, Debug)]
@@ -183,7 +203,9 @@ async fn quick_post(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<QuickUpdate>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(team) = Team::find_by_id(db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
@@ -193,24 +215,27 @@ async fn quick_post(
for (station_id, points) in &form.fields {
let Ok(station_id) = station_id.parse::<i64>() else {
ret.push_str(&format!(
let _ = write!(
ret,
"Skipped stationid={station_id} because this id can't be parsed as i64"
));
);
continue;
};
let Ok(points) = points.parse::<i64>() else {
ret.push_str(&format!(
"Skipped stationid={station_id} because points {points} can't be parsed as i64",
));
let _ = write!(
ret,
"Skipped stationid={station_id} because {points} points can't be parsed as i64",
);
continue;
};
let Some(station) = Station::find_by_id(&db, station_id).await else {
ret.push_str(&format!(
let Some(station) = Station::find_by_id(&mut *db, station_id).await else {
let _ = write!(
ret,
"Skipped stationid={station_id} because this station does not exist"
));
);
continue;
};
if Rating::find_by_team_and_station(&db, &team, &station)
if Rating::find_by_team_and_station(db, &team, &station)
.await
.is_some()
{
@@ -224,7 +249,7 @@ async fn quick_post(
continue;
}
Rating::create_quick(&db, &team, &station, points).await;
Rating::create_quick(db, &team, &station, points).await;
amount_succ += 1;
}
@@ -247,15 +272,17 @@ async fn view(
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Markup, impl IntoResponse> {
let Some(team) = Team::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(team) = Team::find_by_id(db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Err(Redirect::to("/admin/team"));
};
let first_station = team.first_station(&db).await;
let last_station = team.last_station(&db).await;
let routes = Route::all(&db).await;
let first_station = team.first_station(db).await;
let last_station = team.last_station(db).await;
let routes = Route::all(db).await;
let stations = team.route(&db).await.crewful_stations(&db).await;
let stations = team.route(db).await.crewful_stations(db).await;
// maybe switch to maud-display impl of team
let content = html! {
@@ -281,7 +308,12 @@ async fn view(
table {
tbody {
tr {
th scope="row" { (t!("notes")) };
th scope="row" {
(t!("notes"))
article {
(t!("team_notes_expl"))
}
};
td {
@match &team.notes {
Some(notes) => {
@@ -330,8 +362,8 @@ async fn view(
tr {
th scope="row" { (t!("route")) };
td {
a href=(format!("/admin/route/{}", &team.route(&db).await.id)) {
(&team.route(&db).await.name)
a href=(format!("/admin/route/{}", &team.route(db).await.id)) {
(&team.route(db).await.name)
}
@if routes.len() > 1 {
details {
@@ -339,7 +371,7 @@ async fn view(
form action=(format!("/admin/team/{}/update-route", team.id)) method="post" {
select name="route_id" aria-label=(t!("select_route")) required {
@for route in &routes {
@if route.id != team.route(&db).await.id {
@if route.id != team.route(db).await.id {
option value=(route.id) {
(route.name)
}
@@ -372,12 +404,16 @@ async fn view(
@if station.id != first_station.id {
option value=(station.id) {
(station.name)
@let amount_start_teams = Team::all_with_first_station(&db, station).await.len();
@let amount_start_teams = Team::all_with_first_station(db, station).await.len();
@if amount_start_teams > 0 {
@if amount_start_teams == 1 {
" ("
(t!("already_has_1_start_team"))
")"
}@else{
" ("
(t!("already_has_n_start_team", amount=amount_start_teams))
")"
}
}
}
@@ -434,7 +470,10 @@ async fn view(
}
}
};
Ok(page(content, session, true).await)
Ok(PageBuilder::new(content, session)
.with_leaflet()
.markup()
.await)
}
#[derive(Deserialize)]
@@ -447,12 +486,14 @@ async fn update_name(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateNameForm>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(team) = Team::find_by_id(db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
team.update_name(&db, &form.name).await;
team.update_name(db, &form.name).await;
suc!(
session,
@@ -472,12 +513,14 @@ async fn update_notes(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateNotesForm>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(team) = Team::find_by_id(db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
team.update_notes(&db, &form.notes).await;
team.update_notes(db, &form.notes).await;
suc!(session, t!("notes_edited", team = team.name));
@@ -494,12 +537,14 @@ async fn update_amount_people(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateAmountPeopleForm>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(team) = Team::find_by_id(db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
team.update_amount_people(&db, form.amount_people).await;
team.update_amount_people(db, form.amount_people).await;
suc!(session, t!("amount_teammembers_edited", team = team.name));
@@ -516,18 +561,20 @@ async fn update_route(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateRouteForm>,
) -> impl IntoResponse {
let db = &mut *db.acquire().await.unwrap();
// TODO: move sanity checks into mod.rs
let Some(team) = Team::find_by_id(&db, id).await else {
let Some(team) = Team::find_by_id(db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
let Some(route) = Route::find_by_id(&db, form.route_id).await else {
let Some(route) = Route::find_by_id(db, form.route_id).await else {
er!(session, t!("nonexisting_route", id = form.route_id));
return Redirect::to(&format!("/admin/team/{id}"));
};
match team.update_route(&db, &route).await {
match team.update_route(db, &route).await {
Ok(new_first_station_name) => suc!(
session,
t!(
@@ -560,12 +607,14 @@ async fn update_first_station(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateFirstStationForm>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(team) = Team::find_by_id(db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
let Some(station) = Station::find_by_id(&db, form.first_station_id).await else {
let Some(station) = Station::find_by_id(&mut *db, form.first_station_id).await else {
er!(
session,
t!("nonexisting_station", id = form.first_station_id)
@@ -574,21 +623,22 @@ async fn update_first_station(
return Redirect::to(&format!("/admin/team/{id}"));
};
if !station.is_in_route(&db, &team.route(&db).await).await {
let route = team.route(db).await;
if !station.is_in_route(db, &route).await {
er!(
session,
t!(
"first_station_not_edited_not_on_route",
station = station.name,
team = team.name,
route = team.route(&db).await.name
route = team.route(db).await.name
)
);
return Redirect::to(&format!("/admin/team/{id}"));
}
team.update_first_station(&db, &station).await;
team.update_first_station(db, &station).await;
suc!(
session,
@@ -612,12 +662,14 @@ async fn update_last_station(
axum::extract::Path(id): axum::extract::Path<i64>,
Form(form): Form<UpdateLastStationForm>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(team) = Team::find_by_id(db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
let Some(station) = Station::find_by_id(&db, form.last_station_id).await else {
let Some(station) = Station::find_by_id(&mut *db, form.last_station_id).await else {
er!(
session,
t!("nonexisting_station", id = form.last_station_id)
@@ -625,21 +677,22 @@ async fn update_last_station(
return Redirect::to(&format!("/admin/team/{id}"));
};
if !station.is_in_route(&db, &team.route(&db).await).await {
let route = team.route(db).await;
if !station.is_in_route(db, &route).await {
er!(
session,
t!(
"last_station_not_edited_not_on_route",
station = station.name,
team = team.name,
route = team.route(&db).await.name
route = team.route(db).await.name
)
);
return Redirect::to(&format!("/admin/team/{id}"));
}
team.update_last_station(&db, &station).await;
team.update_last_station(db, &station).await;
suc!(
session,
@@ -658,12 +711,14 @@ async fn update_amount_people_reset(
session: Session,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> impl IntoResponse {
let Some(team) = Team::find_by_id(&db, id).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(team) = Team::find_by_id(db, id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/admin/team");
};
team.update_amount_people_reset(&db).await;
team.update_amount_people_reset(db).await;
suc!(session, t!("amount_teammembers_edited", team = team.name));
@@ -671,7 +726,9 @@ async fn update_amount_people_reset(
}
async fn lost(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
let losts = LastContactTeam::all_sort_missing(&db).await;
let db = &mut *db.acquire().await.unwrap();
let losts = LastContactTeam::all_sort_missing(db).await;
let content = html! {
h1 {
@@ -697,7 +754,7 @@ async fn lost(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
}
td {
@if let Some(time) = lost.local_last_contact() {
(time)
(time.format("%H:%M"))
}@else{
(t!("not_yet_seen"))
}
@@ -718,12 +775,14 @@ async fn lost(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
}
};
page(content, session, false).await
PageBuilder::new(content, session).markup().await
}
async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
let teams = Team::all(&db).await;
let routes = Route::all(&db).await;
let db = &mut *db.acquire().await.unwrap();
let teams = Team::all(db).await;
let routes = Route::all(db).await;
let content = html! {
h1 {
@@ -777,13 +836,13 @@ async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
}
a href="/admin/team/lost" {
button class="outline" {
(t!("have_i_lost_groups"))
(t!("have_i_lost_teams"))
}
}
@for route in &routes {
h2 { (route.name) }
ol {
@for team in &route.teams(&db).await{
@for team in &route.teams(db).await{
li {
a href=(format!("/admin/team/{}", team.id)){
(team.name)
@@ -797,7 +856,8 @@ async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
}
}
};
page(content, session, false).await
PageBuilder::new(content, session).markup().await
}
pub(super) fn routes() -> Router<AppState> {

View File

@@ -41,11 +41,11 @@ impl User {
Ok(result.id)
}
async fn update_name(&self, db: &SqlitePool, name: &str) {
async fn update_name(&self, db: &SqlitePool, name: &str) -> bool {
sqlx::query!("UPDATE user SET name = ? WHERE id = ?", name, self.id)
.execute(db)
.await
.unwrap();
.is_ok()
}
async fn new_pw(&self, db: &SqlitePool) {

View File

@@ -1,4 +1,4 @@
use crate::{auth::User, er, partials::page, suc, AppState};
use crate::{auth::User, er, suc, AppState, PageBuilder};
use axum::{
extract::State,
response::{IntoResponse, Redirect},
@@ -119,7 +119,8 @@ async fn view(
}
}
};
Ok(page(content, session, true).await)
Ok(PageBuilder::new(content, session).markup().await)
}
#[derive(Deserialize)]
@@ -137,12 +138,14 @@ async fn update_name(
return Redirect::to("/admin/user");
};
user.update_name(&db, &form.name).await;
suc!(
session,
t!("new_user_name", old = user.name, new = form.name)
);
if user.update_name(&db, &form.name).await {
suc!(
session,
t!("new_user_name", old = user.name, new = form.name)
);
} else {
er!(session, t!("user_name_already_exists", new = form.name));
}
Redirect::to(&format!("/admin/user/{id}"))
}
@@ -200,7 +203,8 @@ async fn index(State(db): State<Arc<SqlitePool>>, session: Session) -> Markup {
}
}
};
page(content, session, false).await
PageBuilder::new(content, session).markup().await
}
pub(super) fn routes() -> Router<AppState> {

View File

@@ -1,13 +1,13 @@
use crate::{er, page, suc, AppState};
use crate::{AppState, PageBuilder, er, suc};
use async_trait::async_trait;
use axum::{
Form, Router,
http::StatusCode,
response::{IntoResponse, Redirect},
routing::{get, post},
Form, Router,
};
use axum_login::{AuthUser, AuthnBackend};
use maud::{html, Markup};
use maud::{Markup, html};
use password_auth::verify_password;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
@@ -119,7 +119,7 @@ async fn login(session: Session) -> Markup {
}
};
page(content, session, false).await
PageBuilder::new(content, session).markup().await
}
pub async fn login_post(

View File

@@ -16,29 +16,29 @@ macro_rules! testdb {
i18n!("locales", fallback = "de-AT");
use admin::station::{print::station_pdf, Station};
use admin::station::{Station, print::station_pdf};
use auth::{AuthSession, Backend, User};
use axum::{
Form, Router,
body::Body,
extract::{FromRef, State},
response::{IntoResponse, Redirect, Response},
routing::{get, post},
Form, Router,
};
use axum_login::AuthManagerLayerBuilder;
use maud::{html, Markup};
use partials::page;
use maud::{Markup, html};
use partials::PageBuilder;
use serde::Deserialize;
use sqlx::SqlitePool;
use std::{env, sync::Arc};
use tokio::net::TcpListener;
use tower_sessions::{cookie::time::Duration, Expiry, Session, SessionManagerLayer};
use tower_sessions::{Expiry, Session, SessionManagerLayer, cookie::time::Duration};
use tower_sessions_sqlx_store_chrono::SqliteStore;
pub(crate) mod admin;
mod auth;
pub(crate) mod models;
mod partials;
pub(crate) mod rating;
pub(crate) mod station;
pub(crate) fn test_version() -> bool {
@@ -212,7 +212,7 @@ async fn set_pw(
}
};
Ok(page(content, session, false).await)
Ok(PageBuilder::new(content, session).markup().await)
}
#[derive(Deserialize)]
struct NewPwForm {
@@ -302,7 +302,7 @@ pub async fn start(listener: TcpListener, db: SqlitePool) {
tokio::spawn(async move {
// Kick-off typst compilation, to reduce wait time for 1st load
let stations = Station::all(&db).await;
let stations = Station::all(&mut db.acquire().await.unwrap()).await;
station_pdf(stations).await;
});

View File

@@ -1,5 +1,5 @@
use dotenv::dotenv;
use sqlx::{pool::PoolOptions, SqlitePool};
use sqlx::{SqlitePool, pool::PoolOptions};
use std::env;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

View File

@@ -1 +0,0 @@
pub(crate) mod rating;

View File

@@ -2,34 +2,71 @@ use crate::test_version;
use maud::{DOCTYPE, Markup, html};
use tower_sessions::Session;
pub(crate) async fn page(content: Markup, session: Session, leaflet: bool) -> Markup {
// Get and clear flash message
let succ_msg = session.get::<String>("succ").await.unwrap_or(None);
if succ_msg.is_some() {
session.remove::<String>("succ").await.unwrap();
}
let warn_msg = session.get::<String>("warn").await.unwrap_or(None);
if warn_msg.is_some() {
session.remove::<String>("warn").await.unwrap();
}
let err_msg = session.get::<String>("err").await.unwrap_or(None);
if err_msg.is_some() {
session.remove::<String>("err").await.unwrap();
pub(crate) struct PageBuilder {
content: Markup,
session: Session,
leaflet: bool,
full: bool,
}
impl PageBuilder {
pub fn new(content: Markup, session: Session) -> Self {
Self {
content,
session,
leaflet: false,
full: false,
}
}
html! {
(DOCTYPE)
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
link rel="stylesheet" href="/pico.css";
link rel="stylesheet" href="/style.css";
@if leaflet {
link rel="stylesheet" href="/leaflet.css";
}
@if test_version() {
style {
r#"
#[must_use]
pub fn with_leaflet(self) -> Self {
Self {
leaflet: true,
..self
}
}
#[must_use]
pub fn set_leaflet(self, leaflet: bool) -> Self {
Self { leaflet, ..self }
}
#[must_use]
pub fn full_page(self) -> Self {
Self { full: true, ..self }
}
pub async fn markup(self) -> Markup {
// Get and clear flash message
let succ_msg = self.session.get::<String>("succ").await.unwrap_or(None);
if succ_msg.is_some() {
self.session.remove::<String>("succ").await.unwrap();
}
let warn_msg = self.session.get::<String>("warn").await.unwrap_or(None);
if warn_msg.is_some() {
self.session.remove::<String>("warn").await.unwrap();
}
let err_msg = self.session.get::<String>("err").await.unwrap_or(None);
if err_msg.is_some() {
self.session.remove::<String>("err").await.unwrap();
}
let main_style = if self.full { "max-width: 98%;" } else { "" };
html! {
(DOCTYPE)
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
link rel="stylesheet" href="/pico.css";
link rel="stylesheet" href="/style.css";
@if self.leaflet {
link rel="stylesheet" href="/leaflet.css";
}
@if test_version() {
style {
r#"
body {
margin: 0;
height: 100%;
@@ -42,30 +79,32 @@ body {
rgba(255, 255, 0, 0.3) 40px
);
}"#
}
}
}
}
body {
main class="container" {
@if let Some(message) = err_msg {
article class="error" {
(message)
body {
main class="container" style=(main_style)
{
@if let Some(message) = err_msg {
article class="error" {
(message)
}
}
}
@if let Some(message) = warn_msg {
article class="warning" {
(message)
@if let Some(message) = warn_msg {
article class="warning" {
(message)
}
}
}
@if let Some(message) = succ_msg {
article class="succ" {
(message)
@if let Some(message) = succ_msg {
article class="succ" {
(message)
}
}
@if self.leaflet {
script src="/leaflet.js" {};
}
(self.content)
}
@if leaflet {
script src="/leaflet.js" {};
}
(content)
}
}
}

View File

@@ -1,7 +1,11 @@
use crate::{admin::team::Team, Station};
use crate::{
Station,
admin::{route::Route, team::Team},
};
use chrono::{DateTime, Local, NaiveDateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, SqlitePool};
use sqlx::{FromRow, SqliteConnection};
use std::collections::HashMap;
#[derive(FromRow, Debug, Serialize, Deserialize)]
pub(crate) struct Rating {
@@ -16,7 +20,7 @@ pub(crate) struct Rating {
impl Rating {
pub(crate) async fn create(
db: &SqlitePool,
db: &mut SqliteConnection,
station: &Station,
team: &Team,
) -> Result<(), String> {
@@ -31,7 +35,12 @@ impl Rating {
Ok(())
}
pub(crate) async fn create_quick(db: &SqlitePool, team: &Team, station: &Station, points: i64) {
pub(crate) async fn create_quick(
db: &mut SqliteConnection,
team: &Team,
station: &Station,
points: i64,
) {
sqlx::query!(
"INSERT INTO rating(team_id, station_id, points, arrived_at, started_at, left_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
team.id,
@@ -43,19 +52,19 @@ impl Rating {
.unwrap();
}
pub(crate) async fn team(&self, db: &SqlitePool) -> Team {
pub(crate) async fn team(&self, db: &mut SqliteConnection) -> Team {
Team::find_by_id(db, self.team_id)
.await
.expect("db constraints")
}
pub(crate) async fn station(&self, db: &SqlitePool) -> Station {
pub(crate) async fn station(&self, db: &mut SqliteConnection) -> Station {
Station::find_by_id(db, self.station_id)
.await
.expect("db constraints")
}
pub(crate) async fn for_station(db: &SqlitePool, station: &Station) -> Vec<Self> {
pub(crate) async fn for_station(db: &mut SqliteConnection, station: &Station) -> Vec<Self> {
sqlx::query_as::<_, Self>("SELECT team_id, station_id, points, notes, arrived_at, started_at, left_at FROM rating WHERE station_id = ?;")
.bind(station.id)
.fetch_all(db)
@@ -64,7 +73,7 @@ impl Rating {
}
pub(crate) async fn update(
db: &SqlitePool,
db: &mut SqliteConnection,
station: &Station,
team: &Team,
points: Option<i64>,
@@ -84,7 +93,7 @@ impl Rating {
}
pub(crate) async fn delete(
db: &SqlitePool,
db: &mut SqliteConnection,
station: &Station,
team: &Team,
) -> Result<(), String> {
@@ -99,7 +108,7 @@ impl Rating {
Ok(())
}
pub async fn find_by_team_and_station(
db: &SqlitePool,
db: &mut SqliteConnection,
team: &Team,
station: &Station,
) -> Option<Self> {
@@ -139,14 +148,19 @@ impl Rating {
pub(crate) struct TeamsAtStationLocation {
pub(crate) total_teams: i64,
pub(crate) not_yet_here: Vec<Team>,
pub(crate) not_yet_here_by_route: HashMap<Route, Vec<Team>>,
pub(crate) waiting: Vec<(Team, Rating)>,
pub(crate) doing: Vec<(Team, Rating)>,
pub(crate) left_not_yet_rated: Vec<(Team, Rating)>,
pub(crate) left_and_rated: Vec<(Team, Rating)>,
pub(crate) done: bool,
}
impl TeamsAtStationLocation {
pub(crate) async fn for_station(db: &SqlitePool, station: &Station) -> TeamsAtStationLocation {
pub(crate) async fn for_station(
db: &mut SqliteConnection,
station: &Station,
) -> TeamsAtStationLocation {
let teams = station.teams(db).await;
let total_teams = teams.len() as i64;
@@ -156,32 +170,47 @@ impl TeamsAtStationLocation {
let mut left_not_yet_rated = Vec::new();
let mut left_and_rated = Vec::new();
let mut done = true;
for team in teams {
match Rating::find_by_team_and_station(db, &team, station).await {
Some(rating) => {
if rating.left_at.is_some() {
if rating.points.is_some() {
left_and_rated.push((team, rating));
} else {
left_not_yet_rated.push((team, rating));
}
} else if rating.started_at.is_some() {
doing.push((team, rating));
if let Some(rating) = Rating::find_by_team_and_station(db, &team, station).await {
if rating.left_at.is_some() {
if rating.points.is_some() {
left_and_rated.push((team, rating));
} else {
waiting.push((team, rating));
done = false;
left_not_yet_rated.push((team, rating));
}
} else if rating.started_at.is_some() {
done = false;
doing.push((team, rating));
} else {
done = false;
waiting.push((team, rating));
}
None => not_yet_here.push(team),
} else {
done = false;
not_yet_here.push(team);
}
}
let mut not_yet_here_by_route = HashMap::new();
for team in &not_yet_here {
not_yet_here_by_route
.entry(team.route(db).await)
.or_insert_with(Vec::new)
.push(team.clone());
}
TeamsAtStationLocation {
total_teams,
not_yet_here,
not_yet_here_by_route,
waiting,
doing,
left_not_yet_rated,
left_and_rated,
done,
}
}
}

View File

@@ -1,16 +1,17 @@
use crate::{
admin::{team::Team, RunStatus},
AppState, PageBuilder, Station,
admin::{RunStatus, team::Team},
er, err,
models::rating::TeamsAtStationLocation,
partials, suc, AppState, Station,
rating::TeamsAtStationLocation,
suc,
};
use axum::{
Form, Router,
extract::State,
response::{IntoResponse, Redirect},
routing::{get, post},
Form, Router,
};
use maud::{html, Markup, PreEscaped};
use maud::{Markup, PreEscaped, html};
use serde::Deserialize;
use sqlx::SqlitePool;
use std::sync::Arc;
@@ -21,33 +22,34 @@ async fn view(
session: Session,
axum::extract::Path((id, code)): axum::extract::Path<(i64, String)>,
) -> Markup {
let Some(station) = Station::login(&db, id, &code).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::login(db, id, &code).await else {
let content = html! {
article class="error" {
(t!("invalid_rating_code"))
}
};
return partials::page(content, session, false).await;
return PageBuilder::new(content, session).markup().await;
};
let teams = TeamsAtStationLocation::for_station(&db, &station).await;
let teams_on_the_way = station.teams_on_the_way(&db).await;
let status = RunStatus::curr(&db).await;
let teams = TeamsAtStationLocation::for_station(db, &station).await;
let teams_on_the_way = station.teams_on_the_way(db).await;
let status = RunStatus::curr(db).await;
let content = html! {
h1 {
(t!("station"))
" "
(station.name)
}
@if let (Some(lat), Some(lng)) = (station.lat, station.lng) {
h1 {
(t!("station"))
" "
(station.name)
}
article {
details open[(!station.ready)]{
details class="mb-0" open[(!station.ready)]{
summary { (t!("infos")) }
"👋"
"👋 "
(t!("station_info"))
" "
@let first_teams = Team::all_with_first_station(&db, &station).await;
@let first_teams = Team::all_with_first_station(db, &station).await;
@if first_teams.is_empty() {
(t!("station_has_no_teams_to_take_to_start"))
} @else{
@@ -72,20 +74,31 @@ async fn view(
}
}
}
@if let Some(notes) = team.notes {
@if let Some(notes) = &team.notes {
li {
(notes)
}
}
}
@if teams.not_yet_here.contains(&team) {
form action=(format!("/s/{id}/{code}/new-waiting")) method="post" {
input type="hidden" name="team_id" value=(team.id);
input type="submit" value=(t!("team_is_here"));
}
} @else if teams.waiting.iter().any(|(t, _)| t == &team) {
a href=(format!("/s/{id}/{code}/remove-waiting/{}", team.id))
onclick=(format!("return confirm('{}');", t!("confirm_station_cancel_team_waiting", team=team.name))) {
"🗑️"
}
}
}
}
}
}
(t!("your_station_is_here"))
div id="map" style="height: 500px" {}
script { (format!("
@if let (Some(lat), Some(lng)) = (station.lat, station.lng) {
(t!("your_station_is_here"))
div id="map" style="height: 500px" {}
script { (format!("
const map = L.map('map').setView([{lat}, {lng}], 14);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
@@ -100,11 +113,12 @@ async fn view(
currentMarker = L.marker([{lat}, {lng}], {{icon: myIcon}}).addTo(map);
map.setView([{lat}, {lng}], 14);
"))
}
div {
sub {
a href=(format!("https://www.google.com/maps?q={lat},{lng}")) target="_blank" {
(t!("google_maps_navigation"))
}
div {
sub {
a href=(format!("https://www.google.com/maps?q={lat},{lng}")) target="_blank" {
(t!("google_maps_navigation"))
}
}
}
}
@@ -120,205 +134,175 @@ async fn view(
}
}
}
}
article {
@if teams.total_teams == 1 {
(t!("one_team_should_come_to_station"))
} @else{
(t!("n_teams_should_come_to_station", amount=teams.total_teams))
}
progress value=(teams.total_teams-teams.not_yet_here.len() as i64) max=(teams.total_teams) {}
@if status == RunStatus::HasEnded {
@let teams_to_take_home = Team::all_with_last_station(&db, &station).await;
@if !teams_to_take_home.is_empty() {
@if teams_to_take_home.len() == 1 {
(t!("take_home_the_following_team"))
} @else {
(t!("take_home_the_following_teams"))
}
ol {
@for team in teams_to_take_home {
li { (team.name) }
}
}
}@else {
(t!("no_team_to_take_home"))
}
}
}
@for team in teams_on_the_way {
article {
(t!("team_on_the_way_to_your_station", team=team.team.name, time=team.left))
form action=(format!("/s/{id}/{code}/new-waiting")) method="post" {
input type="hidden" name="team_id" value=(team.team.id);
input type="submit" value=(t!("team_is_here"));
@if teams.total_teams == 1 {
(t!("one_team_should_come_to_station"))
} @else{
(t!("n_teams_should_come_to_station", amount=teams.total_teams))
}
}
}
@if !teams.not_yet_here.is_empty() {
form action=(format!("/s/{id}/{code}/new-waiting")) method="post" {
fieldset role="group" {
select name="team_id" aria-label=(t!("select_team")) required {
@for team in &teams.not_yet_here {
option value=(team.id) {
(team.name)
}
div {
em data-tooltip=(t!("station_team_progress", arrived=teams.total_teams-teams.not_yet_here.len() as i64, total=teams.total_teams, waiting=teams.waiting.len(), active=teams.doing.len())) {
progress value=(teams.total_teams-teams.not_yet_here.len() as i64) max=(teams.total_teams) {}
}
}
@if status == RunStatus::HasEnded {
(t!("station_done"))
@let teams_to_take_home = Team::all_with_last_station(db, &station).await;
@if !teams_to_take_home.is_empty() {
@if teams_to_take_home.len() == 1 {
(t!("take_home_the_following_team"))
} @else {
(t!("take_home_the_following_teams"))
}
ol {
@for team in teams_to_take_home {
li { (team.name) }
}
}
}@else {
(t!("no_team_to_take_home"))
}
}
}
@if teams.done {
article {
(t!("station_done"))
}
}
@for team in teams_on_the_way {
article {
(t!("team_on_the_way_to_your_station", team=team.team.name, time=team.left))
form action=(format!("/s/{id}/{code}/new-waiting")) method="post" {
input type="hidden" name="team_id" value=(team.team.id);
input type="submit" value=(t!("team_is_here"));
}
}
}
h2 { (t!("teams_at_your_station")) }
@if !teams.doing.is_empty() {
@for (team, rating) in teams.doing {
article {
details {
summary {
em data-tooltip=(t!("state_active")) { (t!("state_active_icon")) " " }
(team.name)
small {
" ("
(t!("since_time", time=rating.local_time_doing()))
")"
}
"✏️"
a href=(format!("/s/{id}/{code}/team-finished/{}", team.id)) {
button { (t!("team_finished")) }
}
}
form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" {
label {
(t!("notes"))
@if let Some(notes) = &rating.notes {
input type="text" name="notes" value=(notes);
} @else {
input type="text" name="notes";
}
}
input type="submit" value=(t!("save_notes"));
}
a href=(format!("/s/{id}/{code}/remove-doing/{}", team.id))
onclick=(format!("return confirm('{}');", t!("confirm_station_cancel_team_active", team=team.name))) {
"🗑️"
}
}
}
}
}
@if !teams.waiting.is_empty() {
@for (team, rating) in teams.waiting {
article {
details {
summary {
em data-tooltip=(t!("state_waiting")) { (t!("state_waiting_icon")) " "}
(team.name)
small {
" ("
(t!("since_time", time=rating.local_time_arrived_at()))
")"
}
"✏️"
a href=(format!("/s/{id}/{code}/team-starting/{}", team.id)) {
button { (t!("team_starting")) }
}
}
form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" {
label {
(t!("notes"))
@if let Some(notes) = &rating.notes {
input type="text" name="notes" value=(notes);
} @else {
input type="text" name="notes";
}
}
input type="submit" value=(t!("save_notes"));
}
a href=(format!("/s/{id}/{code}/remove-waiting/{}", team.id))
onclick=(format!("return confirm('{}');", t!("confirm_station_cancel_team_waiting", team=team.name))) {
"🗑️"
}
}
}
}
}
@if !teams.left_not_yet_rated.is_empty() {
h2 { (t!("to_rate")) }
article class="warning" {
@if teams.left_not_yet_rated.len() == 1 {
(t!("info_single_team_not_yet_rated"))
} @else {
(t!("info_multiple_teams_not_yet_rated"))
}
}
@for (team, rating) in teams.left_not_yet_rated {
article {
em data-tooltip=(t!("state_to_rate")) { (t!("state_to_rate_icon")) " " }
(team.name)
small {
" ("
(t!("left_at", time=rating.local_time_left()))
")"
}
form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" {
label {
@if let Some(points) = rating.points {
span { (points) " " (t!("points")) }
input type="range" name="points" min="0" max="10" value=(points)
onchange=(format!("if(!confirm('{}')) {{ this.value = this.defaultValue; this.previousElementSibling.textContent = this.defaultValue + ' Punkte'; }}", t!("confirm_rating_change")))
oninput=(format!("this.previousElementSibling.textContent = this.value + ' {}'", t!("points"))) {}
} @else {
span { "0 " (t!("points")) }
input type="range" name="points" min="0" max="10" value="0" oninput="this.previousElementSibling.textContent = this.value + ' Punkte'" {}
}
}
label {
(t!("notes"))
@if let Some(notes) = &rating.notes {
input type="text" name="notes" value=(notes);
} @else {
input type="text" name="notes";
}
}
input type="submit" value=(t!("save_notes"));
}
a href=(format!("/s/{id}/{code}/remove-left/{}", team.id))
onclick=(format!("return confirm('{}');", t!("confirm_station_cancel_team_finished"))) {
"🗑️"
}
}
}
}
h2 { (t!("history")) }
@if !teams.left_and_rated.is_empty() {
@for (team, rating) in teams.left_and_rated {
article {
details {
summary {
em data-tooltip=(t!("state_rated")) { (t!("state_rated_icon")) " " }
(team.name)
(PreEscaped(" &rarr; "))
(rating.points.unwrap())
" "
(t!("points"))
@if !teams.not_yet_here.is_empty() {
form action=(format!("/s/{id}/{code}/new-waiting")) method="post" {
fieldset role="group" {
select name="team_id" aria-label=(t!("select_team")) required {
@for (route, teams) in &teams.not_yet_here_by_route {
optgroup label=(route.name) {
@for team in teams {
option value=(team.id) {
(team.name)
}
}
}
}
}
input type="submit" value=(t!("team_is_here"));
}
}
}
@if !teams.doing.is_empty() || !teams.waiting.is_empty() {
h2 { (t!("teams_at_your_station")) }
}
@if !teams.doing.is_empty() {
@for (team, rating) in teams.doing {
article {
details class="mb-0" {
summary class="flex-center-between" {
span {
em class="mr-1" data-tooltip=(t!("state_active")) { (t!("state_active_icon")) " " }
(team.name)
small class="mr-1" {
" ("
(t!("since_time", time=rating.local_time_doing()))
")"
}
"✏️"
}
a class="contrast" role="button" href=(format!("/s/{id}/{code}/team-finished/{}", team.id)) {
(t!("team_finished"))
}
}
form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" {
fieldset role="group" {
@if let Some(notes) = &rating.notes {
input type="text" name="notes" value=(notes) placeholder=(t!("notes"));
} @else {
input type="text" name="notes" placeholder=(t!("notes"));
}
input type="submit" value=(t!("save_notes"));
}
}
a role="button" class="danger" href=(format!("/s/{id}/{code}/remove-doing/{}", team.id))
onclick=(format!("return confirm('{}');", t!("confirm_station_cancel_team_active", team=team.name))) {
(t!("team_active_step_back"))
}
}
}
}
}
@if !teams.waiting.is_empty() {
@for (team, rating) in teams.waiting {
article {
details class="mb-0" {
summary class="flex-center-between" {
span {
em class="mr-1" data-tooltip=(t!("state_waiting")) { (t!("state_waiting_icon")) " "}
(team.name)
small class="mr-1" {
" ("
(t!("since_time", time=rating.local_time_arrived_at()))
")"
}
"✏️"
}
a role="button" href=(format!("/s/{id}/{code}/team-starting/{}", team.id)) {
(t!("team_starting"))
}
}
form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" {
fieldset role="group" {
@if let Some(notes) = &rating.notes {
input type="text" name="notes" value=(notes) placeholder=(t!("notes"));
} @else {
input type="text" name="notes" placeholder=(t!("notes"));
}
input type="submit" value=(t!("save_notes"));
}
}
a role="button" class="danger" href=(format!("/s/{id}/{code}/remove-waiting/{}", team.id))
onclick=(format!("return confirm('{}');", t!("confirm_station_cancel_team_waiting", team=team.name))) {
(t!("team_waiting_step_back"))
}
}
}
}
}
@if !teams.left_not_yet_rated.is_empty() {
h2 { (t!("to_rate")) }
article class="warning" {
@if teams.left_not_yet_rated.len() == 1 {
(t!("info_single_team_not_yet_rated"))
} @else {
(t!("info_multiple_teams_not_yet_rated"))
}
}
@for (team, rating) in teams.left_not_yet_rated {
article {
em data-tooltip=(t!("state_to_rate")) { (t!("state_to_rate_icon")) " " }
(team.name)
small {
" ("
(t!("arrived_at_started_at_left_at", arrived=rating.local_time_arrived_at(), active=rating.local_time_doing(), left=rating.local_time_left()))
")"
(t!("left_at", time=rating.local_time_left()))
")"
}
form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" {
label {
@if let Some(points) = rating.points {
span { (points) " Punkte" }
span { (points) " " (t!("points")) }
input type="range" name="points" min="0" max="10" value=(points)
onchange=(format!("if(!confirm('{}')) {{ this.value = this.defaultValue; this.previousElementSibling.textContent = this.defaultValue + ' Punkte'; }}", t!("confirm_rating_change")))
oninput=(format!("this.previousElementSibling.textContent = this.value + ' {}'", t!("points"))) {}
} @else {
span { "0 " (t!("points")) }
input type="range" name="points" min="0" max="10" value="0" oninput="this.previousElementSibling.textContent = this.value + ' Punkte'" {}
}
}
@@ -330,24 +314,74 @@ async fn view(
input type="text" name="notes";
}
}
input type="submit" value=(t!("save_notes"));
input type="submit" value=(t!("save"));
}
a href=(format!("/s/{id}/{code}/remove-left/{}", team.id))
onclick=(format!("return confirm('{}');", t!("confirm_station_cancel_team_finished"))) {
"🗑️"
a role="button" class="danger" href=(format!("/s/{id}/{code}/remove-left/{}", team.id))
onclick=(format!("return confirm('{}');", t!("confirm_station_cancel_team_finished"))) {
(t!("team_done_step_back"))
}
}
}
}
h2 { (t!("history")) }
@if !teams.left_and_rated.is_empty() {
@for (team, rating) in teams.left_and_rated {
article {
details class="mb-0" {
summary {
em class="mr-1" data-tooltip=(t!("state_rated")) { (t!("state_rated_icon")) " " }
(team.name)
(PreEscaped(" &rarr; "))
(rating.points.unwrap())
" "
(t!("points"))
}
small {
" ("
(t!("arrived_at_started_at_left_at", arrived=rating.local_time_arrived_at(), active=rating.local_time_doing(), left=rating.local_time_left()))
")"
}
form action=(format!("/s/{id}/{code}/team-update/{}", team.id)) method="post" {
label {
@if let Some(points) = rating.points {
span { (points) " Punkte" }
input type="range" name="points" min="0" max="10" value=(points)
onchange=(format!("if(!confirm('{}')) {{ this.value = this.defaultValue; this.previousElementSibling.textContent = this.defaultValue + ' Punkte'; }}", t!("confirm_rating_change")))
oninput=(format!("this.previousElementSibling.textContent = this.value + ' {}'", t!("points"))) {}
}
}
label {
(t!("notes"))
@if let Some(notes) = &rating.notes {
input type="text" name="notes" value=(notes);
} @else {
input type="text" name="notes";
}
}
input type="submit" value=(t!("save"));
}
a role="button" class="danger" href=(format!("/s/{id}/{code}/remove-left/{}", team.id))
onclick=(format!("return confirm('{}');", t!("confirm_station_cancel_team_rated"))) {
(t!("team_done_step_back"))
}
}
}
}
} @else {
(t!("no_teams_rated_yet"))
}
} @else {
(t!("no_teams_rated_yet"))
}
};
};
let use_map = station.lat.is_some() && station.lng.is_some();
partials::page(content, session, use_map).await
PageBuilder::new(content, session)
.set_leaflet(use_map)
.markup()
.await
}
async fn ready(
@@ -355,12 +389,14 @@ async fn ready(
session: Session,
axum::extract::Path((id, code)): axum::extract::Path<(i64, String)>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::login(db, id, &code).await else {
er!(session, t!("invalid_rating_code"));
return Redirect::to("/s/{id}/{code}");
};
station.switch_ready(&db).await;
station.switch_ready(db).await;
suc!(session, t!("succ_change"));
Redirect::to(&format!("/s/{id}/{code}"))
@@ -376,16 +412,18 @@ async fn new_waiting(
axum::extract::Path((id, code)): axum::extract::Path<(i64, String)>,
Form(form): Form<NewWaitingForm>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::login(db, id, &code).await else {
er!(session, t!("invalid_rating_code"));
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, form.team_id).await else {
let Some(team) = Team::find_by_id(db, form.team_id).await else {
er!(session, t!("nonexisting_team", id = form.team_id));
return Redirect::to("/s/{id}/{code}");
};
match station.new_team_waiting(&db, &team).await {
match station.new_team_waiting(db, &team).await {
Ok(()) => suc!(session, t!("team_added_to_waiting", team = team.name)),
Err(e) => err!(session, "{e}"),
}
@@ -398,17 +436,19 @@ async fn remove_waiting(
session: Session,
axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::login(db, id, &code).await else {
er!(session, t!("invalid_rating_code"));
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, team_id).await else {
let Some(team) = Team::find_by_id(db, team_id).await else {
er!(session, t!("nonexisting_team", id = team_id));
return Redirect::to("/s/{id}/{code}");
};
match station.remove_team_waiting(&db, &team).await {
match station.remove_team_waiting(db, &team).await {
Ok(()) => suc!(session, t!("team_removed_from_waiting", team = team.name)),
Err(e) => err!(session, "{e}"),
}
@@ -421,17 +461,18 @@ async fn team_starting(
session: Session,
axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::login(&mut *db, id, &code).await else {
er!(session, t!("invalid_rating_code"));
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, team_id).await else {
let Some(team) = Team::find_by_id(&mut *db, team_id).await else {
er!(session, t!("nonexisting_team", id = team_id));
return Redirect::to("/s/{id}/{code}");
};
match station.team_starting(&db, &team).await {
match station.team_starting(&mut *db, &team).await {
Ok(()) => suc!(session, t!("team_added_to_active", team = team.name)),
Err(e) => err!(session, "{e}"),
}
@@ -444,17 +485,19 @@ async fn remove_doing(
session: Session,
axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::login(db, id, &code).await else {
er!(session, t!("invalid_rating_code"));
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, team_id).await else {
let Some(team) = Team::find_by_id(db, team_id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/s/{id}/{code}");
};
match station.remove_team_doing(&db, &team).await {
match station.remove_team_doing(db, &team).await {
Ok(()) => suc!(session, t!("team_removed_from_active", team = team.name)),
Err(e) => err!(session, "{e}"),
}
@@ -467,17 +510,19 @@ async fn team_finished(
session: Session,
axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::login(db, id, &code).await else {
er!(session, t!("invalid_rating_code"));
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, team_id).await else {
let Some(team) = Team::find_by_id(db, team_id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/s/{id}/{code}");
};
match station.team_finished(&db, &team).await {
match station.team_finished(db, &team).await {
Ok(()) => suc!(session, t!("team_added_to_finished", team = team.name)),
Err(e) => err!(session, "{e}"),
}
@@ -490,17 +535,19 @@ async fn remove_left(
session: Session,
axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::login(db, id, &code).await else {
er!(session, t!("invalid_rating_code"));
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, team_id).await else {
let Some(team) = Team::find_by_id(db, team_id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/s/{id}/{code}");
};
match station.remove_team_left(&db, &team).await {
match station.remove_team_left(db, &team).await {
Ok(()) => suc!(session, t!("team_removed_from_finished", team = team.name)),
Err(e) => err!(session, "{e}"),
}
@@ -519,17 +566,18 @@ async fn team_update(
axum::extract::Path((id, code, team_id)): axum::extract::Path<(i64, String, i64)>,
Form(form): Form<TeamUpdateForm>,
) -> impl IntoResponse {
let Some(station) = Station::login(&db, id, &code).await else {
let db = &mut *db.acquire().await.unwrap();
let Some(station) = Station::login(db, id, &code).await else {
er!(session, t!("invalid_rating_code"));
return Redirect::to("/s/{id}/{code}");
};
let Some(team) = Team::find_by_id(&db, team_id).await else {
let Some(team) = Team::find_by_id(db, team_id).await else {
er!(session, t!("nonexisting_team", id = id));
return Redirect::to("/s/{id}/{code}");
};
match station
.team_update(&db, &team, form.points, form.notes)
.team_update(db, &team, form.points, form.notes)
.await
{
Ok(()) => suc!(session, t!("rating_updated", team = team.name)),
@@ -554,7 +602,7 @@ pub(super) fn routes() -> Router<AppState> {
#[cfg(test)]
mod test {
use crate::{router, testdb, Station};
use crate::{Station, router, testdb};
use sqlx::SqlitePool;
@@ -563,8 +611,9 @@ mod test {
#[sqlx::test]
async fn test_wrong_station() {
let pool = testdb!();
let db = &mut *pool.acquire().await.unwrap();
Station::create(&pool, "Teststation").await.unwrap();
Station::create(db, "Teststation").await.unwrap();
let server = TestServer::new(router(pool)).unwrap();
@@ -576,9 +625,10 @@ mod test {
#[sqlx::test]
async fn test_correct_station() {
let pool = testdb!();
let db = &mut *pool.acquire().await.unwrap();
Station::create(&pool, "42-Station").await.unwrap();
let stations = Station::all(&pool).await;
Station::create(db, "42-Station").await.unwrap();
let stations = Station::all(db).await;
let station = stations.last().unwrap();
let server = TestServer::new(router(pool)).unwrap();

52
watch.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Function to kill the previous cargo process
kill_cargo() {
if [ -n "$pid" ] && ps -p $pid > /dev/null; then
echo "Terminating previous cargo process..."
pkill -P $pid
kill -TERM $pid 2>/dev/null
wait $pid 2>/dev/null
fi
}
# Cleanup on script exit
trap 'kill_cargo; echo "Monitor stopped."; exit' INT TERM EXIT
echo "Monitoring directory for changes..."
echo "Press Ctrl+C to exit"
# Initial run
cargo r &
pid=$!
# Check if we're on macOS
if [[ "$(uname)" == "Darwin" ]]; then
# Use fswatch for macOS
fswatch -o src/ assets/ | while read; do
echo -e "\n--- Changes detected, restarting cargo r ---"
# Kill the previous cargo process
kill_cargo
# Start a new cargo process
cargo r &
pid=$!
done
else
# Use inotifywait for Linux
while true; do
# Wait for changes in the directory
inotifywait -q -e modify,create,delete,move -r src/ assets/ 2>/dev/null
echo -e "\n--- Changes detected, restarting cargo r ---"
# Kill the previous cargo process
kill_cargo
# Start a new cargo process
cargo r &
pid=$!
done
fi