Compare commits
	
		
			1416 Commits
		
	
	
		
			24667e56a4
			...
			mb-npm-cho
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 397092bff5 | ||
| 627a515a42 | |||
| 1c6421139d | |||
| f4509b8504 | |||
| b53b8b6f0b | |||
| 3f76e5be78 | |||
| a14a76399e | |||
| b15050cd63 | |||
| 2907ed5caf | |||
| bc8cd88af4 | |||
| 539d299c1a | |||
| f0936c7784 | |||
| 59478a5ee1 | |||
| 2f5d483bff | |||
| 7be9339645 | |||
| 837d0febdf | |||
| 51c7cf28f8 | |||
| 80eca1a3b2 | |||
| d1341006f7 | |||
| a534568a39 | |||
| b4c04cbdd8 | |||
| 1f0bfb04e4 | |||
| 86b8d3a30d | |||
| da7a303efb | |||
| 0a31410ca5 | |||
| f793cb4a9a | |||
| d3b2d78f9f | |||
| 155adce2e9 | |||
| 9548cb4f0b | |||
| c42713b86e | |||
| d5a92d8f79 | |||
| aa3df2a294 | |||
| 7a2743046d | |||
| 7027145a9a | |||
| 782d68cd03 | |||
| c6a2b529c3 | |||
| b0b2ad2148 | |||
| 09e06017c2 | |||
| 34ade37f2d | |||
| 138c0598e6 | |||
| 5b75ff5d38 | |||
| a42e0b3ed3 | |||
| 743359904a | |||
| f46ddf249a | |||
| bc6244bc03 | |||
|   | 47e3d1b5b3 | ||
| d6b9a2f11b | |||
| 4e04b2b082 | |||
| 73a7abd418 | |||
| abd58766d8 | |||
| 58a357fdb5 | |||
| 5202060e2f | |||
| 129c90f1aa | |||
| 64b3e63e15 | |||
| e631ee67b5 | |||
| 63edc3d249 | |||
| 61016f284c | |||
| 18348e68f3 | |||
| 7730de8ada | |||
| 066f47d99d | |||
| f7bb394236 | |||
| b3033fbc72 | |||
| c246e06e69 | |||
| 0dca843d6a | |||
| e334cea0e2 | |||
| 7e10253e2e | |||
| dc75e0145a | |||
| 1e2dc4ccbc | |||
| 4bcba1ec47 | |||
| 452a1e1b97 | |||
| 412b733e30 | |||
| 965cba0919 | |||
| dae8632a34 | |||
| 55bdca4238 | |||
| bf7dab235c | |||
| bb3e8dadb7 | |||
| ed6d05eb9e | |||
| edcdc74c1c | |||
| 3ab1dbd1f1 | |||
| 6e9367fa07 | |||
| e4a8caf632 | |||
| cd39f1a694 | |||
| 396fc8e659 | |||
| f86d2f6307 | |||
| 1ecde79593 | |||
| e8b8ba393f | |||
| 3801c7ce8c | |||
| 816257d4be | |||
| 23399b7757 | |||
| 0c5812f725 | |||
| d88a35bb82 | |||
| 52abcbb3fb | |||
| 29777cdc36 | |||
| 22b9a2e324 | |||
| a97d515f03 | |||
|   | 72fc3ed91e | ||
| b079eafc3d | |||
| 6e1bfe8635 | |||
| ce28f93d65 | |||
| bf3a4c686a | |||
| 5fb9e0fbba | |||
|   | f58e7d1307 | ||
| 374fed9e3b | |||
|   | b9f2382cba | ||
|   | aab3a15488 | ||
| 83b93fba09 | |||
| 3b5ff70d1d | |||
| 2af9ac20b1 | |||
|   | 5331ac71fa | ||
| 6098aedb74 | |||
|   | af2e7cb557 | ||
|   | 81b99ef414 | ||
| 17513bbc38 | |||
| c1cecf3b20 | |||
| 8e40e563c6 | |||
|   | bb78441cc4 | ||
|   | abcf46281b | ||
| c460494be8 | |||
| e5560ba536 | |||
|   | b7094bff06 | ||
|   | 6b8b4ba1d2 | ||
| 03f76b1ae5 | |||
| 1864ea260c | |||
|   | 35a5a55140 | ||
| 9a4dcc0b9d | |||
| 43074b3bd7 | |||
| 933e407c64 | |||
| d9e86bf43b | |||
| ebbb4fe3da | |||
| 9178476013 | |||
| e853381bd7 | |||
| 8777ccb341 | |||
| 6362fed909 | |||
| 905178e60d | |||
| cd52e76b61 | |||
| 151c97aabc | |||
| e360c4f06b | |||
| d50501b362 | |||
| e6895c8cf1 | |||
|   | 7bd863ddf1 | ||
| afc32cc41e | |||
|   | 9dfcb4e2c4 | ||
|   | 149b6afbf5 | ||
| f9a53a703b | |||
| c8be0c2c22 | |||
|   | 6c8667973d | ||
|   | 07f7dbca12 | ||
| 3f29400831 | |||
| 46981c3311 | |||
|   | b08fcdc05b | ||
|   | a60606bbe4 | ||
|   | 540031cab4 | ||
|   | a93c420630 | ||
| 8dc55a7aad | |||
|   | 5b78afff63 | ||
| 25c3a28c7d | |||
| 2bb42c3f6a | |||
|   | d7dec5da29 | ||
|   | ffe1745b65 | ||
| f374016207 | |||
| 6d329eb980 | |||
| cfd3c6200f | |||
|   | 4d95282e58 | ||
|   | 540c122248 | ||
| 9aab07422d | |||
|   | c47b1988b2 | ||
| a73bbf059f | |||
| 1f17a10133 | |||
| 73c79fb008 | |||
| c2b57583cf | |||
| d2000f4699 | |||
| c4ed766c4d | |||
| 68cf563964 | |||
| b2e07653e6 | |||
| 19887e133d | |||
| d2914f9287 | |||
| c8d5c633d7 | |||
| 90087843ad | |||
| 5e588f209f | |||
| c99c83d9fb | |||
| ef7beccdf2 | |||
| 34850321b7 | |||
| b0168b798c | |||
| 7604678d4a | |||
| 876451fc02 | |||
| 80a70fb812 | |||
| 8059e5b8fc | |||
| f1423b8713 | |||
| 47b46cf41d | |||
| a484785027 | |||
| 4134b2a65b | |||
| f289c7b6d7 | |||
| 0f1bc39b4b | |||
| 3eb84ce46b | |||
| c8b01bcd03 | |||
| 9b31ea981a | |||
| b4a22820e7 | |||
| af0aad2a99 | |||
| fe6db2cdd5 | |||
| 5cd75ed8c8 | |||
| 1ed0d8fd32 | |||
| 10740f988d | |||
| f98963a28a | |||
| 37b6ea6057 | |||
| 06c5e5a9d1 | |||
| 0059dfe96f | |||
| e01afa7d74 | |||
| 2458b0a100 | |||
| 36245fd0f7 | |||
| 85bec7f591 | |||
| 7e0b30f058 | |||
| b0a2d3d539 | |||
| ac5f9d253d | |||
| 8340e8b33f | |||
| db429b6fe3 | |||
| cf90ab6e1a | |||
| 3b25143a08 | |||
| 4ce9a573fe | |||
| 78aafe4d41 | |||
| dc2ee38aa0 | |||
| 2b79df8e42 | |||
| 43c0b9ffc1 | |||
| 588520914c | |||
| 819c4bb31b | |||
| 0c425f7a8e | |||
| 5da4b592ea | |||
| 9a30ce0afb | |||
| 21b33566bc | |||
| eb9dd3f864 | |||
| 29f2cadb99 | |||
| ca3de1123b | |||
| f42bf5ea3a | |||
| dfb53291b7 | |||
| 1c628f40ed | |||
| 9fcd5a1a8f | |||
| 2f4874321f | |||
| 6c83d00c2c | |||
| 418bcc3143 | |||
| 35dffdd8f0 | |||
| b9368e6c64 | |||
| f0a86a7186 | |||
| b419004949 | |||
| 18d9f51354 | |||
| dfe39cdd13 | |||
| 94938fb4ea | |||
| 3a1ff3189d | |||
| a89d78160d | |||
| 2368f03761 | |||
| 86e5482c6f | |||
| 08283dd392 | |||
| a7d33548d4 | |||
| 2003ff0e59 | |||
| 1471ccad2c | |||
| 0f345862ee | |||
| d1102a7b04 | |||
| faa8b4e767 | |||
| bed4b4eb44 | |||
| 856e3b2cff | |||
| 1ca0de1dd3 | |||
| 40bc866b3e | |||
| 13c9c5a708 | |||
| d4b99f67ac | |||
| b189c4f203 | |||
| 4820f8c798 | |||
| 7b2c47613c | |||
| 0a81489fa3 | |||
| 31a7643d96 | |||
| 83796a9824 | |||
| 227c751f60 | |||
| ee5a1202fd | |||
| 7f824ccd2f | |||
| e3d8a47af0 | |||
| 9f35920f3c | |||
| 58e3140376 | |||
| b86043bba5 | |||
| e141bcfc37 | |||
| 9b9cf98473 | |||
| eaa35fb46c | |||
| 86470da184 | |||
| ae61564ad4 | |||
| 82a54bdea1 | |||
| 2a37bcbec5 | |||
| a2a39103e0 | |||
| c96cc4b38f | |||
| 3008264261 | |||
|   | 11025738bb | ||
|   | 31fc0605d9 | ||
|   | 1fdec59f77 | ||
| da793fec2d | |||
| 8917629613 | |||
| 2a2c2ce9dc | |||
| d82bd3ebeb | |||
| 10f6268e56 | |||
| f0ea5823ba | |||
| 32800b1897 | |||
| 3406b66f41 | |||
| cfd8b12556 | |||
| 2ffddda960 | |||
| c7c92c83fb | |||
| 5cc77c39ff | |||
| 80d8857c6b | |||
| 78403e4ec5 | |||
| 4dd656f566 | |||
| 23a1a118a3 | |||
| b281201906 | |||
| 4d58bd3cae | |||
| 67e790a82e | |||
| 63bf1015cc | |||
| 352dad8e6c | |||
| 4f42e7cb8c | |||
| c6aa25fe0e | |||
| 9ba848cbab | |||
| 9047459d6c | |||
| 87de3859a2 | |||
| b8aaf5ba2e | |||
| de9ea9405e | |||
| 3bd229554b | |||
| f9c9f7c523 | |||
| 0dfceec737 | |||
| e5fec411f3 | |||
| ac67c6cfdb | |||
| a90c4fc07e | |||
| 52b960cec7 | |||
| f7d109f1b2 | |||
| 63505722f9 | |||
| d21272d4bb | |||
| 97dd7794fb | |||
| cfe99c2f2a | |||
| 2a3f846c5c | |||
| af4163a065 | |||
| 8a9047b3c3 | |||
| ebc7c32351 | |||
| 1a850535ed | |||
| 99bbb2b088 | |||
| b31209a97a | |||
|   | be4f302a4c | ||
| e5c2bec145 | |||
| 0ebcd5a284 | |||
| 6237340f72 | |||
|   | 5b013fe389 | ||
|   | 022ec6bd5b | ||
| 09d4c0abe4 | |||
| 5448558085 | |||
| 3232a03d75 | |||
| dceb57e370 | |||
| f68928df00 | |||
| d3bb050534 | |||
| 32b4131aae | |||
| 1d34cb5794 | |||
| 8a4d98a90f | |||
|   | 213e9faad4 | ||
| a9a8207813 | |||
| b7b2385264 | |||
| b560233acf | |||
| d7187a7589 | |||
| e61b16c389 | |||
| 2ac8a3155c | |||
| d01e6ea30b | |||
| f38ca09eb7 | |||
| 1ad4c31979 | |||
| 5e413d2d72 | |||
| 0f8e1158b9 | |||
| af10399797 | |||
| 6344ba720d | |||
| 2485f910fd | |||
|   | 4550be5b2a | ||
| 9cc0df3a62 | |||
| 4b1dceb08a | |||
| 267135bf73 | |||
| b9c5a87ee7 | |||
| cb819c16a3 | |||
| a3d05d93bd | |||
| a249857331 | |||
| 08a48cb4d2 | |||
| 4a200327a6 | |||
| f283240876 | |||
| 9c36da32bd | |||
| 257cdcf823 | |||
| 671a0fc89f | |||
| 77444d25ae | |||
| 0ad62e2ece | |||
| 85c759d9b7 | |||
| a683af00d0 | |||
| 5a66211353 | |||
| a07ff1d993 | |||
| b41457d30e | |||
| 766886d857 | |||
| 4408100e49 | |||
| 32b8aa0145 | |||
| 38703321e8 | |||
| 1f0b74554f | |||
| 9d3b1d522b | |||
| ec1c717341 | |||
| 656c0b99ea | |||
| 85b39d472c | |||
| 22bb79bfbd | |||
| 50f410d9fd | |||
| f574ae14db | |||
| eba4b77983 | |||
| 5c8966f34c | |||
| 88c6469154 | |||
| e33074f540 | |||
| 83d266b3e0 | |||
| 768a96345e | |||
| c8cfcd619f | |||
| 980bcff1d9 | |||
| d7eaa14e55 | |||
| 8e1b1c1aac | |||
| c15ed6e9a9 | |||
| d5e6371b89 | |||
| eb49a829c6 | |||
| c7b5b7e39d | |||
| d76ce744f1 | |||
| 61ec8bddb8 | |||
|   | 07e69d7833 | ||
| 35866c216b | |||
| c8b60fa518 | |||
| 7e20120a02 | |||
| 2c7b8d9393 | |||
| d2cebc7c67 | |||
| 0c72dc9e4c | |||
| b4ec423b81 | |||
| 13a372252d | |||
| fa364d0be9 | |||
| d0b0888a9b | |||
| 4b0460aeee | |||
| 6d18fe0219 | |||
| fbad517b56 | |||
| f405a3ca15 | |||
| d9e8f6170c | |||
| ab52bf4e96 | |||
| a68c423fdb | |||
| 779e1bbfb9 | |||
| c87baaed07 | |||
| de567eedec | |||
| 01fdfcae99 | |||
| f801606899 | |||
| 3272833b2d | |||
| f71c83dc3f | |||
| b9344a42a0 | |||
| 4d4c680e59 | |||
| f7f2f2ec38 | |||
| 984ffc69e4 | |||
| 935f0dd1dd | |||
| aa8d9639fe | |||
| 2da249b57d | |||
| b0123e2b42 | |||
| 94af469b33 | |||
| a53c0ede9c | |||
| 43377fff8e | |||
| 84789cf79d | |||
| eea61ee6ca | |||
| a6a143f238 | |||
| bdde326f03 | |||
| 0cc72f17a1 | |||
| aca4fc82e4 | |||
| 318fe13666 | |||
| c2f7583b38 | |||
| 96fd9c8ed6 | |||
| dd487853bc | |||
| 48a817e9ca | |||
| 17d97a5e25 | |||
| 10b55387a4 | |||
| 44ccbea376 | |||
| b792088593 | |||
| 461819923d | |||
| b6efe5170b | |||
|   | 4581ec4abc | ||
| ca8cd4612d | |||
| c99686f72f | |||
|   | 2cdfacab53 | ||
| 6d3c8bffa3 | |||
| cecd5e8106 | |||
| 615898ead4 | |||
|   | 2663772651 | ||
|   | b7e3c882d8 | ||
| 56f5b6e8db | |||
| 102cc90a23 | |||
| b429998775 | |||
| 5727c0c9ce | |||
| ece64868fe | |||
| 1225aeac94 | |||
| 8408148ead | |||
| d0d7da7996 | |||
| 14bfb695d9 | |||
| 1da9412904 | |||
| abe256af5d | |||
| 8666b014f2 | |||
|   | af8637d2b7 | ||
| c7d3435f4d | |||
| 3e14b61ce5 | |||
| 14d546bdc3 | |||
| 81dbbeac00 | |||
| d404636261 | |||
| f116b97072 | |||
| 0eaf3aa92c | |||
| 9cbbe10e12 | |||
| d6c6f8800e | |||
| 582cfd60c8 | |||
| 8152822efc | |||
| 5f21148b3c | |||
| a356e7bd08 | |||
| b40850626b | |||
| d6f354bf34 | |||
| 6df24f0f22 | |||
| f41b5e9fef | |||
| b6d58077f6 | |||
| 1ce3ef9082 | |||
| 3b3374b0cc | |||
| 242f4ee266 | |||
| 0689e75626 | |||
| 7ab6d95e23 | |||
| f38d506fe4 | |||
| 96dcf2c4ae | |||
| c4ca148b54 | |||
| a441d99b5e | |||
| 76022a1f0e | |||
| 122e5daab2 | |||
| 63e9597c06 | |||
| c7adea88ed | |||
| 1202b0afec | |||
| 3c6e938949 | |||
| 1e9dfa3e70 | |||
| 2b74b47d06 | |||
| bb2771b412 | |||
| 2dc145e697 | |||
| 142169d638 | |||
| 6b88927880 | |||
| be94707228 | |||
| 49b2305cdb | |||
| 99a49dbec9 | |||
| 0645103466 | |||
| f968d5d03b | |||
| 6ba97e2631 | |||
| a52ee97a80 | |||
| ae1091c9a2 | |||
| 0b46cbf8db | |||
| be6d3229a4 | |||
| afc23ae519 | |||
| fdd9c3bdff | |||
| ae6c129fd3 | |||
| 396aa204a4 | |||
| 4290010cc6 | |||
| bbe4949203 | |||
| 94130f9230 | |||
| 14dbe748a3 | |||
| 010627c91d | |||
| 36276e5415 | |||
| b827bd6996 | |||
| 4bb0e54635 | |||
| 83a2c7ab92 | |||
| a518023892 | |||
| 3f06e91e24 | |||
| 2b4345ba77 | |||
| 412c45a9df | |||
| d971c1504c | |||
| cf9b79e56e | |||
| 5d01d18e70 | |||
| 1e96a2d6e1 | |||
| ff81ab0246 | |||
| 202e128c98 | |||
| ecb347c204 | |||
| 2bc426be52 | |||
| 0a130709c7 | |||
| 6e8a5927a6 | |||
| a31bacb3e1 | |||
| 93c8316543 | |||
| 6e72e2a753 | |||
| c847c3300f | |||
| 116c7523d2 | |||
| eb15421d08 | |||
| 818cf0d40b | |||
| ed9d93410e | |||
| c162e0a66f | |||
| e965d33a7b | |||
| 3d77a2325c | |||
| afb6af8ece | |||
| 799e94a50f | |||
| c41dc0853a | |||
| 74edcfa119 | |||
| cc7bd3a416 | |||
| bfee85a963 | |||
| f55f45d960 | |||
| c68593a67d | |||
| 20da86f69e | |||
| 8588e1f71b | |||
| 8efb3aea2c | |||
| 83aa9bc84c | |||
| 6171bb0f85 | |||
| e040764902 | |||
| eeab4c167b | |||
| 25161fc8e9 | |||
| dea53d8396 | |||
| 80ac131fb2 | |||
| 8e65a6540d | |||
| d7e5731753 | |||
| 1a4d5ac569 | |||
| 668fc5e295 | |||
| 4f9778eccf | |||
| 09d4c5d958 | |||
| 11dd978135 | |||
| d7d0a3fedd | |||
| e4333a05d7 | |||
| f687e18195 | |||
| a4f72d746c | |||
| e55f380c4f | |||
| bb502a4561 | |||
| 1340639f91 | |||
| ce28c95853 | |||
| 2c3f69a562 | |||
| 0bf7094770 | |||
| a75c892cfb | |||
| f71ab634d7 | |||
| 0c770f6ddc | |||
| 6b71449183 | |||
| d59b3f4345 | |||
| 7e41cd3f73 | |||
| 2a8c339dcd | |||
| 0dd10e1dd6 | |||
| 2d2e44126a | |||
| def8028446 | |||
| db318c23cd | |||
| 4555391dd3 | |||
| 23aa6aa0f8 | |||
| a682d1e6ce | |||
| 8aca437eb3 | |||
| cd1bf12e68 | |||
| 5f7591f52a | |||
| 127d9784ad | |||
| bf4ea502d3 | |||
| c13dfdaa77 | |||
| 26aa222bc6 | |||
| 0bc9e11b9a | |||
| 2fdcab9030 | |||
| 7689a39ac5 | |||
| b43682ac39 | |||
| 8d7a1c707d | |||
| 958dda9f52 | |||
| 1eea8c9662 | |||
| b4b922222c | |||
| 84e76e8d65 | |||
| bdfcc6bc0a | |||
| afa88b9529 | |||
| 4229a4e021 | |||
| 6cd555298d | |||
| f6207e2994 | |||
| 4da996251a | |||
| c44c0d8505 | |||
| aa9568f326 | |||
| 1db09cd8ac | |||
| 59ef93d6fa | |||
| 4a3ee5b551 | |||
| c73b3e94c3 | |||
| 4969a0d90a | |||
| 3efcd99bbc | |||
| 4f0f509ad6 | |||
| 8112f1ed2a | |||
| b1252e8d5c | |||
| 9cb9cfe2a1 | |||
| a62fd116ea | |||
| 622bc700f3 | |||
| 2540a3dc7c | |||
| 0e5fd25e61 | |||
| 72b86d4dad | |||
|   | 16fbeea81b | ||
|   | bd6fbe772e | ||
| 647970e1fc | |||
| 1e1c1bb6d9 | |||
| 088fe98995 | |||
| 4237fafdff | |||
| 6b24008c17 | |||
| 4fbd3c7717 | |||
| ce8a095b31 | |||
| 6c191cf59e | |||
| b0698e70a4 | |||
| 6f7283f754 | |||
| 323f721fc0 | |||
| d0bcf1f384 | |||
| bd7cd0020e | |||
| 22bfe48d18 | |||
| c379a6ca79 | |||
| 3543ffe9e1 | |||
| 1d6770f11b | |||
| 1ad6509568 | |||
| 705f2ddc52 | |||
|   | dba1e08c5d | ||
| 1dc0c9c0e1 | |||
| 45004567ed | |||
| 3dff956544 | |||
| 79f8efc34b | |||
| 5c31fac230 | |||
| 3e983e05f9 | |||
| 8f91cc4e88 | |||
| c55f9743aa | |||
| 76290a64ae | |||
| 4b48fbaa82 | |||
| d25cd491d0 | |||
| def8affb5f | |||
| dfa7be9928 | |||
| 03a467270d | |||
| 2159696112 | |||
| 1c04462c30 | |||
| 7af53203f8 | |||
| b2393eb6ec | |||
| a5e82851ba | |||
| 80725e223b | |||
| 39bde35864 | |||
| 70be6726db | |||
| 5f301324ee | |||
| 9558965e8f | |||
| 08fc324cc6 | |||
| 5f4d8982a8 | |||
| 0e2ef9e256 | |||
| df007524ed | |||
| 1dc91f4f28 | |||
| 5720767af3 | |||
| f56da43723 | |||
| 9dc1ec6fa0 | |||
| c9b67f5790 | |||
| 1215bdbd84 | |||
| 16687e39ab | |||
| 957c474389 | |||
| f7aed68423 | |||
| 01637d0800 | |||
| 734490efe7 | |||
| 0a77011170 | |||
| 980fcc0c0c | |||
| dea0c65da3 | |||
| 31bf38f112 | |||
| 6b29907596 | |||
| 84f23e6e55 | |||
| 7b17c30ce2 | |||
| ec4068e499 | |||
| 36193e3a64 | |||
| fca19745f8 | |||
| bb48ddb3de | |||
| 34b098fa2a | |||
| df1a06531f | |||
| c6e3458588 | |||
| 7b499fb457 | |||
| d00570ff2f | |||
| a0528c1c65 | |||
| bd63f2c386 | |||
| e1b78b2725 | |||
| fa14cfbf83 | |||
| 5f6cb9a12b | |||
| 5e4d708884 | |||
| 1cac70cabb | |||
| 09cb8ebfa9 | |||
| 6e41758104 | |||
| ab88ce3230 | |||
| 30a6bc7109 | |||
| 99409f9407 | |||
| c916381fb0 | |||
| 7eff2a948a | |||
| 243838fd44 | |||
| 7d44204533 | |||
| 2de4c86c26 | |||
| 2889d40d55 | |||
| 145892104b | |||
| e8d4672176 | |||
| 6c0f0e6b04 | |||
| 1bd643f6f4 | |||
| 562c32939d | |||
| 3c0b8e5114 | |||
| e004d81ca1 | |||
| 6d5ff5404b | |||
| a8c0282918 | |||
| 41b5aff329 | |||
| 9973913af6 | |||
| 7055c999e8 | |||
| 76c8456380 | |||
| c0d766832e | |||
| f88c0be781 | |||
| 95f43a73cf | |||
| 91fa2a7762 | |||
| 82aa94c024 | |||
| aaf09208f3 | |||
| 3d340bf803 | |||
| 86f7ca7065 | |||
| e325e0478a | |||
| ae096ad602 | |||
| 0298617fc9 | |||
| 02ff89ba34 | |||
| 25d8b1ea7c | |||
| 47a543fa64 | |||
| 4e8fd84134 | |||
| 410cd05acc | |||
| 544267a037 | |||
| 97b0ae83a9 | |||
| 972811c2cf | |||
| f6d8c07c08 | |||
| da56723909 | |||
| e22d2d718e | |||
| 603aed8394 | |||
| f22d3b65be | |||
| b55f122f1d | |||
| 446e48020e | |||
| 93e3e0ef5c | |||
| 8f5cc70981 | |||
| d27489d714 | |||
| 71c228f202 | |||
| 3ebde6afce | |||
| c340d1a916 | |||
| a797180b0d | |||
| 9704893329 | |||
| e7732b9e96 | |||
| 05c4c4f6a2 | |||
| daf9460bf7 | |||
| 97dc9308bc | |||
| db5e0873a6 | |||
| 40f97f18a9 | |||
| 6b911f242a | |||
| f4ce748a74 | |||
| d4ffd8850e | |||
| c93556a5ab | |||
| 96ce46d39c | |||
| 412ec27927 | |||
| a1c7e4c690 | |||
| 1f0de7abf4 | |||
| 64f3596132 | |||
| 3b75f38dca | |||
| 10f2e3016a | |||
| 1069e29cf0 | |||
| b4967b54e9 | |||
| 1285c3bc28 | |||
| b6c9cb0b99 | |||
| 29fabb04b0 | |||
| 830aa58e7b | |||
| 1b6aec8d89 | |||
| 1bf1cc9c68 | |||
| 02e1f77f65 | |||
| d819462b0d | |||
| 387acdbd09 | |||
| b36144832a | |||
| fc49e6c977 | |||
| b8463122d6 | |||
| 86db4cb2f4 | |||
| 82865799ce | |||
| 76f08905ab | |||
| 8dd878b492 | |||
| b405cf9936 | |||
| 01c2f0c4a3 | |||
| 0318d1dfb2 | |||
| 261753c6b4 | |||
| d0038677ca | |||
| 4bd91b2a7e | |||
| 17f4291af0 | |||
| 073f5aed0c | |||
| 3097d99e00 | |||
| 0eac1a66f9 | |||
| f034f80794 | |||
| 57c9d532c8 | |||
| b774acf9ae | |||
| 20cc085562 | |||
| 862ec5624a | |||
| 77a90a8086 | |||
| ca5a932ae5 | |||
| 626be1c9fb | |||
| 7e2c185c03 | |||
| 65068e44a5 | |||
| 133a517a2e | |||
| 3d45310c73 | |||
| e4ef1f1584 | |||
| ebb4fe84bb | |||
| 2bf517ccd8 | |||
| 1908f61268 | |||
| ac3301e97b | |||
| 18faf4a72d | |||
| e3c30e010b | |||
| d9aa7cafe1 | |||
| 97b0ce65f9 | |||
| a465dfcce5 | |||
| 6371366a96 | |||
| 0952bf7878 | |||
| fa0dc5b544 | |||
| c3c7ecec98 | |||
| a0d53366e0 | |||
| b69eded21d | |||
| bd68bfc668 | |||
| 7355d9d69b | |||
| 5602ad2681 | |||
| 6813d75db5 | |||
| b4023c1ea8 | |||
| 45b51f4698 | |||
| 31fda6bee9 | |||
| e728c4dbea | |||
| 1d9adf071f | |||
| fcb4d65d32 | |||
| 96036b180b | |||
| 8c563a9c36 | |||
| b70929c5ce | |||
| c98f33e138 | |||
| 17d1ee3566 | |||
| a75ba765df | |||
| 1503544a73 | |||
| 0b350d344d | |||
| 67c8431157 | |||
| d6ecd87593 | |||
| 03073965a1 | |||
| 2189b082c0 | |||
| 6d4bc81720 | |||
| 25fe4c23ef | |||
| 2fdfddbd2e | |||
| dea6520aa9 | |||
| f8e0cd2d5b | |||
| 9fda9cbde2 | |||
| 74b24569dd | |||
| 3a39315a01 | |||
| 3323807e46 | |||
| 65d51c2cc2 | |||
| 8773bbb9d1 | |||
| 89ac4974db | |||
| 7dfdc55adb | |||
| e4f1528b15 | |||
| c449e878f0 | |||
| 311e611d5f | |||
| 8e5661b2f3 | |||
| 79976b751f | |||
| 139acb2ec5 | |||
| 5dcd7bc745 | |||
| ebdfe37bec | |||
| 08fe779403 | |||
| 9ca510b892 | |||
| d01895df90 | |||
| f08d9728eb | |||
| c27a2ad15e | |||
| 18047d16bf | |||
| 1866034431 | |||
| e2306e890d | |||
| 688ce4c6fc | |||
| 46f8ca230a | |||
| fcf86c7ff1 | |||
| 123b4bd39f | |||
| 7a8b79ccef | |||
| 5eadfd42bb | |||
| 87307378c6 | |||
| 8b42bdce0c | |||
| 5bb0cb4112 | |||
| 237377dc05 | |||
| fc7ca28f56 | |||
| 6a18d7435a | |||
| a42191715d | |||
| 43e073c54e | |||
| 14e7616b88 | |||
| 8e03c935a5 | |||
| 0560ed7a6a | |||
| 07b197cc63 | |||
| a1ebd59f22 | |||
| aac7444896 | |||
| d646996c80 | |||
| 1412852087 | |||
| 5f5f215aad | |||
| 7a59c67763 | |||
| a2b0146d6d | |||
| 7f813823f3 | |||
| d91baeb7bc | |||
| b5cdc8827a | |||
| b6b88ead37 | |||
| 6b3851bfe4 | |||
| a0dbd0f490 | |||
| 3f80cb498c | |||
| 7ba5070df3 | |||
| cc1a7106cb | |||
| d4218289f0 | |||
| 349a9f843c | |||
| 982bb3b5c8 | |||
| b4f38089ee | |||
| e22dd8eb51 | |||
| ac9c4b256e | |||
| cfec4ef8b3 | |||
| a424e79cba | |||
|   | be24001b4b | ||
|   | d9885f9bba | ||
|   | 6c2e0669d5 | ||
|   | e556b1375d | ||
|   | 23a623bdc9 | ||
|   | b1d48a5154 | ||
| 61261c9816 | |||
| f993eae27f | |||
| f1ee266288 | |||
| 9aefc814a7 | |||
| 548c025fbc | |||
| 8f44fdadf2 | |||
| 3abff08f61 | |||
| 32dee2c320 | |||
| 3c67e0deeb | |||
| ef21e719c8 | |||
| 4a7cd2f085 | |||
| 27b124cce5 | |||
| 254e8b7063 | |||
| 31d45d6ab4 | |||
| c122dea6a9 | |||
| 2ad5f0883c | |||
| 8f2d1ef6f4 | |||
| d6214d5369 | |||
| 74ededf913 | |||
| 8ffed75251 | |||
|   | 73a6f1d58c | ||
|   | 2540e49a31 | ||
| d612cf01b6 | |||
| 44c1b1bb72 | |||
| e3895f3c9c | |||
|   | e00142926e | ||
|   | 4c6ef71a17 | ||
| 7b32b9bbcb | |||
| 9def90daa7 | |||
|   | c528b8831a | ||
|   | 30756ad4aa | ||
| 8aa4f3577a | |||
| 6416356d89 | |||
| bc2790fd4d | |||
| b0ceb38e22 | |||
| 377be7c3b7 | |||
| 96c07c0eb3 | |||
| 010e600aa6 | |||
| cf46032f24 | |||
| ff27eb5eed | |||
| aecfb27d6e | |||
| 686feaf66b | |||
| c658ea133d | |||
| cf56e8f6fe | |||
| 4bec5443a2 | |||
| ee7c62c3b7 | |||
| 42a3addd9a | |||
| 7c71ce59bd | |||
| 858ae28eb3 | |||
| 55b259061b | |||
| e2746d5105 | |||
| 1dd752f354 | |||
| 22d499d38b | |||
| d0830e4631 | |||
| ef85d30846 | |||
| 36d7c43bbd | |||
| 3ff8067c51 | |||
| 4d3af37d5f | |||
|   | 0f96f39f24 | ||
| b32bdb5a70 | |||
| 6956e4c487 | |||
|   | acb1c711e5 | ||
|   | ec0bd91aa3 | ||
| de786c2b51 | |||
| 558f600271 | |||
| 5c680a8bea | |||
| 74f4d4854a | |||
| 4b49b5517d | |||
| 0cd623482c | |||
| 0a01b95c85 | |||
| 6daf2495a8 | |||
| 8771a378da | |||
| e84547c8ca | |||
| 872dcd0668 | |||
| a9f74fcd3c | |||
| 95752703ff | |||
| 5e19a62c7e | |||
| 2694829b6e | |||
| 151e1b7864 | |||
| 5965b1d626 | |||
| 219b80377d | |||
| 8315a27ea8 | |||
| d070c7731a | |||
| c8f614e2d2 | |||
| 4b07c11bc3 | |||
| 0bc00472d7 | |||
| 37da4f2c3e | |||
| 74505c1554 | |||
| 1869b36e09 | |||
| 13808d0103 | |||
| 4a3803df51 | |||
| d58d4642af | |||
| c3fa5195d3 | |||
| c4a9a541d3 | |||
| 8db9e020c8 | |||
| 42277699a7 | |||
| a1b78db750 | |||
| 7ebbf5661a | |||
| 2ff08141ae | |||
| 297e0629a4 | |||
| 213a80bd28 | |||
| b111c4a1b9 | |||
|   | db43ef628f | ||
| ccf9f41f4e | |||
| fb9e694919 | |||
| ea70170d2b | |||
| 36af52bf7e | |||
|   | 0b6461eeb5 | ||
| b0c936cc34 | |||
| d51174e8fd | |||
| e2a30dad52 | |||
| 74d3957cf8 | |||
| cfc35fbec6 | |||
| b1d3f8ddc9 | |||
| 3b04b39d66 | |||
| cdcb07a3f7 | |||
| 19523acc67 | |||
| faa8e6a13b | |||
| 0fed206df6 | |||
| 7660953e6b | |||
| 4a5d9fa65b | |||
| 83c0285204 | |||
| 3823d959e8 | |||
| 6c302712d4 | |||
| f93677b420 | |||
| 23cd62820c | |||
| 39d410b050 | |||
| 61c67d78da | |||
| 64d32e2688 | |||
| 1aba6948ee | |||
| 3b9103e9aa | |||
| db3158d4e7 | |||
| 08a970853a | |||
| 7cbfafa5c5 | |||
| c64f392fe7 | |||
| 4fef4ca2c6 | |||
| 268c2018ae | |||
| b7e8e1fa37 | |||
| fe6af27813 | |||
| a2005c55aa | |||
| da446c5073 | |||
| d3bc2bea4f | |||
| 37fcdb81bc | |||
| b3041d9ca7 | |||
| 7c8f20623c | |||
| 8b07ace876 | |||
| 073b2aca04 | |||
| 934795abd8 | |||
| c3965c9528 | |||
| 5164ce1f02 | |||
| 18cdb20923 | |||
| 85a61dfdc0 | |||
| c094097af7 | |||
| 39ba7d53dd | |||
| 98fc037f73 | |||
| 955f657298 | |||
| 853f9d901e | |||
| d0c2b4d703 | |||
| 1af3838ebc | |||
| d0bbf8f181 | |||
| 8c8a5c9762 | |||
| b0ea0668c7 | |||
| 4e1de0c886 | |||
| 244eb1be07 | |||
| 06f9fcc427 | |||
| 784deaf9f4 | |||
| 3b63cafa79 | |||
| 0fe672c9da | |||
| 37ab6e9132 | |||
| 53afb4ee6f | |||
| 1783527f39 | |||
| e7a679541b | |||
| 32b2185e94 | |||
| c1d46a6e6b | |||
| 1c43d83bd4 | |||
| 6fb27d52d6 | |||
| 092dba3f4b | |||
| 6044aed46f | |||
| ef4e6f57d9 | |||
| 974b4aeb48 | |||
| 4466c9f018 | |||
| 0ba2590bfd | |||
| cccf62bb53 | |||
| 649169c192 | |||
| 1caced26d6 | |||
| 163c97b2f5 | |||
| 7a28f0360d | |||
| a5ed2cb9e3 | |||
| 35333324ed | |||
| 2d36be07d2 | |||
| 183f26e5c3 | |||
| 2452d31b9a | |||
| 4549043a08 | |||
|   | 47986df47e | ||
| 42a1579cd1 | |||
| a36fc300f0 | |||
| c5af1e4cf8 | |||
| ab565f1369 | |||
| 3e859ebc7b | |||
| 2b05828f6f | |||
| df72ec9d8a | |||
| bf04ff780f | |||
| 64ca6caa3c | |||
| 9b70875c72 | |||
| 5d55a10ad0 | |||
| 272b6f3eb1 | |||
| 890f6cce3f | |||
| 67eea1beb0 | |||
| abefd93be5 | |||
| 1e0096c44b | |||
| c007ae6fb8 | |||
|   | b9f11281e5 | ||
| 2e4a9a1168 | |||
| 53ca2c24c1 | |||
| 42e032e977 | |||
| ac587a1b1c | |||
| 905a22e7c0 | |||
| 15e3680a97 | |||
| 9eb91ee2d4 | |||
| 266a3b978e | |||
| de1b6b76c9 | |||
|   | 961cdbad09 | ||
| 3416373b8f | |||
| f2874a4c1b | |||
| a27e9612e4 | |||
| 04b09983bc | |||
|   | 7050d68293 | ||
| d1067988c6 | |||
| 257a682eb4 | |||
| 80cc614390 | |||
| 431accb20e | |||
| 58db070cc0 | |||
| 31348a6a93 | |||
| f50ea78e3f | |||
| 8792dc7cbf | |||
| 59e5f48589 | |||
| 4624dfaf17 | |||
| 5ddc302048 | |||
| 2d4b433144 | |||
| 0d8040c00e | |||
| b920d65f1a | |||
| 9c277df2b7 | |||
| 591f9ea245 | |||
| bc35afb521 | |||
| 6a6afe5e60 | |||
| 63af74662f | |||
| e3afe8c2ae | |||
| c9270b2c54 | |||
| ac90dbedea | |||
| 0dcf941cd1 | |||
| 39306150bb | |||
| 868847f778 | |||
| 638c13bc53 | |||
| ffce336199 | |||
| 35900f3059 | |||
| 9a1117a7c8 | |||
| e48bc468cd | |||
| 9fdc1f82bd | |||
| 9d14dae4a7 | |||
| 6959f71f96 | |||
| be50e65846 | |||
| 2ebfe7564a | |||
| fda2673f5a | |||
| 68a1153885 | |||
| 7614cc8fae | |||
| c1411b3a76 | |||
| 6ad07f35f7 | |||
| 5533106aca | |||
| f61ffb60d8 | |||
| a17a08d018 | |||
| c9eecf0a29 | |||
| 9c1bcbc5f5 | |||
| d6b4f76fb5 | |||
| 5cedbc078d | |||
| a649912e78 | |||
| 0aa32654b0 | |||
| a1126e0509 | |||
| 39a8a1563c | |||
| b075b8803b | |||
| 8645612718 | |||
| 54058b0917 | |||
| c068713572 | |||
| e228deb6cd | |||
| d1fa3e0336 | |||
| 4d634ce313 | |||
| 1a2f4c9920 | |||
| 09a0354eee | |||
| 2ab164d5b9 | |||
| 730559f2f4 | |||
| ec9657c6e9 | |||
| 2ab2472e66 | |||
| a0183c1359 | |||
| c9d10f81a9 | |||
| 3b72bf279f | |||
| 413d08f538 | |||
| 11a96f4091 | |||
| 5e24f9ce04 | |||
| 7958a9311d | |||
| f70766e817 | |||
| da525d98cb | |||
| 09e11dbb2b | |||
| 21265e20cb | |||
| aef5748f5f | |||
| 5af1860607 | |||
| e94fc79580 | |||
| ee73509fc7 | |||
| 2445e82c69 | |||
| ad2f2241aa | |||
| f14177d497 | |||
| 1e96c113a9 | |||
| 75be5d3ca2 | |||
| 5da1900ae8 | |||
| a61f7cebbb | |||
| 69edb63ddd | |||
| 3deb1e40fc | |||
| 92a1a8278d | |||
| 7e6b577315 | |||
| 754f746094 | |||
| edb42717bc | |||
| ef6fc3a349 | |||
| a3ce96d4bf | |||
| 25667af9c5 | |||
| 4af1f48ebf | |||
| 9f6d7bf5d7 | |||
| 8854ef36f3 | |||
| 3b9d743603 | |||
| ef4323b275 | |||
| 292e944783 | |||
| 1a662acf3b | |||
| 8c8b9b7aca | |||
| 94b622a5e7 | |||
| aadc1b315e | |||
| 6f5fcd59ea | |||
| 7a6bea3c46 | |||
| 1a482db9e2 | |||
| 10a0e82392 | |||
| 6c2ff716e1 | |||
| 8b0cbe23d1 | |||
| 58f8cb14b8 | |||
| 814e80864d | |||
| ae98a4278d | |||
| f3b97e0e49 | |||
| 4b59cdfc8f | |||
| bb6ba7730e | |||
| 16e7c2379a | |||
| 359e20e948 | |||
| 737af80c39 | |||
| b3779bfefd | |||
| 212ef5faa7 | |||
| 5cbdb2ae63 | |||
| 5078c5f341 | |||
| 38d4899bf4 | |||
| 007b87e8ad | |||
| c813e2b3da | |||
| eed7718a3f | |||
| 4a81d2d624 | |||
| 2bff90b025 | |||
| 842cc00812 | |||
| ff6d8adefd | |||
| 2490428781 | |||
| 05b7b7fbbf | |||
| a269efedca | |||
| 8edbf15235 | |||
| e7168d308f | |||
| b6a318df85 | |||
| 11188bacbe | |||
| a1b5c26518 | |||
| dfa0eaeb4f | |||
| 4eeb2162d8 | |||
| df9f1c398b | |||
| 62ff5f0c2e | |||
| ee17bc2ab3 | |||
| eb483d9959 | |||
| c98170636b | |||
| 442257df01 | |||
| ec5a69f3e6 | |||
| 09fffa1830 | |||
| 28acee3085 | |||
| 7ce535f500 | |||
| e338c78d04 | |||
| 0de21e9abb | |||
| 9c3ae7434e | |||
| 996fcdc14f | |||
| 5781937bee | |||
| d1296ec40a | |||
| 29284b30a6 | |||
| cc7ba59407 | |||
| 966254807c | |||
| 770e321bed | |||
| 4f6dacb907 | |||
| f7a7ab8733 | |||
| 4ddac52a93 | |||
| 65e425fed7 | |||
| cdd57eb147 | |||
| 6ed28994c6 | |||
| 4a0026a9ee | |||
| e9bee963fe | |||
| 75d7396b96 | |||
| 9746a2114c | |||
| ce2e4e5219 | |||
| 6e0c594c84 | |||
| b2fd52e4a5 | |||
| fe2ca0f33a | |||
| 14fbfd200b | |||
| 043d042dcc | |||
| ebbd9215ea | |||
| d0b7f9c76c | |||
| 58b498b9de | |||
| 23f5e3ca4a | |||
| 7ff9978587 | |||
| f58d0d010e | |||
| da9d2febf1 | |||
| 86f2f3fb0c | |||
| 23cfc8aa1f | |||
| d731f97169 | |||
| 0231d2bd21 | |||
| 808cdbaddc | |||
| 5b5eb2e831 | |||
| cef1d91ee3 | |||
| 8c43ab3331 | |||
| 08d935e6a5 | |||
| 9c8bb59ad7 | |||
| 49b810d200 | |||
| 664bb62733 | |||
| c1bed58fbb | |||
| ccd9aad51f | |||
| 123efa1c8c | |||
| 19afa34c13 | |||
| ded9ed984b | |||
| 59f0ee1429 | |||
| 5c61998ff7 | |||
| 6f94bb1186 | |||
| 9181b79166 | |||
| 1913bf8b22 | |||
| f4cdd0ae28 | |||
| fc8529c20b | |||
| ab64583efc | |||
| 26ad0ba80a | 
| @@ -11,30 +11,21 @@ env: | |||||||
| jobs: | jobs: | ||||||
|   test: |   test: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     container: git.hofer.link/ruderverein-donau-linz/rowing-ci:20240118 |     container: git.hofer.link/philipp/ci-images:rust-latest | ||||||
|     steps: |     steps: | ||||||
|     - uses: actions/checkout@v3 |     - uses: actions/checkout@v3 | ||||||
|     - name: Run Test DB Script |     - name: Run Test DB Script | ||||||
|       run: ./test_db.sh |       run: ./test_db.sh | ||||||
|  |  | ||||||
|     - name: Set up cargo cache |     - name: Cache Cargo dependencies | ||||||
|       uses: actions/cache@v3 |       uses: Swatinem/rust-cache@v2 | ||||||
|       with: |  | ||||||
|         path: | |  | ||||||
|           ~/.cargo/bin/ |  | ||||||
|           ~/.cargo/registry/index/ |  | ||||||
|           ~/.cargo/registry/cache/ |  | ||||||
|           ~/.cargo/git/db/ |  | ||||||
|           target/ |  | ||||||
|         key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }} |  | ||||||
|         restore-keys: ${{ runner.os }}-cargo-debug- |  | ||||||
|  |  | ||||||
|     - name: Build |     - name: Build | ||||||
|       run: | |       run: | | ||||||
|         cargo build  |         cargo build  | ||||||
|         cd frontend && npm install && npm run build |         cd frontend && npm install && npm run build | ||||||
|     - name: Frontend tests |     - name: Frontend tests | ||||||
|       run: cd frontend && npx playwright test --workers 1 |       run: cd frontend  && npx playwright install && npx playwright test --workers 1 --reporter line | ||||||
|     - name: Backend tests |     - name: Backend tests | ||||||
|       run: cargo test --verbose |       run: cargo test --verbose | ||||||
|     #- uses: actions/upload-artifact@v3 |     #- uses: actions/upload-artifact@v3 | ||||||
| @@ -46,7 +37,7 @@ jobs: | |||||||
|  |  | ||||||
|   deploy-staging: |   deploy-staging: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     container: git.hofer.link/ruderverein-donau-linz/rowing-ci:20240118 |     container: git.hofer.link/philipp/ci-images:rust-latest | ||||||
|     needs: [test] |     needs: [test] | ||||||
|     if: github.ref == 'refs/heads/staging' |     if: github.ref == 'refs/heads/staging' | ||||||
|     steps: |     steps: | ||||||
| @@ -56,17 +47,9 @@ jobs: | |||||||
|       - name: Run Test DB Script |       - name: Run Test DB Script | ||||||
|         run: ./test_db.sh |         run: ./test_db.sh | ||||||
|  |  | ||||||
|       - name: Set up cargo cache |       - name: Cache Cargo dependencies | ||||||
|         uses: actions/cache@v3 |         uses: Swatinem/rust-cache@v2 | ||||||
|         with: |  | ||||||
|           path: | |  | ||||||
|             ~/.cargo/bin/ |  | ||||||
|             ~/.cargo/registry/index/ |  | ||||||
|             ~/.cargo/registry/cache/ |  | ||||||
|             ~/.cargo/git/db/ |  | ||||||
|             target/ |  | ||||||
|           key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} |  | ||||||
|           restore-keys: ${{ runner.os }}-cargo-release- |  | ||||||
|       - name: Build |       - name: Build | ||||||
|         run: | |         run: | | ||||||
|           cargo build --release --target $CARGO_TARGET |           cargo build --release --target $CARGO_TARGET | ||||||
| @@ -80,16 +63,16 @@ jobs: | |||||||
|           echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa |           echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa | ||||||
|           chmod 600 ~/.ssh/id_rsa |           chmod 600 ~/.ssh/id_rsa | ||||||
|  |  | ||||||
|           scp target/$CARGO_TARGET/release/rot $SSH_USER@$SSH_HOST:/home/k004373/rowing-staging/rot-updating |           scp -C target/$CARGO_TARGET/release/rot $SSH_USER@$SSH_HOST:/root/rowing-staging/rot-updating | ||||||
|      |      | ||||||
|           scp staging-diff.sql $SSH_USER@$SSH_HOST:/home/k004373/rowing-staging/ |           scp -C staging-diff.sql $SSH_USER@$SSH_HOST:/root/rowing-staging/ | ||||||
|           scp -r static $SSH_USER@$SSH_HOST:/home/k004373/rowing-staging/ |           scp -C -r static $SSH_USER@$SSH_HOST:/root/rowing-staging/ | ||||||
|           scp -r templates $SSH_USER@$SSH_HOST:/home/k004373/rowing-staging/ |           scp -C -r templates $SSH_USER@$SSH_HOST:/root/rowing-staging/ | ||||||
|           scp -r svelte $SSH_USER@$SSH_HOST:/home/k004373/rowing-staging/ |           scp -C -r svelte $SSH_USER@$SSH_HOST:/root/rowing-staging/ | ||||||
|           ssh $SSH_USER@$SSH_HOST 'sudo systemctl stop rotstaging' |           ssh $SSH_USER@$SSH_HOST 'sudo systemctl stop rowing-staging' | ||||||
|           ssh $SSH_USER@$SSH_HOST 'rm /home/k004373/rowing-staging/db.sqlite && cp /home/k004373/rowing/db.sqlite /home/k004373/rowing-staging/db.sqlite && mkdir -p /home/k004373/rowing-staging/svelte/build && mkdir -p /home/k004373/rowing-staging/data-ergo/thirty && mkdir -p /home/k004373/rowing-staging/data-ergo/dozen && sqlite3 /home/k004373/rowing-staging/db.sqlite < /home/k004373/rowing-staging/staging-diff.sql' |           ssh $SSH_USER@$SSH_HOST 'rm -f /root/rowing-staging/db.sqlite && cp /root/rowing-prod/db.sqlite /root/rowing-staging/db.sqlite && mkdir -p /root/rowing-staging/svelte/build && mkdir -p /root/rowing-staging/data-ergo/thirty && mkdir -p /root/rowing-staging/data-ergo/dozen && sqlite3 /root/rowing-staging/db.sqlite < /root/rowing-staging/staging-diff.sql' | ||||||
|           ssh $SSH_USER@$SSH_HOST 'mv  /home/k004373/rowing-staging/rot-updating /home/k004373/rowing-staging/rot' |           ssh $SSH_USER@$SSH_HOST 'mv  /root/rowing-staging/rot-updating /root/rowing-staging/rot' | ||||||
|           ssh $SSH_USER@$SSH_HOST 'sudo systemctl start rotstaging' |           ssh $SSH_USER@$SSH_HOST 'sudo systemctl start rowing-staging' | ||||||
|         env: |         env: | ||||||
|           SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} |           SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} | ||||||
|           SSH_HOST: ${{ secrets.SSH_HOST }} |           SSH_HOST: ${{ secrets.SSH_HOST }} | ||||||
| @@ -97,7 +80,7 @@ jobs: | |||||||
|  |  | ||||||
|   deploy-main: |   deploy-main: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     container: git.hofer.link/ruderverein-donau-linz/rowing-ci:20240118 |     container: git.hofer.link/philipp/ci-images:rust-latest | ||||||
|     needs: [test] |     needs: [test] | ||||||
|     if: github.ref == 'refs/heads/main' |     if: github.ref == 'refs/heads/main' | ||||||
|     steps: |     steps: | ||||||
| @@ -107,17 +90,8 @@ jobs: | |||||||
|       - name: Run Test DB Script |       - name: Run Test DB Script | ||||||
|         run: ./test_db.sh |         run: ./test_db.sh | ||||||
|        |        | ||||||
|       - name: Set up cargo cache |       - name: Cache Cargo dependencies | ||||||
|         uses: actions/cache@v3 |         uses: Swatinem/rust-cache@v2 | ||||||
|         with: |  | ||||||
|           path: | |  | ||||||
|             ~/.cargo/bin/ |  | ||||||
|             ~/.cargo/registry/index/ |  | ||||||
|             ~/.cargo/registry/cache/ |  | ||||||
|             ~/.cargo/git/db/ |  | ||||||
|             target/ |  | ||||||
|           key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} |  | ||||||
|           restore-keys: ${{ runner.os }}-cargo-release- |  | ||||||
|  |  | ||||||
|       - name: Build |       - name: Build | ||||||
|         run: | |         run: | | ||||||
| @@ -132,14 +106,14 @@ jobs: | |||||||
|           echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa |           echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa | ||||||
|           chmod 600 ~/.ssh/id_rsa |           chmod 600 ~/.ssh/id_rsa | ||||||
|  |  | ||||||
|           scp target/$CARGO_TARGET/release/rot $SSH_USER@$SSH_HOST:/home/k004373/rowing/rot-updating |           scp -C target/$CARGO_TARGET/release/rot $SSH_USER@$SSH_HOST:/root/rowing-prod/rot-updating | ||||||
|           scp -r static $SSH_USER@$SSH_HOST:/home/k004373/rowing/ |           scp -C -r static $SSH_USER@$SSH_HOST:/root/rowing-prod/ | ||||||
|           scp -r templates $SSH_USER@$SSH_HOST:/home/k004373/rowing/ |           scp -C -r templates $SSH_USER@$SSH_HOST:/root/rowing-prod/ | ||||||
|           scp -r svelte $SSH_USER@$SSH_HOST:/home/k004373/rowing/ |           scp -C -r svelte $SSH_USER@$SSH_HOST:/root/rowing-prod/ | ||||||
|           ssh $SSH_USER@$SSH_HOST 'mkdir -p /home/k004373/rowing/svelte/build && mkdir -p /home/k004373/rowing/data-ergo/thirty && mkdir -p /home/k004373/rowing/data-ergo/dozen' |           ssh $SSH_USER@$SSH_HOST 'mkdir -p /root/rowing-prod/svelte/build && mkdir -p /root/rowing-prod/data-ergo/thirty && mkdir -p /root/rowing-prod/data-ergo/dozen' | ||||||
|           ssh $SSH_USER@$SSH_HOST 'sudo systemctl stop rot' |           ssh $SSH_USER@$SSH_HOST 'sudo systemctl stop rowing-prod' | ||||||
|           ssh $SSH_USER@$SSH_HOST 'mv  /home/k004373/rowing/rot-updating /home/k004373/rowing/rot' |           ssh $SSH_USER@$SSH_HOST 'mv  /root/rowing-prod/rot-updating /root/rowing-prod/rot' | ||||||
|           ssh $SSH_USER@$SSH_HOST 'sudo systemctl start rot' |           ssh $SSH_USER@$SSH_HOST 'sudo systemctl start rowing-prod' | ||||||
|         env: |         env: | ||||||
|           SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} |           SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} | ||||||
|           SSH_HOST: ${{ secrets.SSH_HOST }} |           SSH_HOST: ${{ secrets.SSH_HOST }} | ||||||
|   | |||||||
							
								
								
									
										51
									
								
								.gitea/workflows/update.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,51 @@ | |||||||
|  | name: Update Cargo Dependencies | ||||||
|  |  | ||||||
|  | on: | ||||||
|  |   schedule: | ||||||
|  |     - cron: '0 2 * * 5'  # Run weekly on Friday at 2am | ||||||
|  |   workflow_dispatch:  # Allow manual triggering | ||||||
|  |  | ||||||
|  | jobs: | ||||||
|  |   update-dependencies: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     container: git.hofer.link/philipp/ci-images:rust-latest | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v3 | ||||||
|  |  | ||||||
|  |       - name: Update dependencies | ||||||
|  |         run: | | ||||||
|  |           cargo upgrade | ||||||
|  |           cargo update | ||||||
|  |  | ||||||
|  |       - name: Create Pull Request Staging | ||||||
|  |         uses: https://git.hofer.link/philipp/create-pull-request@18ef1fdad70eec569ab10292c1fa79c1b5296370 | ||||||
|  |         with: | ||||||
|  |           token: ${{ secrets.GITEATOKEN }} | ||||||
|  |           commit-message: Update Cargo dependencies | ||||||
|  |           title: Update Cargo dependencies | ||||||
|  |           body: | | ||||||
|  |             This PR updates Cargo dependencies to their latest versions. | ||||||
|  |  | ||||||
|  |             @philipp | ||||||
|  |  | ||||||
|  |             - Run `cargo upgrade` to update version requirements in Cargo.toml | ||||||
|  |             - Run `cargo update` to update Cargo.lock | ||||||
|  |           branch: update-cargo-dependencies | ||||||
|  |           delete-branch: false | ||||||
|  |  | ||||||
|  |       - name: Create Pull Request Main  | ||||||
|  |         uses: https://git.hofer.link/philipp/create-pull-request@18ef1fdad70eec569ab10292c1fa79c1b5296370 | ||||||
|  |         with: | ||||||
|  |           token: ${{ secrets.GITEATOKEN }} | ||||||
|  |           commit-message: Update Cargo dependencies | ||||||
|  |           title: Update Cargo dependencies | ||||||
|  |           body: | | ||||||
|  |             This PR updates Cargo dependencies to their latest versions. | ||||||
|  |  | ||||||
|  |             @philipp | ||||||
|  |  | ||||||
|  |             - Run `cargo upgrade` to update version requirements in Cargo.toml | ||||||
|  |             - Run `cargo update` to update Cargo.lock | ||||||
|  |           branch: update-cargo-dependencies | ||||||
|  |           base: main | ||||||
|  |           delete-branch: true | ||||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -5,3 +5,4 @@ Rocket.toml | |||||||
| frontend/node_modules/* | frontend/node_modules/* | ||||||
| /static/ | /static/ | ||||||
| /data-ergo/ | /data-ergo/ | ||||||
|  | usage.txt | ||||||
|   | |||||||
							
								
								
									
										2117
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
							
								
								
									
										20
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						| @@ -1,7 +1,7 @@ | |||||||
| [package] | [package] | ||||||
| name = "rot" | name = "rot" | ||||||
| version = "0.1.0" | version = "0.1.0" | ||||||
| edition = "2021" | edition = "2024" | ||||||
|  |  | ||||||
| [features] | [features] | ||||||
| default = ["rest", "rowing-tera" ] | default = ["rest", "rowing-tera" ] | ||||||
| @@ -9,20 +9,26 @@ rowing-tera = ["rocket_dyn_templates", "tera"] | |||||||
| rest = [] | rest = [] | ||||||
|  |  | ||||||
| [dependencies] | [dependencies] | ||||||
| rocket = { version = "0.5.0", features = ["secrets"]} | rocket = { version = "0.5", features = ["secrets"]} | ||||||
| rocket_dyn_templates = {version = "0.1.0", features = [ "tera" ], optional = true } | rocket_dyn_templates = {version = "0.2", features = [ "tera" ], optional = true } | ||||||
| log = "0.4" | log = "0.4" | ||||||
| env_logger = "0.10" | env_logger = "0.11" | ||||||
| sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "macros", "chrono", "time"] } | sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "macros", "chrono"] } | ||||||
| argon2 = "0.5" | argon2 = "0.5" | ||||||
| serde = { version = "1.0", features = [ "derive" ]} | serde = { version = "1.0", features = [ "derive" ]} | ||||||
| serde_json = "1.0" | serde_json = "1.0" | ||||||
| chrono =  { version = "0.4", features = ["serde"]} | chrono =  { version = "0.4", features = ["serde"]} | ||||||
| chrono-tz = "0.8" | chrono-tz = "0.10" | ||||||
| tera =  { version = "1.18", features = ["date-locale"], optional = true} | tera =  { version = "1.20", features = ["date-locale"], optional = true} | ||||||
| ics = "0.5" | ics = "0.5" | ||||||
| futures = "0.3" | futures = "0.3" | ||||||
| lettre = "0.11" | lettre = "0.11" | ||||||
|  | csv = "1.3" | ||||||
|  | itertools = "0.14" | ||||||
|  | job_scheduler_ng = "2.2" | ||||||
|  | ureq = { version = "3.0", features = ["json"] } | ||||||
|  | regex = "1.11" | ||||||
|  | urlencoding = "2.1" | ||||||
|  |  | ||||||
| [target.'cfg(not(windows))'.dependencies] | [target.'cfg(not(windows))'.dependencies] | ||||||
| openssl = { version = "0.10", features = [ "vendored" ] } | openssl = { version = "0.10", features = [ "vendored" ] } | ||||||
|   | |||||||
							
								
								
									
										25
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						| @@ -1,25 +0,0 @@ | |||||||
| # This dockerfile is used as basis for the CI jobs. |  | ||||||
| # Process to renew it: |  | ||||||
| # 0. Login to gitea docker registry: `docker login git.hofer.link` |  | ||||||
| # 1. Build the image `docker build .` |  | ||||||
| # 2. Tag the image: `docker tag <id> git.hofer.link/ruderverein-donau-linz/rowing-ci:<date>` |  | ||||||
| # 3. Push the image: `docker push git.hofer.link/ruderverein-donau-linz/rowing-ci:<date>` |  | ||||||
|  |  | ||||||
| FROM rust:1.75.0 |  | ||||||
|  |  | ||||||
| RUN apt-get update && apt-get install -y sqlite3 |  | ||||||
|  |  | ||||||
| # nodejs |  | ||||||
| RUN apt-get install -y curl && \ |  | ||||||
|     curl -sL https://deb.nodesource.com/setup_21.x | bash - && \ |  | ||||||
|     apt-get install -y nodejs |  | ||||||
|  |  | ||||||
| # playwright |  | ||||||
| RUN npx playwright install --with-deps |  | ||||||
|  |  | ||||||
| # deployment |  | ||||||
| RUN rustup target add x86_64-unknown-linux-musl |  | ||||||
| RUN apt-get install -y -qq pkg-config sshpass musl musl-tools curl gnupg libssl-dev |  | ||||||
|  |  | ||||||
| # TEMPORARY act workaround (otherwise gitea cache is not working) |  | ||||||
| RUN apt-get install -y zstd |  | ||||||
							
								
								
									
										287
									
								
								LICENSE
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,287 @@ | |||||||
|  |                       EUROPEAN UNION PUBLIC LICENCE v. 1.2 | ||||||
|  |                       EUPL © the European Union 2007, 2016 | ||||||
|  |  | ||||||
|  | This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined | ||||||
|  | below) which is provided under the terms of this Licence. Any use of the Work, | ||||||
|  | other than as authorised under this Licence is prohibited (to the extent such | ||||||
|  | use is covered by a right of the copyright holder of the Work). | ||||||
|  |  | ||||||
|  | The Work is provided under the terms of this Licence when the Licensor (as | ||||||
|  | defined below) has placed the following notice immediately following the | ||||||
|  | copyright notice for the Work: | ||||||
|  |  | ||||||
|  |         Licensed under the EUPL | ||||||
|  |  | ||||||
|  | or has expressed by any other means his willingness to license under the EUPL. | ||||||
|  |  | ||||||
|  | 1. Definitions | ||||||
|  |  | ||||||
|  | In this Licence, the following terms have the following meaning: | ||||||
|  |  | ||||||
|  | - ‘The Licence’: this Licence. | ||||||
|  |  | ||||||
|  | - ‘The Original Work’: the work or software distributed or communicated by the | ||||||
|  |   Licensor under this Licence, available as Source Code and also as Executable | ||||||
|  |   Code as the case may be. | ||||||
|  |  | ||||||
|  | - ‘Derivative Works’: the works or software that could be created by the | ||||||
|  |   Licensee, based upon the Original Work or modifications thereof. This Licence | ||||||
|  |   does not define the extent of modification or dependence on the Original Work | ||||||
|  |   required in order to classify a work as a Derivative Work; this extent is | ||||||
|  |   determined by copyright law applicable in the country mentioned in Article 15. | ||||||
|  |  | ||||||
|  | - ‘The Work’: the Original Work or its Derivative Works. | ||||||
|  |  | ||||||
|  | - ‘The Source Code’: the human-readable form of the Work which is the most | ||||||
|  |   convenient for people to study and modify. | ||||||
|  |  | ||||||
|  | - ‘The Executable Code’: any code which has generally been compiled and which is | ||||||
|  |   meant to be interpreted by a computer as a program. | ||||||
|  |  | ||||||
|  | - ‘The Licensor’: the natural or legal person that distributes or communicates | ||||||
|  |   the Work under the Licence. | ||||||
|  |  | ||||||
|  | - ‘Contributor(s)’: any natural or legal person who modifies the Work under the | ||||||
|  |   Licence, or otherwise contributes to the creation of a Derivative Work. | ||||||
|  |  | ||||||
|  | - ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of | ||||||
|  |   the Work under the terms of the Licence. | ||||||
|  |  | ||||||
|  | - ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, | ||||||
|  |   renting, distributing, communicating, transmitting, or otherwise making | ||||||
|  |   available, online or offline, copies of the Work or providing access to its | ||||||
|  |   essential functionalities at the disposal of any other natural or legal | ||||||
|  |   person. | ||||||
|  |  | ||||||
|  | 2. Scope of the rights granted by the Licence | ||||||
|  |  | ||||||
|  | The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, | ||||||
|  | sublicensable licence to do the following, for the duration of copyright vested | ||||||
|  | in the Original Work: | ||||||
|  |  | ||||||
|  | - use the Work in any circumstance and for all usage, | ||||||
|  | - reproduce the Work, | ||||||
|  | - modify the Work, and make Derivative Works based upon the Work, | ||||||
|  | - communicate to the public, including the right to make available or display | ||||||
|  |   the Work or copies thereof to the public and perform publicly, as the case may | ||||||
|  |   be, the Work, | ||||||
|  | - distribute the Work or copies thereof, | ||||||
|  | - lend and rent the Work or copies thereof, | ||||||
|  | - sublicense rights in the Work or copies thereof. | ||||||
|  |  | ||||||
|  | Those rights can be exercised on any media, supports and formats, whether now | ||||||
|  | known or later invented, as far as the applicable law permits so. | ||||||
|  |  | ||||||
|  | In the countries where moral rights apply, the Licensor waives his right to | ||||||
|  | exercise his moral right to the extent allowed by law in order to make effective | ||||||
|  | the licence of the economic rights here above listed. | ||||||
|  |  | ||||||
|  | The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to | ||||||
|  | any patents held by the Licensor, to the extent necessary to make use of the | ||||||
|  | rights granted on the Work under this Licence. | ||||||
|  |  | ||||||
|  | 3. Communication of the Source Code | ||||||
|  |  | ||||||
|  | The Licensor may provide the Work either in its Source Code form, or as | ||||||
|  | Executable Code. If the Work is provided as Executable Code, the Licensor | ||||||
|  | provides in addition a machine-readable copy of the Source Code of the Work | ||||||
|  | along with each copy of the Work that the Licensor distributes or indicates, in | ||||||
|  | a notice following the copyright notice attached to the Work, a repository where | ||||||
|  | the Source Code is easily and freely accessible for as long as the Licensor | ||||||
|  | continues to distribute or communicate the Work. | ||||||
|  |  | ||||||
|  | 4. Limitations on copyright | ||||||
|  |  | ||||||
|  | Nothing in this Licence is intended to deprive the Licensee of the benefits from | ||||||
|  | any exception or limitation to the exclusive rights of the rights owners in the | ||||||
|  | Work, of the exhaustion of those rights or of other applicable limitations | ||||||
|  | thereto. | ||||||
|  |  | ||||||
|  | 5. Obligations of the Licensee | ||||||
|  |  | ||||||
|  | The grant of the rights mentioned above is subject to some restrictions and | ||||||
|  | obligations imposed on the Licensee. Those obligations are the following: | ||||||
|  |  | ||||||
|  | Attribution right: The Licensee shall keep intact all copyright, patent or | ||||||
|  | trademarks notices and all notices that refer to the Licence and to the | ||||||
|  | disclaimer of warranties. The Licensee must include a copy of such notices and a | ||||||
|  | copy of the Licence with every copy of the Work he/she distributes or | ||||||
|  | communicates. The Licensee must cause any Derivative Work to carry prominent | ||||||
|  | notices stating that the Work has been modified and the date of modification. | ||||||
|  |  | ||||||
|  | Copyleft clause: If the Licensee distributes or communicates copies of the | ||||||
|  | Original Works or Derivative Works, this Distribution or Communication will be | ||||||
|  | done under the terms of this Licence or of a later version of this Licence | ||||||
|  | unless the Original Work is expressly distributed only under this version of the | ||||||
|  | Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee | ||||||
|  | (becoming Licensor) cannot offer or impose any additional terms or conditions on | ||||||
|  | the Work or Derivative Work that alter or restrict the terms of the Licence. | ||||||
|  |  | ||||||
|  | Compatibility clause: If the Licensee Distributes or Communicates Derivative | ||||||
|  | Works or copies thereof based upon both the Work and another work licensed under | ||||||
|  | a Compatible Licence, this Distribution or Communication can be done under the | ||||||
|  | terms of this Compatible Licence. For the sake of this clause, ‘Compatible | ||||||
|  | Licence’ refers to the licences listed in the appendix attached to this Licence. | ||||||
|  | Should the Licensee's obligations under the Compatible Licence conflict with | ||||||
|  | his/her obligations under this Licence, the obligations of the Compatible | ||||||
|  | Licence shall prevail. | ||||||
|  |  | ||||||
|  | Provision of Source Code: When distributing or communicating copies of the Work, | ||||||
|  | the Licensee will provide a machine-readable copy of the Source Code or indicate | ||||||
|  | a repository where this Source will be easily and freely available for as long | ||||||
|  | as the Licensee continues to distribute or communicate the Work. | ||||||
|  |  | ||||||
|  | Legal Protection: This Licence does not grant permission to use the trade names, | ||||||
|  | trademarks, service marks, or names of the Licensor, except as required for | ||||||
|  | reasonable and customary use in describing the origin of the Work and | ||||||
|  | reproducing the content of the copyright notice. | ||||||
|  |  | ||||||
|  | 6. Chain of Authorship | ||||||
|  |  | ||||||
|  | The original Licensor warrants that the copyright in the Original Work granted | ||||||
|  | hereunder is owned by him/her or licensed to him/her and that he/she has the | ||||||
|  | power and authority to grant the Licence. | ||||||
|  |  | ||||||
|  | Each Contributor warrants that the copyright in the modifications he/she brings | ||||||
|  | to the Work are owned by him/her or licensed to him/her and that he/she has the | ||||||
|  | power and authority to grant the Licence. | ||||||
|  |  | ||||||
|  | Each time You accept the Licence, the original Licensor and subsequent | ||||||
|  | Contributors grant You a licence to their contributions to the Work, under the | ||||||
|  | terms of this Licence. | ||||||
|  |  | ||||||
|  | 7. Disclaimer of Warranty | ||||||
|  |  | ||||||
|  | The Work is a work in progress, which is continuously improved by numerous | ||||||
|  | Contributors. It is not a finished work and may therefore contain defects or | ||||||
|  | ‘bugs’ inherent to this type of development. | ||||||
|  |  | ||||||
|  | For the above reason, the Work is provided under the Licence on an ‘as is’ basis | ||||||
|  | and without warranties of any kind concerning the Work, including without | ||||||
|  | limitation merchantability, fitness for a particular purpose, absence of defects | ||||||
|  | or errors, accuracy, non-infringement of intellectual property rights other than | ||||||
|  | copyright as stated in Article 6 of this Licence. | ||||||
|  |  | ||||||
|  | This disclaimer of warranty is an essential part of the Licence and a condition | ||||||
|  | for the grant of any rights to the Work. | ||||||
|  |  | ||||||
|  | 8. Disclaimer of Liability | ||||||
|  |  | ||||||
|  | Except in the cases of wilful misconduct or damages directly caused to natural | ||||||
|  | persons, the Licensor will in no event be liable for any direct or indirect, | ||||||
|  | material or moral, damages of any kind, arising out of the Licence or of the use | ||||||
|  | of the Work, including without limitation, damages for loss of goodwill, work | ||||||
|  | stoppage, computer failure or malfunction, loss of data or any commercial | ||||||
|  | damage, even if the Licensor has been advised of the possibility of such damage. | ||||||
|  | However, the Licensor will be liable under statutory product liability laws as | ||||||
|  | far such laws apply to the Work. | ||||||
|  |  | ||||||
|  | 9. Additional agreements | ||||||
|  |  | ||||||
|  | While distributing the Work, You may choose to conclude an additional agreement, | ||||||
|  | defining obligations or services consistent with this Licence. However, if | ||||||
|  | accepting obligations, You may act only on your own behalf and on your sole | ||||||
|  | responsibility, not on behalf of the original Licensor or any other Contributor, | ||||||
|  | and only if You agree to indemnify, defend, and hold each Contributor harmless | ||||||
|  | for any liability incurred by, or claims asserted against such Contributor by | ||||||
|  | the fact You have accepted any warranty or additional liability. | ||||||
|  |  | ||||||
|  | 10. Acceptance of the Licence | ||||||
|  |  | ||||||
|  | The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ | ||||||
|  | placed under the bottom of a window displaying the text of this Licence or by | ||||||
|  | affirming consent in any other similar way, in accordance with the rules of | ||||||
|  | applicable law. Clicking on that icon indicates your clear and irrevocable | ||||||
|  | acceptance of this Licence and all of its terms and conditions. | ||||||
|  |  | ||||||
|  | Similarly, you irrevocably accept this Licence and all of its terms and | ||||||
|  | conditions by exercising any rights granted to You by Article 2 of this Licence, | ||||||
|  | such as the use of the Work, the creation by You of a Derivative Work or the | ||||||
|  | Distribution or Communication by You of the Work or copies thereof. | ||||||
|  |  | ||||||
|  | 11. Information to the public | ||||||
|  |  | ||||||
|  | In case of any Distribution or Communication of the Work by means of electronic | ||||||
|  | communication by You (for example, by offering to download the Work from a | ||||||
|  | remote location) the distribution channel or media (for example, a website) must | ||||||
|  | at least provide to the public the information requested by the applicable law | ||||||
|  | regarding the Licensor, the Licence and the way it may be accessible, concluded, | ||||||
|  | stored and reproduced by the Licensee. | ||||||
|  |  | ||||||
|  | 12. Termination of the Licence | ||||||
|  |  | ||||||
|  | The Licence and the rights granted hereunder will terminate automatically upon | ||||||
|  | any breach by the Licensee of the terms of the Licence. | ||||||
|  |  | ||||||
|  | Such a termination will not terminate the licences of any person who has | ||||||
|  | received the Work from the Licensee under the Licence, provided such persons | ||||||
|  | remain in full compliance with the Licence. | ||||||
|  |  | ||||||
|  | 13. Miscellaneous | ||||||
|  |  | ||||||
|  | Without prejudice of Article 9 above, the Licence represents the complete | ||||||
|  | agreement between the Parties as to the Work. | ||||||
|  |  | ||||||
|  | If any provision of the Licence is invalid or unenforceable under applicable | ||||||
|  | law, this will not affect the validity or enforceability of the Licence as a | ||||||
|  | whole. Such provision will be construed or reformed so as necessary to make it | ||||||
|  | valid and enforceable. | ||||||
|  |  | ||||||
|  | The European Commission may publish other linguistic versions or new versions of | ||||||
|  | this Licence or updated versions of the Appendix, so far this is required and | ||||||
|  | reasonable, without reducing the scope of the rights granted by the Licence. New | ||||||
|  | versions of the Licence will be published with a unique version number. | ||||||
|  |  | ||||||
|  | All linguistic versions of this Licence, approved by the European Commission, | ||||||
|  | have identical value. Parties can take advantage of the linguistic version of | ||||||
|  | their choice. | ||||||
|  |  | ||||||
|  | 14. Jurisdiction | ||||||
|  |  | ||||||
|  | Without prejudice to specific agreement between parties, | ||||||
|  |  | ||||||
|  | - any litigation resulting from the interpretation of this License, arising | ||||||
|  |   between the European Union institutions, bodies, offices or agencies, as a | ||||||
|  |   Licensor, and any Licensee, will be subject to the jurisdiction of the Court | ||||||
|  |   of Justice of the European Union, as laid down in article 272 of the Treaty on | ||||||
|  |   the Functioning of the European Union, | ||||||
|  |  | ||||||
|  | - any litigation arising between other parties and resulting from the | ||||||
|  |   interpretation of this License, will be subject to the exclusive jurisdiction | ||||||
|  |   of the competent court where the Licensor resides or conducts its primary | ||||||
|  |   business. | ||||||
|  |  | ||||||
|  | 15. Applicable Law | ||||||
|  |  | ||||||
|  | Without prejudice to specific agreement between parties, | ||||||
|  |  | ||||||
|  | - this Licence shall be governed by the law of the European Union Member State | ||||||
|  |   where the Licensor has his seat, resides or has his registered office, | ||||||
|  |  | ||||||
|  | - this licence shall be governed by Belgian law if the Licensor has no seat, | ||||||
|  |   residence or registered office inside a European Union Member State. | ||||||
|  |  | ||||||
|  | Appendix | ||||||
|  |  | ||||||
|  | ‘Compatible Licences’ according to Article 5 EUPL are: | ||||||
|  |  | ||||||
|  | - GNU General Public License (GPL) v. 2, v. 3 | ||||||
|  | - GNU Affero General Public License (AGPL) v. 3 | ||||||
|  | - Open Software License (OSL) v. 2.1, v. 3.0 | ||||||
|  | - Eclipse Public License (EPL) v. 1.0 | ||||||
|  | - CeCILL v. 2.0, v. 2.1 | ||||||
|  | - Mozilla Public Licence (MPL) v. 2 | ||||||
|  | - GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 | ||||||
|  | - Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for | ||||||
|  |   works other than software | ||||||
|  | - European Union Public Licence (EUPL) v. 1.1, v. 1.2 | ||||||
|  | - Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong | ||||||
|  |   Reciprocity (LiLiQ-R+). | ||||||
|  |  | ||||||
|  | The European Commission may update this Appendix to later versions of the above | ||||||
|  | licences without producing a new version of the EUPL, as long as they provide | ||||||
|  | the rights granted in Article 2 of this Licence and protect the covered Source | ||||||
|  | Code from exclusive appropriation. | ||||||
|  |  | ||||||
|  | All other changes or additions to this Appendix require the production of a new | ||||||
|  | EUPL version. | ||||||
							
								
								
									
										30
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -1,3 +1,5 @@ | |||||||
|  |  | ||||||
|  |  | ||||||
| # Build | # Build | ||||||
| ## Frontend | ## Frontend | ||||||
| 1. `cd frontend` | 1. `cd frontend` | ||||||
| @@ -16,3 +18,31 @@ | |||||||
|  |  | ||||||
| ## Backend (Unit + Integration) | ## Backend (Unit + Integration) | ||||||
| `cargo t` | `cargo t` | ||||||
|  |  | ||||||
|  | # Lints | ||||||
|  |  | ||||||
|  | - Rust: `cargo check` | ||||||
|  | - Tera files: `djlint **.html.tera --profile=jinja --reformat` | ||||||
|  | - Typescript: `prettier -w *.ts` | ||||||
|  |  | ||||||
|  | # Dependencies | ||||||
|  | - `sqlite3` | ||||||
|  | - `rust` | ||||||
|  |  | ||||||
|  | # Nginx config | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | server { | ||||||
|  |     server_name staging.rudernlinz.at; | ||||||
|  |     location / { | ||||||
|  |         proxy_pass http://localhost:7999/; # The / is important! | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | server { | ||||||
|  |     server_name app.rudernlinz.at; | ||||||
|  |     location / { | ||||||
|  |         proxy_pass http://localhost:8001/; # The / is important! | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | ``` | ||||||
|   | |||||||
| @@ -2,4 +2,7 @@ | |||||||
| secret_key = "/NtVGizglEoyoxBLzsRDWTy4oAG1qDw4J4O+CWJSv+fypD7W9sam8hUY4j90EZsbZk8wEradS5zBoWtWKi3k8w==" | secret_key = "/NtVGizglEoyoxBLzsRDWTy4oAG1qDw4J4O+CWJSv+fypD7W9sam8hUY4j90EZsbZk8wEradS5zBoWtWKi3k8w==" | ||||||
| rss_key = "rss-key-for-ci" | rss_key = "rss-key-for-ci" | ||||||
| limits = { file = "10 MiB", data-form = "10 MiB"} | limits = { file = "10 MiB", data-form = "10 MiB"} | ||||||
| smtp_pw = "8kIjlLH79Ky6D3jQ" | smtp_pw = "my-smtp-password" | ||||||
|  | usage_log_path = "./usage.txt" | ||||||
|  | openweathermap_key = "openweather-key" | ||||||
|  | wordpress_key = "pw-to-allow-sending-notifications" | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								demo_db.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,6 @@ | |||||||
|  | #!/bin/bash | ||||||
|  |  | ||||||
|  | rm -f db.sqlite | ||||||
|  | touch db.sqlite | ||||||
|  | sqlite3 db.sqlite < migration.sql | ||||||
|  | sqlite3 db.sqlite < seeds_demo.sql | ||||||
							
								
								
									
										59
									
								
								doc/db/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,59 @@ | |||||||
|  | # Database | ||||||
|  |  | ||||||
|  | Since the database stabilized quite well over the last months/years, hopefully it will not change that much in the future.  | ||||||
|  | Thus, here is the current (October '24) model and the reasoning behind it: | ||||||
|  |  | ||||||
|  | ## User | ||||||
|  |  | ||||||
|  |  | ||||||
|  | - All user-relevant fields are stored in `User`. | ||||||
|  | - `Role` (and its associative table `UserRole`) map current roles the user has. This is used for e.g. permissions (`Vorstand`, `Admin`, `cox`, ... roles) and fee calculation (`Donau Linz`, `scheckbuch`, `Rennjugend`). | ||||||
|  | - `Family` specifies, well, a family. Currently only used for fee calculation. | ||||||
|  | - `cluster` in `Role` groups roles together. There is a db check to only allow for at most 1 role of the same cluster (e.g. either `cox` or `bootsfuehrer`, but not both). | ||||||
|  |  | ||||||
|  | ## Planned rowing adventures :-) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | There are 2 main types: | ||||||
|  |  | ||||||
|  | 1. **Trips:** Trips can be created by every cox. They are "simple", every-day trips. | ||||||
|  | 2. **Events:** Events can be created by everyone who has the `manage_events` role. They are used if multiple coxes are needed, e.g. for "Fetzenfahrt", "Anrudern", .... Additionally, events are shown in public calendar (e.g. on the website). | ||||||
|  |  | ||||||
|  | `TripDetails` extracts the common data for both Trips and Events. | ||||||
|  | Rower can register using the `UserTrip` table.  | ||||||
|  | This table expects either... | ||||||
|  |  | ||||||
|  | - a `user_id`, if a person who has an account registers to the trip/event | ||||||
|  | - a `user_note`, if the cox of a trip, or a `manage_events` user of an event wants to add a guest which has no account | ||||||
|  |  | ||||||
|  | ## Logbook | ||||||
|  |  | ||||||
|  |  | ||||||
|  | If `arrival` is NULL, the boat is assumed to still be on the water. | ||||||
|  | There are a few `LogbookType`s: | ||||||
|  |  | ||||||
|  | - `Wanderfahrt`: Used to check if a user has accomplished their `Fahrtenabzeichen` in the current year. | ||||||
|  | - `Regatta` | ||||||
|  |  | ||||||
|  | If the number of users entered is less than the boat's maximum capacity, the remaining spaces will be automatically assigned to guests. | ||||||
|  |  | ||||||
|  | ## Boat | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ## Trailer | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ## Fetching | ||||||
|  |  | ||||||
|  |  | ||||||
|  | This tables are used to automatically fetch data (every hour). Currently we have: | ||||||
|  |  | ||||||
|  | - `Waterlevel` which fetches the current waterlevel in Linz from hydro (with their explicit permission :-)) | ||||||
|  | - `Weather` weather data from *Open Weather* | ||||||
|  |  | ||||||
|  | ## Misc | ||||||
|  |  | ||||||
|  |  | ||||||
|  | - **Log:** Logs "interesting" activities, to be viewed in the web ui | ||||||
|  | - **Notification**  | ||||||
|  | - **Distance:** Default distances of certain common targets | ||||||
							
								
								
									
										69
									
								
								doc/db/boat.mermaid
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,69 @@ | |||||||
|  | classDiagram | ||||||
|  |     class Boat { | ||||||
|  |         +int id | ||||||
|  |         +string name | ||||||
|  |         +int amount_seats | ||||||
|  |         +int location_id | ||||||
|  |         +int owner | ||||||
|  |         +int year_built | ||||||
|  |         +string boatbuilder | ||||||
|  |         +bool default_shipmaster_only_steering | ||||||
|  |         +bool convert_handoperated_possible | ||||||
|  |         +string default_destination | ||||||
|  |         +bool skull | ||||||
|  |         +bool external | ||||||
|  |         +bool deleted | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class Location { | ||||||
|  |         +int id | ||||||
|  |         +string name | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class Boathouse { | ||||||
|  |         +int id | ||||||
|  |         +int boat_id | ||||||
|  |         +string aisle | ||||||
|  |         +string side | ||||||
|  |         +int level | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class BoatDamage { | ||||||
|  |         +int id | ||||||
|  |         +int boat_id | ||||||
|  |         +string desc | ||||||
|  |         +int user_id_created | ||||||
|  |         +datetime created_at | ||||||
|  |         +int user_id_fixed | ||||||
|  |         +datetime fixed_at | ||||||
|  |         +int user_id_verified | ||||||
|  |         +datetime verified_at | ||||||
|  |         +bool lock_boat | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class BoatReservation { | ||||||
|  |         +int id | ||||||
|  |         +int boat_id | ||||||
|  |         +date start_date | ||||||
|  |         +date end_date | ||||||
|  |         +string time_desc | ||||||
|  |         +string usage | ||||||
|  |         +int user_id_applicant | ||||||
|  |         +int user_id_confirmation | ||||||
|  |         +datetime created_at | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class User { | ||||||
|  |       ... | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Boat "*" -- "1" User : owner | ||||||
|  |     Boat "*" -- "1" Location | ||||||
|  |     Boathouse "*" -- "1" Boat | ||||||
|  |     BoatDamage "*" -- "1" Boat | ||||||
|  |     BoatDamage "*" -- "1" User : created_by | ||||||
|  |     BoatDamage "*" -- "0..1" User : fixed_by | ||||||
|  |     BoatDamage "*" -- "0..1" User : verified_by | ||||||
|  |     BoatReservation "*" -- "1" Boat | ||||||
|  |     BoatReservation "*" -- "1" User : applicant | ||||||
|  |     BoatReservation "*" -- "0..1" User : confirmed_by | ||||||
							
								
								
									
										1
									
								
								doc/db/boat.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg aria-roledescription="classDiagram" role="graphics-document document" viewBox="0 0 825.1484375 855" style="max-width: 825.148px; background-color: transparent;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="100%" id="my-svg"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#my-svg .error-icon{fill:#a44141;}#my-svg .error-text{fill:#ddd;stroke:#ddd;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:lightgrey;stroke:lightgrey;}#my-svg .marker.cross{stroke:lightgrey;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg g.classGroup text{fill:#ccc;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#my-svg g.classGroup text .title{font-weight:bolder;}#my-svg .nodeLabel,#my-svg .edgeLabel{color:#e0dfdf;}#my-svg .edgeLabel .label rect{fill:#1f2020;}#my-svg .label text{fill:#e0dfdf;}#my-svg .edgeLabel .label span{background:#1f2020;}#my-svg .classTitle{font-weight:bolder;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#my-svg .divider{stroke:#ccc;stroke-width:1;}#my-svg g.clickable{cursor:pointer;}#my-svg g.classGroup rect{fill:#1f2020;stroke:#ccc;}#my-svg g.classGroup line{stroke:#ccc;stroke-width:1;}#my-svg .classLabel .box{stroke:none;stroke-width:0;fill:#1f2020;opacity:0.5;}#my-svg .classLabel .label{fill:#ccc;font-size:10px;}#my-svg .relation{stroke:lightgrey;stroke-width:1;fill:none;}#my-svg .dashed-line{stroke-dasharray:3;}#my-svg .dotted-line{stroke-dasharray:1 2;}#my-svg #compositionStart,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #compositionEnd,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionStart,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionEnd,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationStart,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationEnd,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopStart,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopEnd,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg .edgeTerminals{font-size:11px;line-height:initial;}#my-svg .classTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker extension classDiagram" id="my-svg_classDiagram-extensionStart"><path d="M 1,7 L18,13 V 1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker extension classDiagram" id="my-svg_classDiagram-extensionEnd"><path d="M 1,1 V 13 L18,7 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker composition classDiagram" id="my-svg_classDiagram-compositionStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker composition classDiagram" id="my-svg_classDiagram-compositionEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="6" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyStart"><path d="M 5,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="13" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyEnd"><path d="M 18,7 L9,13 L14,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="13" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopStart"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="1" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopEnd"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><g class="root"><g class="clusters"/><g class="edgePaths"><path style="fill:none" class="edge-pattern-solid relation" id="id_Boat_User_1" d="M139.711,678L139.135,683.667C138.559,689.333,137.406,700.667,200.161,719.538C262.915,738.409,389.577,764.818,452.908,778.023L516.238,791.227"/><path style="fill:none" class="edge-pattern-solid relation" id="id_Boat_Location_2" d="M242.162,678L244.971,683.667C247.78,689.333,253.398,700.667,243.99,714.444C234.583,728.222,210.151,744.444,197.935,752.556L185.719,760.667"/><path style="fill:none" class="edge-pattern-solid relation" id="id_Boathouse_Boat_3" d="M111.75,230L111.75,243.333C111.75,256.667,111.75,283.333,112.713,300.833C113.675,318.333,115.601,326.667,116.563,330.833L117.526,335"/><path style="fill:none" class="edge-pattern-solid relation" id="id_BoatDamage_Boat_4" d="M336.91,204.012L311.453,221.677C285.996,239.342,235.082,274.671,209.052,296.502C183.022,318.333,181.876,326.667,181.303,330.833L180.73,335"/><path style="fill:none" class="edge-pattern-solid relation" id="id_BoatDamage_User_5" d="M386.832,285L385.841,289.167C384.849,293.333,382.866,301.667,381.874,338.583C380.883,375.5,380.883,441,380.883,508C380.883,575,380.883,643.5,403.442,689.616C426.001,735.733,471.12,759.465,493.679,771.331L516.238,783.198"/><path style="fill:none" class="edge-pattern-solid relation" id="id_BoatDamage_User_6" d="M462.548,285L463.834,289.167C465.121,293.333,467.693,301.667,468.979,338.583C470.266,375.5,470.266,441,470.266,508C470.266,575,470.266,643.5,477.928,686.835C485.59,730.171,500.914,748.342,508.576,757.427L516.238,766.513"/><path style="fill:none" class="edge-pattern-solid relation" id="id_BoatDamage_User_7" d="M502.676,244.022L512.021,255.018C521.367,266.015,540.059,288.007,549.404,331.754C558.75,375.5,558.75,441,558.75,508C558.75,575,558.75,643.5,557.221,685.25C555.693,727,552.635,742,551.107,749.5L549.578,757"/><path style="fill:none" class="edge-pattern-solid relation" id="id_BoatReservation_Boat_8" d="M622.159,274L619.15,280C616.141,286,610.123,298,557.48,325.821C504.836,353.643,405.566,397.286,355.932,419.107L306.297,440.928"/><path style="fill:none" class="edge-pattern-solid relation" id="id_BoatReservation_User_9" d="M672.826,274L672.201,280C671.576,286,670.327,298,669.703,336.75C669.078,375.5,669.078,441,669.078,508C669.078,575,669.078,643.5,652.035,689.041C634.991,734.582,600.904,757.164,583.86,768.455L566.816,779.746"/><path style="fill:none" class="edge-pattern-solid relation" id="id_BoatReservation_User_10" d="M750.844,274L753.891,280C756.938,286,763.031,298,766.078,336.75C769.125,375.5,769.125,441,769.125,508C769.125,575,769.125,643.5,735.407,690.268C701.689,737.037,634.253,762.074,600.535,774.592L566.816,787.111"/></g><g class="edgeLabels"><g transform="translate(136.25390625, 712)" class="edgeLabel"><g transform="translate(-21.7890625, -9)" class="label"><foreignObject height="18" width="43.578125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"><span class="edgeLabel">owner</span></span></div></foreignObject></g></g><g transform="translate(123.43097887498585, 693.9244385293466)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(497.16837675300997, 767.971031051437)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(236.4945714638542, 700.3413024031892)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(203.59489959445315, 758.4829443529314)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(96.75, 247.5)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(124.43018037783705, 310.3413665531297)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(313.9811061744754, 201.66525613262883)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(195.50573949028518, 319.9824574544449)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g transform="translate(380.8828125, 506.5)" class="edgeLabel"><g transform="translate(-39.5859375, -9)" class="label"><foreignObject height="18" width="79.171875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"><span class="edgeLabel">created_by</span></span></div></foreignObject></g></g><g transform="translate(369.078258009932, 299.5874584688675)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(502.7332112607392, 756.7755266715053)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g transform="translate(470.265625, 506.5)" class="edgeLabel"><g transform="translate(-29.796875, -9)" class="label"><foreignObject height="18" width="59.59375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"><span class="edgeLabel">fixed_by</span></span></div></foreignObject></g></g><g transform="translate(451.7824474859586, 305.25359206268877)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(511.4230142164039, 738.4648690133215)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 36px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">0..1</span></div></foreignObject></g><g transform="translate(558.75, 506.5)" class="edgeLabel"><g transform="translate(-38.6875, -9)" class="label"><foreignObject height="18" width="77.375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"><span class="edgeLabel">verified_by</span></span></div></foreignObject></g></g><g transform="translate(502.5790655821325, 267.07045663806787)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(562.770958609737, 737.8482329279067)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 36px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">0..1</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(600.9055693832728, 282.9191045372611)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(323.35394884645706, 442.6169068399622)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g transform="translate(669.078125, 506.5)" class="edgeLabel"><g transform="translate(-32.0234375, -9)" class="label"><foreignObject height="18" width="64.046875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"><span class="edgeLabel">applicant</span></span></div></foreignObject></g></g><g transform="translate(656.0942860844532, 289.85291648785403)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(584.6896419950095, 777.5863880546077)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g transform="translate(769.125, 506.5)" class="edgeLabel"><g transform="translate(-48.0234375, -9)" class="label"><foreignObject height="18" width="96.046875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"><span class="edgeLabel">confirmed_by</span></span></div></foreignObject></g></g><g transform="translate(745.3930018207337, 296.3950723175959)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(583.4430300371845, 790.0820960054875)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 36px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">0..1</span></div></foreignObject></g></g><g class="nodes"><g transform="translate(157.1484375, 506.5)" id="classId-Boat-0" class="node default"><rect height="343" width="298.296875" y="-171.5" x="-149.1484375" class="outer title-state" style=""/><line y2="-141.5" y1="-141.5" x2="149.1484375" x1="-149.1484375" class="divider"/><line y2="160.5" y1="160.5" x2="149.1484375" x1="-149.1484375" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -17.78125, -164)" height="18" width="35.5625" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Boat</span></div></foreignObject><foreignObject transform="translate( -141.6484375, -130)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -141.6484375, -108)" height="18" width="92.9375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string name</span></div></foreignObject><foreignObject transform="translate( -141.6484375, -86)" height="18" width="131.203125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int amount_seats</span></div></foreignObject><foreignObject transform="translate( -141.6484375, -64)" height="18" width="107.1875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int location_id</span></div></foreignObject><foreignObject transform="translate( -141.6484375, -42)" height="18" width="74.265625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int owner</span></div></foreignObject><foreignObject transform="translate( -141.6484375, -20)" height="18" width="100.0625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int year_built</span></div></foreignObject><foreignObject transform="translate( -141.6484375, 2)" height="18" width="132.09375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string boatbuilder</span></div></foreignObject><foreignObject transform="translate( -141.6484375, 24)" height="18" width="283.296875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+bool default_shipmaster_only_steering</span></div></foreignObject><foreignObject transform="translate( -141.6484375, 46)" height="18" width="271.765625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+bool convert_handoperated_possible</span></div></foreignObject><foreignObject transform="translate( -141.6484375, 68)" height="18" width="187.25"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string default_destination</span></div></foreignObject><foreignObject transform="translate( -141.6484375, 90)" height="18" width="76.046875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+bool skull</span></div></foreignObject><foreignObject transform="translate( -141.6484375, 112)" height="18" width="100.96875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+bool external</span></div></foreignObject><foreignObject transform="translate( -141.6484375, 134)" height="18" width="96.53125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+bool deleted</span></div></foreignObject></g></g><g transform="translate(131.75, 796.5)" id="classId-Location-1" class="node default"><rect height="101" width="107.9375" y="-50.5" x="-53.96875" class="outer title-state" style=""/><line y2="-20.5" y1="-20.5" x2="53.96875" x1="-53.96875" class="divider"/><line y2="39.5" y1="39.5" x2="53.96875" x1="-53.96875" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -33.3359375, -43)" height="18" width="66.671875" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Location</span></div></foreignObject><foreignObject transform="translate( -46.46875, -9)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -46.46875, 13)" height="18" width="92.9375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string name</span></div></foreignObject></g></g><g transform="translate(111.75, 146.5)" id="classId-Boathouse-2" class="node default"><rect height="167" width="100.828125" y="-83.5" x="-50.4140625" class="outer title-state" style=""/><line y2="-53.5" y1="-53.5" x2="50.4140625" x1="-50.4140625" class="divider"/><line y2="72.5" y1="72.5" x2="50.4140625" x1="-50.4140625" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -41.3359375, -76)" height="18" width="82.671875" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Boathouse</span></div></foreignObject><foreignObject transform="translate( -42.9140625, -42)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -42.9140625, -20)" height="18" width="83.1875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int boat_id</span></div></foreignObject><foreignObject transform="translate( -42.9140625, 2)" height="18" width="85.828125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string aisle</span></div></foreignObject><foreignObject transform="translate( -42.9140625, 24)" height="18" width="82.265625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string side</span></div></foreignObject><foreignObject transform="translate( -42.9140625, 46)" height="18" width="63.59375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int level</span></div></foreignObject></g></g><g transform="translate(419.79296875, 146.5)" id="classId-BoatDamage-3" class="node default"><rect height="277" width="165.765625" y="-138.5" x="-82.8828125" class="outer title-state" style=""/><line y2="-108.5" y1="-108.5" x2="82.8828125" x1="-82.8828125" class="divider"/><line y2="127.5" y1="127.5" x2="82.8828125" x1="-82.8828125" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -48.90625, -131)" height="18" width="97.8125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">BoatDamage</span></div></foreignObject><foreignObject transform="translate( -75.3828125, -97)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -75.3828125, -75)" height="18" width="83.1875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int boat_id</span></div></foreignObject><foreignObject transform="translate( -75.3828125, -53)" height="18" width="86.71875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string desc</span></div></foreignObject><foreignObject transform="translate( -75.3828125, -31)" height="18" width="145.4375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int user_id_created</span></div></foreignObject><foreignObject transform="translate( -75.3828125, -9)" height="18" width="150.765625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+datetime created_at</span></div></foreignObject><foreignObject transform="translate( -75.3828125, 13)" height="18" width="125.859375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int user_id_fixed</span></div></foreignObject><foreignObject transform="translate( -75.3828125, 35)" height="18" width="131.203125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+datetime fixed_at</span></div></foreignObject><foreignObject transform="translate( -75.3828125, 57)" height="18" width="143.640625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int user_id_verified</span></div></foreignObject><foreignObject transform="translate( -75.3828125, 79)" height="18" width="148.984375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+datetime verified_at</span></div></foreignObject><foreignObject transform="translate( -75.3828125, 101)" height="18" width="112.53125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+bool lock_boat</span></div></foreignObject></g></g><g transform="translate(686.09765625, 146.5)" id="classId-BoatReservation-4" class="node default"><rect height="255" width="194.21875" y="-127.5" x="-97.109375" class="outer title-state" style=""/><line y2="-97.5" y1="-97.5" x2="97.109375" x1="-97.109375" class="divider"/><line y2="116.5" y1="116.5" x2="97.109375" x1="-97.109375" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -63.578125, -120)" height="18" width="127.15625" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">BoatReservation</span></div></foreignObject><foreignObject transform="translate( -89.609375, -86)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -89.609375, -64)" height="18" width="83.1875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int boat_id</span></div></foreignObject><foreignObject transform="translate( -89.609375, -42)" height="18" width="116.09375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+date start_date</span></div></foreignObject><foreignObject transform="translate( -89.609375, -20)" height="18" width="111.671875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+date end_date</span></div></foreignObject><foreignObject transform="translate( -89.609375, 2)" height="18" width="125.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string time_desc</span></div></foreignObject><foreignObject transform="translate( -89.609375, 24)" height="18" width="96.515625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string usage</span></div></foreignObject><foreignObject transform="translate( -89.609375, 46)" height="18" width="156.109375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int user_id_applicant</span></div></foreignObject><foreignObject transform="translate( -89.609375, 68)" height="18" width="179.21875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int user_id_confirmation</span></div></foreignObject><foreignObject transform="translate( -89.609375, 90)" height="18" width="150.765625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+datetime created_at</span></div></foreignObject></g></g><g transform="translate(541.52734375, 796.5)" id="classId-User-5" class="node default"><rect height="79" width="50.578125" y="-39.5" x="-25.2890625" class="outer title-state" style=""/><line y2="-9.5" y1="-9.5" x2="25.2890625" x1="-25.2890625" class="divider"/><line y2="28.5" y1="28.5" x2="25.2890625" x1="-25.2890625" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -17.7890625, -32)" height="18" width="35.578125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">User</span></div></foreignObject><foreignObject transform="translate( -17.7890625, 2)" height="18" width="13.34375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">...</span></div></foreignObject></g></g></g></g></g></svg> | ||||||
| After Width: | Height: | Size: 32 KiB | 
							
								
								
									
										22
									
								
								doc/db/fetching.mermaid
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | |||||||
|  | classDiagram | ||||||
|  |     class Waterlevel { | ||||||
|  |         +int id | ||||||
|  |         +date day | ||||||
|  |         +string time | ||||||
|  |         +int max | ||||||
|  |         +int min | ||||||
|  |         +int mittel | ||||||
|  |         +int tumax | ||||||
|  |         +int tumin | ||||||
|  |         +int tumittel | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class Weather { | ||||||
|  |         +int id | ||||||
|  |         +date day | ||||||
|  |         +float max_temp | ||||||
|  |         +float wind_gust | ||||||
|  |         +float rain_mm | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |    | ||||||
							
								
								
									
										1
									
								
								doc/db/fetching.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg aria-roledescription="classDiagram" role="graphics-document document" viewBox="0 0 297.875 271" style="max-width: 297.875px; background-color: transparent;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="100%" id="my-svg"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#my-svg .error-icon{fill:#a44141;}#my-svg .error-text{fill:#ddd;stroke:#ddd;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:lightgrey;stroke:lightgrey;}#my-svg .marker.cross{stroke:lightgrey;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg g.classGroup text{fill:#ccc;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#my-svg g.classGroup text .title{font-weight:bolder;}#my-svg .nodeLabel,#my-svg .edgeLabel{color:#e0dfdf;}#my-svg .edgeLabel .label rect{fill:#1f2020;}#my-svg .label text{fill:#e0dfdf;}#my-svg .edgeLabel .label span{background:#1f2020;}#my-svg .classTitle{font-weight:bolder;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#my-svg .divider{stroke:#ccc;stroke-width:1;}#my-svg g.clickable{cursor:pointer;}#my-svg g.classGroup rect{fill:#1f2020;stroke:#ccc;}#my-svg g.classGroup line{stroke:#ccc;stroke-width:1;}#my-svg .classLabel .box{stroke:none;stroke-width:0;fill:#1f2020;opacity:0.5;}#my-svg .classLabel .label{fill:#ccc;font-size:10px;}#my-svg .relation{stroke:lightgrey;stroke-width:1;fill:none;}#my-svg .dashed-line{stroke-dasharray:3;}#my-svg .dotted-line{stroke-dasharray:1 2;}#my-svg #compositionStart,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #compositionEnd,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionStart,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionEnd,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationStart,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationEnd,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopStart,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopEnd,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg .edgeTerminals{font-size:11px;line-height:initial;}#my-svg .classTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker extension classDiagram" id="my-svg_classDiagram-extensionStart"><path d="M 1,7 L18,13 V 1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker extension classDiagram" id="my-svg_classDiagram-extensionEnd"><path d="M 1,1 V 13 L18,7 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker composition classDiagram" id="my-svg_classDiagram-compositionStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker composition classDiagram" id="my-svg_classDiagram-compositionEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="6" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyStart"><path d="M 5,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="13" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyEnd"><path d="M 18,7 L9,13 L14,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="13" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopStart"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="1" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopEnd"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><g class="root"><g class="clusters"/><g class="edgePaths"/><g class="edgeLabels"/><g class="nodes"><g transform="translate(57.0703125, 135.5)" id="classId-Waterlevel-0" class="node default"><rect height="255" width="98.140625" y="-127.5" x="-49.0703125" class="outer title-state" style=""/><line y2="-97.5" y1="-97.5" x2="49.0703125" x1="-49.0703125" class="divider"/><line y2="116.5" y1="116.5" x2="49.0703125" x1="-49.0703125" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -39.7265625, -120)" height="18" width="79.453125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Waterlevel</span></div></foreignObject><foreignObject transform="translate( -41.5703125, -86)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -41.5703125, -64)" height="18" width="70.734375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+date day</span></div></foreignObject><foreignObject transform="translate( -41.5703125, -42)" height="18" width="83.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string time</span></div></foreignObject><foreignObject transform="translate( -41.5703125, -20)" height="18" width="60.921875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int max</span></div></foreignObject><foreignObject transform="translate( -41.5703125, 2)" height="18" width="56.46875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int min</span></div></foreignObject><foreignObject transform="translate( -41.5703125, 24)" height="18" width="68.921875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int mittel</span></div></foreignObject><foreignObject transform="translate( -41.5703125, 46)" height="18" width="74.265625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int tumax</span></div></foreignObject><foreignObject transform="translate( -41.5703125, 68)" height="18" width="69.8125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int tumin</span></div></foreignObject><foreignObject transform="translate( -41.5703125, 90)" height="18" width="82.265625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int tumittel</span></div></foreignObject></g></g><g transform="translate(223.0078125, 135.5)" id="classId-Weather-1" class="node default"><rect height="167" width="133.734375" y="-83.5" x="-66.8671875" class="outer title-state" style=""/><line y2="-53.5" y1="-53.5" x2="66.8671875" x1="-66.8671875" class="divider"/><line y2="72.5" y1="72.5" x2="66.8671875" x1="-66.8671875" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -31.421875, -76)" height="18" width="62.84375" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Weather</span></div></foreignObject><foreignObject transform="translate( -59.3671875, -42)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -59.3671875, -20)" height="18" width="70.734375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+date day</span></div></foreignObject><foreignObject transform="translate( -59.3671875, 2)" height="18" width="118.734375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+float max_temp</span></div></foreignObject><foreignObject transform="translate( -59.3671875, 24)" height="18" width="116.078125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+float wind_gust</span></div></foreignObject><foreignObject transform="translate( -59.3671875, 46)" height="18" width="106.265625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+float rain_mm</span></div></foreignObject></g></g></g></g></g></svg> | ||||||
| After Width: | Height: | Size: 10 KiB | 
							
								
								
									
										38
									
								
								doc/db/logbook.mermaid
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,38 @@ | |||||||
|  | classDiagram | ||||||
|  |     class Logbook { | ||||||
|  |         +int id | ||||||
|  |         +int boat_id | ||||||
|  |         +int shipmaster | ||||||
|  |         +int steering_person | ||||||
|  |         +bool shipmaster_only_steering | ||||||
|  |         +datetime departure | ||||||
|  |         +datetime arrival | ||||||
|  |         +string destination | ||||||
|  |         +int distance_in_km | ||||||
|  |         +string comments | ||||||
|  |         +int logtype | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class LogbookType { | ||||||
|  |         +int id | ||||||
|  |         +string name | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class Rower { | ||||||
|  |         +int logbook_id | ||||||
|  |         +int rower_id | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class User { | ||||||
|  |       ... | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class Boat { | ||||||
|  |       ... | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Logbook "*" -- "1" Boat | ||||||
|  |     Logbook "*" -- "1" User : shipmaster | ||||||
|  |     Logbook "*" -- "1" LogbookType | ||||||
|  |     Rower "*" -- "1" Logbook | ||||||
|  |     Rower "*" -- "1" User | ||||||
							
								
								
									
										1
									
								
								doc/db/logbook.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg aria-roledescription="classDiagram" role="graphics-document document" viewBox="0 0 479.31640625 635" style="max-width: 479.316px; background-color: transparent;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="100%" id="my-svg"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#my-svg .error-icon{fill:#a44141;}#my-svg .error-text{fill:#ddd;stroke:#ddd;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:lightgrey;stroke:lightgrey;}#my-svg .marker.cross{stroke:lightgrey;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg g.classGroup text{fill:#ccc;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#my-svg g.classGroup text .title{font-weight:bolder;}#my-svg .nodeLabel,#my-svg .edgeLabel{color:#e0dfdf;}#my-svg .edgeLabel .label rect{fill:#1f2020;}#my-svg .label text{fill:#e0dfdf;}#my-svg .edgeLabel .label span{background:#1f2020;}#my-svg .classTitle{font-weight:bolder;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#my-svg .divider{stroke:#ccc;stroke-width:1;}#my-svg g.clickable{cursor:pointer;}#my-svg g.classGroup rect{fill:#1f2020;stroke:#ccc;}#my-svg g.classGroup line{stroke:#ccc;stroke-width:1;}#my-svg .classLabel .box{stroke:none;stroke-width:0;fill:#1f2020;opacity:0.5;}#my-svg .classLabel .label{fill:#ccc;font-size:10px;}#my-svg .relation{stroke:lightgrey;stroke-width:1;fill:none;}#my-svg .dashed-line{stroke-dasharray:3;}#my-svg .dotted-line{stroke-dasharray:1 2;}#my-svg #compositionStart,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #compositionEnd,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionStart,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionEnd,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationStart,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationEnd,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopStart,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopEnd,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg .edgeTerminals{font-size:11px;line-height:initial;}#my-svg .classTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker extension classDiagram" id="my-svg_classDiagram-extensionStart"><path d="M 1,7 L18,13 V 1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker extension classDiagram" id="my-svg_classDiagram-extensionEnd"><path d="M 1,1 V 13 L18,7 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker composition classDiagram" id="my-svg_classDiagram-compositionStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker composition classDiagram" id="my-svg_classDiagram-compositionEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="6" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyStart"><path d="M 5,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="13" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyEnd"><path d="M 18,7 L9,13 L14,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="13" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopStart"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="1" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopEnd"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><g class="root"><g class="clusters"/><g class="edgePaths"><path style="fill:none" class="edge-pattern-solid relation" id="id_Logbook_Boat_1" d="M104.595,458L103.682,463.667C102.769,469.333,100.943,480.667,100.03,493.833C99.117,507,99.117,522,99.117,529.5L99.117,537"/><path style="fill:none" class="edge-pattern-solid relation" id="id_Logbook_User_2" d="M169.648,458L171.201,463.667C172.753,469.333,175.859,480.667,186.17,495.841C196.482,511.016,213.999,530.031,222.757,539.539L231.516,549.047"/><path style="fill:none" class="edge-pattern-solid relation" id="id_Logbook_LogbookType_3" d="M249.359,386.671L276.46,404.226C303.561,421.781,357.763,456.89,384.864,480.112C411.965,503.333,411.965,514.667,411.965,520.333L411.965,526"/><path style="fill:none" class="edge-pattern-solid relation" id="id_Rower_Logbook_4" d="M195.258,94.768L184.161,101.306C173.065,107.845,150.872,120.923,139.776,131.628C128.68,142.333,128.68,150.667,128.68,154.833L128.68,159"/><path style="fill:none" class="edge-pattern-solid relation" id="id_Rower_User_5" d="M295.145,109L298.308,113.167C301.472,117.333,307.798,125.667,310.962,158.917C314.125,192.167,314.125,250.333,314.125,310C314.125,369.667,314.125,430.833,308.786,469.287C303.448,507.74,292.771,523.48,287.432,531.35L282.094,539.22"/></g><g class="edgeLabels"><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(87.04085562560904, 472.93282017540025)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(109.11718874999995, 514.5000010714285)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g transform="translate(178.96484375, 492)" class="edgeLabel"><g transform="translate(-39.125, -9)" class="label"><foreignObject height="18" width="78.25"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"><span class="edgeLabel">shipmaster</span></span></div></foreignObject></g></g><g transform="translate(159.80612717025386, 478.8421081151056)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(225.69137113402684, 521.013028651661)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(255.8922067982493, 408.77480399297514)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(421.5407662903117, 503.3638240725512)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(172.56555899206518, 90.72886543263846)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(142.40747355283347, 143.09219729055113)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(292.13521073682756, 131.59771748907048)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(299.3312305692518, 528.1578500971559)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g></g><g class="nodes"><g transform="translate(128.6796875, 308.5)" id="classId-Logbook-0" class="node default"><rect height="299" width="241.359375" y="-149.5" x="-120.6796875" class="outer title-state" style=""/><line y2="-119.5" y1="-119.5" x2="120.6796875" x1="-120.6796875" class="divider"/><line y2="138.5" y1="138.5" x2="120.6796875" x1="-120.6796875" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -33.7734375, -142)" height="18" width="67.546875" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Logbook</span></div></foreignObject><foreignObject transform="translate( -113.1796875, -108)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -113.1796875, -86)" height="18" width="83.1875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int boat_id</span></div></foreignObject><foreignObject transform="translate( -113.1796875, -64)" height="18" width="108.9375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int shipmaster</span></div></foreignObject><foreignObject transform="translate( -113.1796875, -42)" height="18" width="145.4375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int steering_person</span></div></foreignObject><foreignObject transform="translate( -113.1796875, -20)" height="18" width="226.359375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+bool shipmaster_only_steering</span></div></foreignObject><foreignObject transform="translate( -113.1796875, 2)" height="18" width="143.65625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+datetime departure</span></div></foreignObject><foreignObject transform="translate( -113.1796875, 24)" height="18" width="118.71875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+datetime arrival</span></div></foreignObject><foreignObject transform="translate( -113.1796875, 46)" height="18" width="130.3125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string destination</span></div></foreignObject><foreignObject transform="translate( -113.1796875, 68)" height="18" width="141.859375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int distance_in_km</span></div></foreignObject><foreignObject transform="translate( -113.1796875, 90)" height="18" width="126.71875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string comments</span></div></foreignObject><foreignObject transform="translate( -113.1796875, 112)" height="18" width="82.28125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int logtype</span></div></foreignObject></g></g><g transform="translate(411.96484375, 576.5)" id="classId-LogbookType-1" class="node default"><rect height="101" width="118.703125" y="-50.5" x="-59.3515625" class="outer title-state" style=""/><line y2="-20.5" y1="-20.5" x2="59.3515625" x1="-59.3515625" class="divider"/><line y2="39.5" y1="39.5" x2="59.3515625" x1="-59.3515625" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -51.8515625, -43)" height="18" width="103.703125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">LogbookType</span></div></foreignObject><foreignObject transform="translate( -51.8515625, -9)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -51.8515625, 13)" height="18" width="92.9375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string name</span></div></foreignObject></g></g><g transform="translate(256.8046875, 58.5)" id="classId-Rower-2" class="node default"><rect height="101" width="123.09375" y="-50.5" x="-61.546875" class="outer title-state" style=""/><line y2="-20.5" y1="-20.5" x2="61.546875" x1="-61.546875" class="divider"/><line y2="39.5" y1="39.5" x2="61.546875" x1="-61.546875" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -24.453125, -43)" height="18" width="48.90625" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Rower</span></div></foreignObject><foreignObject transform="translate( -54.046875, -9)" height="18" width="108.09375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int logbook_id</span></div></foreignObject><foreignObject transform="translate( -54.046875, 13)" height="18" width="92.046875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int rower_id</span></div></foreignObject></g></g><g transform="translate(256.8046875, 576.5)" id="classId-User-3" class="node default"><rect height="79" width="50.578125" y="-39.5" x="-25.2890625" class="outer title-state" style=""/><line y2="-9.5" y1="-9.5" x2="25.2890625" x1="-25.2890625" class="divider"/><line y2="28.5" y1="28.5" x2="25.2890625" x1="-25.2890625" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -17.7890625, -32)" height="18" width="35.578125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">User</span></div></foreignObject><foreignObject transform="translate( -17.7890625, 2)" height="18" width="13.34375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">...</span></div></foreignObject></g></g><g transform="translate(99.1171875, 576.5)" id="classId-Boat-4" class="node default"><rect height="79" width="50.5625" y="-39.5" x="-25.28125" class="outer title-state" style=""/><line y2="-9.5" y1="-9.5" x2="25.28125" x1="-25.28125" class="divider"/><line y2="28.5" y1="28.5" x2="25.28125" x1="-25.28125" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -17.78125, -32)" height="18" width="35.5625" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Boat</span></div></foreignObject><foreignObject transform="translate( -17.78125, 2)" height="18" width="13.34375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">...</span></div></foreignObject></g></g></g></g></g></svg> | ||||||
| After Width: | Height: | Size: 19 KiB | 
							
								
								
									
										30
									
								
								doc/db/misc.mermaid
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,30 @@ | |||||||
|  | classDiagram | ||||||
|  |     class Log { | ||||||
|  |         +int id | ||||||
|  |         +string msg | ||||||
|  |         +datetime created_at | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class Notification { | ||||||
|  |         +int id | ||||||
|  |         +int user_id | ||||||
|  |         +string message | ||||||
|  |         +datetime read_at | ||||||
|  |         +datetime created_at | ||||||
|  |         +string category | ||||||
|  |         +string action_after_reading | ||||||
|  |         +string link | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class Distance { | ||||||
|  |         +int id | ||||||
|  |         +string destination | ||||||
|  |         +int distance_in_km | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class User { | ||||||
|  |       ... | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     %% Relationships | ||||||
|  |     Notification "*" -- "1" User | ||||||
							
								
								
									
										1
									
								
								doc/db/misc.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg aria-roledescription="classDiagram" role="graphics-document document" viewBox="0 0 652.421875 378" style="max-width: 652.422px; background-color: transparent;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="100%" id="my-svg"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#my-svg .error-icon{fill:#a44141;}#my-svg .error-text{fill:#ddd;stroke:#ddd;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:lightgrey;stroke:lightgrey;}#my-svg .marker.cross{stroke:lightgrey;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg g.classGroup text{fill:#ccc;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#my-svg g.classGroup text .title{font-weight:bolder;}#my-svg .nodeLabel,#my-svg .edgeLabel{color:#e0dfdf;}#my-svg .edgeLabel .label rect{fill:#1f2020;}#my-svg .label text{fill:#e0dfdf;}#my-svg .edgeLabel .label span{background:#1f2020;}#my-svg .classTitle{font-weight:bolder;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#my-svg .divider{stroke:#ccc;stroke-width:1;}#my-svg g.clickable{cursor:pointer;}#my-svg g.classGroup rect{fill:#1f2020;stroke:#ccc;}#my-svg g.classGroup line{stroke:#ccc;stroke-width:1;}#my-svg .classLabel .box{stroke:none;stroke-width:0;fill:#1f2020;opacity:0.5;}#my-svg .classLabel .label{fill:#ccc;font-size:10px;}#my-svg .relation{stroke:lightgrey;stroke-width:1;fill:none;}#my-svg .dashed-line{stroke-dasharray:3;}#my-svg .dotted-line{stroke-dasharray:1 2;}#my-svg #compositionStart,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #compositionEnd,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionStart,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionEnd,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationStart,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationEnd,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopStart,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopEnd,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg .edgeTerminals{font-size:11px;line-height:initial;}#my-svg .classTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker extension classDiagram" id="my-svg_classDiagram-extensionStart"><path d="M 1,7 L18,13 V 1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker extension classDiagram" id="my-svg_classDiagram-extensionEnd"><path d="M 1,1 V 13 L18,7 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker composition classDiagram" id="my-svg_classDiagram-compositionStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker composition classDiagram" id="my-svg_classDiagram-compositionEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="6" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyStart"><path d="M 5,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="13" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyEnd"><path d="M 18,7 L9,13 L14,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="13" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopStart"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="1" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopEnd"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><g class="root"><g class="clusters"/><g class="edgePaths"><path style="fill:none" class="edge-pattern-solid relation" id="id_Notification_User_1" d="M330.664,241L330.664,245.167C330.664,249.333,330.664,257.667,330.664,266C330.664,274.333,330.664,282.667,330.664,286.833L330.664,291"/></g><g class="edgeLabels"><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(315.6640612500001, 258.4999989285714)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(340.66406125, 268.4999989285714)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g></g><g class="nodes"><g transform="translate(90.8828125, 124.5)" id="classId-Log-0" class="node default"><rect height="123" width="165.765625" y="-61.5" x="-82.8828125" class="outer title-state" style=""/><line y2="-31.5" y1="-31.5" x2="82.8828125" x1="-82.8828125" class="divider"/><line y2="50.5" y1="50.5" x2="82.8828125" x1="-82.8828125" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -14.6640625, -54)" height="18" width="29.328125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Log</span></div></foreignObject><foreignObject transform="translate( -75.3828125, -20)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -75.3828125, 2)" height="18" width="83.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string msg</span></div></foreignObject><foreignObject transform="translate( -75.3828125, 24)" height="18" width="150.765625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+datetime created_at</span></div></foreignObject></g></g><g transform="translate(330.6640625, 124.5)" id="classId-Notification-1" class="node default"><rect height="233" width="213.796875" y="-116.5" x="-106.8984375" class="outer title-state" style=""/><line y2="-86.5" y1="-86.5" x2="106.8984375" x1="-106.8984375" class="divider"/><line y2="105.5" y1="105.5" x2="106.8984375" x1="-106.8984375" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -44, -109)" height="18" width="88" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Notification</span></div></foreignObject><foreignObject transform="translate( -99.3984375, -75)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -99.3984375, -53)" height="18" width="83.171875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int user_id</span></div></foreignObject><foreignObject transform="translate( -99.3984375, -31)" height="18" width="117.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string message</span></div></foreignObject><foreignObject transform="translate( -99.3984375, -9)" height="18" width="129.421875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+datetime read_at</span></div></foreignObject><foreignObject transform="translate( -99.3984375, 13)" height="18" width="150.765625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+datetime created_at</span></div></foreignObject><foreignObject transform="translate( -99.3984375, 35)" height="18" width="114.28125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string category</span></div></foreignObject><foreignObject transform="translate( -99.3984375, 57)" height="18" width="198.796875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string action_after_reading</span></div></foreignObject><foreignObject transform="translate( -99.3984375, 79)" height="18" width="76.921875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string link</span></div></foreignObject></g></g><g transform="translate(565.9921875, 124.5)" id="classId-Distance-2" class="node default"><rect height="123" width="156.859375" y="-61.5" x="-78.4296875" class="outer title-state" style=""/><line y2="-31.5" y1="-31.5" x2="78.4296875" x1="-78.4296875" class="divider"/><line y2="50.5" y1="50.5" x2="78.4296875" x1="-78.4296875" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -33.3515625, -54)" height="18" width="66.703125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Distance</span></div></foreignObject><foreignObject transform="translate( -70.9296875, -20)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -70.9296875, 2)" height="18" width="130.3125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string destination</span></div></foreignObject><foreignObject transform="translate( -70.9296875, 24)" height="18" width="141.859375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int distance_in_km</span></div></foreignObject></g></g><g transform="translate(330.6640625, 330.5)" id="classId-User-3" class="node default"><rect height="79" width="50.578125" y="-39.5" x="-25.2890625" class="outer title-state" style=""/><line y2="-9.5" y1="-9.5" x2="25.2890625" x1="-25.2890625" class="divider"/><line y2="28.5" y1="28.5" x2="25.2890625" x1="-25.2890625" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -17.7890625, -32)" height="18" width="35.578125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">User</span></div></foreignObject><foreignObject transform="translate( -17.7890625, 2)" height="18" width="13.34375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">...</span></div></foreignObject></g></g></g></g></g></svg> | ||||||
| After Width: | Height: | Size: 13 KiB | 
							
								
								
									
										56
									
								
								doc/db/planned.mermaid
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,56 @@ | |||||||
|  | classDiagram | ||||||
|  |     class TripType { | ||||||
|  |         +int id | ||||||
|  |         +string name | ||||||
|  |         +string desc | ||||||
|  |         +string question | ||||||
|  |         +string icon | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class TripDetails { | ||||||
|  |         +int id | ||||||
|  |         +string planned_starting_time | ||||||
|  |         +int max_people | ||||||
|  |         +string day | ||||||
|  |         +bool allow_guests | ||||||
|  |         +string notes | ||||||
|  |         +bool always_show | ||||||
|  |         +bool is_locked | ||||||
|  |         +int trip_type_id | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class PlannedEvent { | ||||||
|  |         +int id | ||||||
|  |         +string name | ||||||
|  |         +int planned_amount_cox | ||||||
|  |         +int trip_details_id | ||||||
|  |         +string created_at | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class Trip { | ||||||
|  |         +int id | ||||||
|  |         +int cox_id | ||||||
|  |         +int trip_details_id | ||||||
|  |         +int planned_event_id | ||||||
|  |         +string created_at | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class UserTrip { | ||||||
|  |         +int user_id | ||||||
|  |         +string user_note | ||||||
|  |         +int trip_details_id | ||||||
|  |         +string created_at | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class User { | ||||||
|  |       ... | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     TripType "1" -- "*" TripDetails | ||||||
|  |     TripDetails "1" -- "*" PlannedEvent | ||||||
|  |     Trip "*" -- "1" TripDetails | ||||||
|  |     Trip "*" -- "1" PlannedEvent | ||||||
|  |     UserTrip "*" -- "1" TripDetails | ||||||
|  |     Trip "*" -- "1" User : cox | ||||||
|  |     UserTrip "*" -- "1" User | ||||||
							
								
								
									
										1
									
								
								doc/db/planned.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg aria-roledescription="classDiagram" role="graphics-document document" viewBox="0 0 589.3359375 723" style="max-width: 589.336px; background-color: transparent;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="100%" id="my-svg"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#my-svg .error-icon{fill:#a44141;}#my-svg .error-text{fill:#ddd;stroke:#ddd;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:lightgrey;stroke:lightgrey;}#my-svg .marker.cross{stroke:lightgrey;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg g.classGroup text{fill:#ccc;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#my-svg g.classGroup text .title{font-weight:bolder;}#my-svg .nodeLabel,#my-svg .edgeLabel{color:#e0dfdf;}#my-svg .edgeLabel .label rect{fill:#1f2020;}#my-svg .label text{fill:#e0dfdf;}#my-svg .edgeLabel .label span{background:#1f2020;}#my-svg .classTitle{font-weight:bolder;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#my-svg .divider{stroke:#ccc;stroke-width:1;}#my-svg g.clickable{cursor:pointer;}#my-svg g.classGroup rect{fill:#1f2020;stroke:#ccc;}#my-svg g.classGroup line{stroke:#ccc;stroke-width:1;}#my-svg .classLabel .box{stroke:none;stroke-width:0;fill:#1f2020;opacity:0.5;}#my-svg .classLabel .label{fill:#ccc;font-size:10px;}#my-svg .relation{stroke:lightgrey;stroke-width:1;fill:none;}#my-svg .dashed-line{stroke-dasharray:3;}#my-svg .dotted-line{stroke-dasharray:1 2;}#my-svg #compositionStart,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #compositionEnd,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionStart,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionEnd,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationStart,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationEnd,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopStart,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopEnd,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg .edgeTerminals{font-size:11px;line-height:initial;}#my-svg .classTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker extension classDiagram" id="my-svg_classDiagram-extensionStart"><path d="M 1,7 L18,13 V 1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker extension classDiagram" id="my-svg_classDiagram-extensionEnd"><path d="M 1,1 V 13 L18,7 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker composition classDiagram" id="my-svg_classDiagram-compositionStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker composition classDiagram" id="my-svg_classDiagram-compositionEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="6" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyStart"><path d="M 5,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="13" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyEnd"><path d="M 18,7 L9,13 L14,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="13" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopStart"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="1" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopEnd"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><g class="root"><g class="clusters"/><g class="edgePaths"><path style="fill:none" class="edge-pattern-solid relation" id="id_TripType_TripDetails_1" d="M100.68,175L100.68,180.667C100.68,186.333,100.68,197.667,101.381,209C102.083,220.333,103.487,231.667,104.188,237.333L104.89,243"/><path style="fill:none" class="edge-pattern-solid relation" id="id_TripDetails_PlannedEvent_2" d="M120.68,498L120.68,502.167C120.68,506.333,120.68,514.667,131.446,525.958C142.212,537.25,163.745,551.5,174.511,558.625L185.277,565.75"/><path style="fill:none" class="edge-pattern-solid relation" id="id_Trip_TripDetails_3" d="M229.757,175L224.929,180.667C220.101,186.333,210.445,197.667,202.806,209C195.167,220.333,189.546,231.667,186.735,237.333L183.924,243"/><path style="fill:none" class="edge-pattern-solid relation" id="id_Trip_PlannedEvent_4" d="M300.898,175L300.898,180.667C300.898,186.333,300.898,197.667,300.898,230.25C300.898,262.833,300.898,316.667,300.898,369C300.898,421.333,300.898,472.167,300.274,501.75C299.649,531.333,298.399,539.667,297.774,543.833L297.15,548"/><path style="fill:none" class="edge-pattern-solid relation" id="id_UserTrip_TripDetails_5" d="M464.909,164L460.335,171.5C455.761,179,446.613,194,408.021,218.842C369.43,243.685,301.395,278.37,267.377,295.712L233.359,313.055"/><path style="fill:none" class="edge-pattern-solid relation" id="id_Trip_User_6" d="M374.885,175L379.906,180.667C384.927,186.333,394.97,197.667,413.107,223.704C431.243,249.741,457.475,290.482,470.591,310.853L483.707,331.223"/><path style="fill:none" class="edge-pattern-solid relation" id="id_UserTrip_User_7" d="M515.295,164L515.934,171.5C516.572,179,517.848,194,517.211,221.833C516.574,249.667,514.024,290.333,512.749,310.667L511.473,331"/></g><g class="edgeLabels"><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(85.74123126895586, 192.54889358487895)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g></g><g transform="translate(112.67598010276458, 218.82506657703127)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(110.37975770723475, 517.2131907237797)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g></g><g transform="translate(173.9619280316911, 538.5833348850657)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(206.98977340825164, 178.59287202327315)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(200.1380412921721, 228.98825858921583)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(285.89843875, 192.50000107142858)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(308.93739583120146, 527.2499290496063)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(442.9909762769256, 171.1304408290506)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(250.76306675409873, 313.4700698998099)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g transform="translate(405.01171875, 209)" class="edgeLabel"><g transform="translate(-12.453125, -9)" class="label"><foreignObject height="18" width="24.90625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"><span class="edgeLabel">cox</span></span></div></foreignObject></g></g><g transform="translate(375.2642134920149, 198.04574431047698)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(481.8451560278755, 303.38887030430874)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(501.8332413904589, 182.70896381395303)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(522.5394365266823, 309.47323674342846)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g></g><g class="nodes"><g transform="translate(100.6796875, 91.5)" id="classId-TripType-0" class="node default"><rect height="167" width="128.40625" y="-83.5" x="-64.203125" class="outer title-state" style=""/><line y2="-53.5" y1="-53.5" x2="64.203125" x1="-64.203125" class="divider"/><line y2="72.5" y1="72.5" x2="64.203125" x1="-64.203125" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -32.75, -76)" height="18" width="65.5" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">TripType</span></div></foreignObject><foreignObject transform="translate( -56.703125, -42)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -56.703125, -20)" height="18" width="92.9375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string name</span></div></foreignObject><foreignObject transform="translate( -56.703125, 2)" height="18" width="86.71875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string desc</span></div></foreignObject><foreignObject transform="translate( -56.703125, 24)" height="18" width="113.40625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string question</span></div></foreignObject><foreignObject transform="translate( -56.703125, 46)" height="18" width="82.265625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string icon</span></div></foreignObject></g></g><g transform="translate(120.6796875, 370.5)" id="classId-TripDetails-1" class="node default"><rect height="255" width="225.359375" y="-127.5" x="-112.6796875" class="outer title-state" style=""/><line y2="-97.5" y1="-97.5" x2="112.6796875" x1="-112.6796875" class="divider"/><line y2="116.5" y1="116.5" x2="112.6796875" x1="-112.6796875" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -40.90625, -120)" height="18" width="81.8125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">TripDetails</span></div></foreignObject><foreignObject transform="translate( -105.1796875, -86)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -105.1796875, -64)" height="18" width="210.359375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string planned_starting_time</span></div></foreignObject><foreignObject transform="translate( -105.1796875, -42)" height="18" width="117.859375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int max_people</span></div></foreignObject><foreignObject transform="translate( -105.1796875, -20)" height="18" width="78.71875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string day</span></div></foreignObject><foreignObject transform="translate( -105.1796875, 2)" height="18" width="136.546875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+bool allow_guests</span></div></foreignObject><foreignObject transform="translate( -105.1796875, 24)" height="18" width="92.0625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string notes</span></div></foreignObject><foreignObject transform="translate( -105.1796875, 46)" height="18" width="139.203125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+bool always_show</span></div></foreignObject><foreignObject transform="translate( -105.1796875, 68)" height="18" width="110.75"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+bool is_locked</span></div></foreignObject><foreignObject transform="translate( -105.1796875, 90)" height="18" width="113.40625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int trip_type_id</span></div></foreignObject></g></g><g transform="translate(284.62890625, 631.5)" id="classId-PlannedEvent-2" class="node default"><rect height="167" width="198.703125" y="-83.5" x="-99.3515625" class="outer title-state" style=""/><line y2="-53.5" y1="-53.5" x2="99.3515625" x1="-99.3515625" class="divider"/><line y2="72.5" y1="72.5" x2="99.3515625" x1="-99.3515625" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -52.90625, -76)" height="18" width="105.8125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">PlannedEvent</span></div></foreignObject><foreignObject transform="translate( -91.8515625, -42)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -91.8515625, -20)" height="18" width="92.9375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string name</span></div></foreignObject><foreignObject transform="translate( -91.8515625, 2)" height="18" width="183.703125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int planned_amount_cox</span></div></foreignObject><foreignObject transform="translate( -91.8515625, 24)" height="18" width="129.421875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int trip_details_id</span></div></foreignObject><foreignObject transform="translate( -91.8515625, 46)" height="18" width="128.53125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string created_at</span></div></foreignObject></g></g><g transform="translate(300.8984375, 91.5)" id="classId-Trip-3" class="node default"><rect height="167" width="172.03125" y="-83.5" x="-86.015625" class="outer title-state" style=""/><line y2="-53.5" y1="-53.5" x2="86.015625" x1="-86.015625" class="divider"/><line y2="72.5" y1="72.5" x2="86.015625" x1="-86.015625" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -14.671875, -76)" height="18" width="29.34375" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Trip</span></div></foreignObject><foreignObject transform="translate( -78.515625, -42)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -78.515625, -20)" height="18" width="76.9375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int cox_id</span></div></foreignObject><foreignObject transform="translate( -78.515625, 2)" height="18" width="129.421875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int trip_details_id</span></div></foreignObject><foreignObject transform="translate( -78.515625, 24)" height="18" width="157.03125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int planned_event_id</span></div></foreignObject><foreignObject transform="translate( -78.515625, 46)" height="18" width="128.53125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string created_at</span></div></foreignObject></g></g><g transform="translate(509.125, 91.5)" id="classId-UserTrip-4" class="node default"><rect height="145" width="144.421875" y="-72.5" x="-72.2109375" class="outer title-state" style=""/><line y2="-42.5" y1="-42.5" x2="72.2109375" x1="-72.2109375" class="divider"/><line y2="61.5" y1="61.5" x2="72.2109375" x1="-72.2109375" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -32.4609375, -65)" height="18" width="64.921875" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">UserTrip</span></div></foreignObject><foreignObject transform="translate( -64.7109375, -31)" height="18" width="83.171875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int user_id</span></div></foreignObject><foreignObject transform="translate( -64.7109375, -9)" height="18" width="124.078125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string user_note</span></div></foreignObject><foreignObject transform="translate( -64.7109375, 13)" height="18" width="129.421875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int trip_details_id</span></div></foreignObject><foreignObject transform="translate( -64.7109375, 35)" height="18" width="128.53125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string created_at</span></div></foreignObject></g></g><g transform="translate(508.99609375, 370.5)" id="classId-User-5" class="node default"><rect height="79" width="50.578125" y="-39.5" x="-25.2890625" class="outer title-state" style=""/><line y2="-9.5" y1="-9.5" x2="25.2890625" x1="-25.2890625" class="divider"/><line y2="28.5" y1="28.5" x2="25.2890625" x1="-25.2890625" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -17.7890625, -32)" height="18" width="35.578125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">User</span></div></foreignObject><foreignObject transform="translate( -17.7890625, 2)" height="18" width="13.34375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">...</span></div></foreignObject></g></g></g></g></g></svg> | ||||||
| After Width: | Height: | Size: 25 KiB | 
							
								
								
									
										3
									
								
								doc/db/recreate.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,3 @@ | |||||||
|  | #!/bin/bash | ||||||
|  |  | ||||||
|  | find . -name "*.mermaid" -type f -exec sh -c 'mmdc -i "$1" -o "${1%.mermaid}.svg" -b transparent -t dark' sh {} \; | ||||||
							
								
								
									
										25
									
								
								doc/db/trailer.mermaid
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,25 @@ | |||||||
|  | classDiagram | ||||||
|  |     class Trailer { | ||||||
|  |         +int id | ||||||
|  |         +string name | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class TrailerReservation { | ||||||
|  |         +int id | ||||||
|  |         +int trailer_id | ||||||
|  |         +date start_date | ||||||
|  |         +date end_date | ||||||
|  |         +string time_desc | ||||||
|  |         +string usage | ||||||
|  |         +int user_id_applicant | ||||||
|  |         +int user_id_confirmation | ||||||
|  |         +datetime created_at | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class User { | ||||||
|  |       ... | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     TrailerReservation "*" -- "1" Trailer | ||||||
|  |     TrailerReservation "*" -- "1" User : applicant | ||||||
|  |     TrailerReservation "*" -- "0..1" User : confirmed_by | ||||||
							
								
								
									
										1
									
								
								doc/db/trailer.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg aria-roledescription="classDiagram" role="graphics-document document" viewBox="0 0 308.6796875 440" style="max-width: 308.68px; background-color: transparent;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="100%" id="my-svg"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#my-svg .error-icon{fill:#a44141;}#my-svg .error-text{fill:#ddd;stroke:#ddd;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:lightgrey;stroke:lightgrey;}#my-svg .marker.cross{stroke:lightgrey;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg g.classGroup text{fill:#ccc;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#my-svg g.classGroup text .title{font-weight:bolder;}#my-svg .nodeLabel,#my-svg .edgeLabel{color:#e0dfdf;}#my-svg .edgeLabel .label rect{fill:#1f2020;}#my-svg .label text{fill:#e0dfdf;}#my-svg .edgeLabel .label span{background:#1f2020;}#my-svg .classTitle{font-weight:bolder;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#my-svg .divider{stroke:#ccc;stroke-width:1;}#my-svg g.clickable{cursor:pointer;}#my-svg g.classGroup rect{fill:#1f2020;stroke:#ccc;}#my-svg g.classGroup line{stroke:#ccc;stroke-width:1;}#my-svg .classLabel .box{stroke:none;stroke-width:0;fill:#1f2020;opacity:0.5;}#my-svg .classLabel .label{fill:#ccc;font-size:10px;}#my-svg .relation{stroke:lightgrey;stroke-width:1;fill:none;}#my-svg .dashed-line{stroke-dasharray:3;}#my-svg .dotted-line{stroke-dasharray:1 2;}#my-svg #compositionStart,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #compositionEnd,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionStart,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionEnd,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationStart,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationEnd,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopStart,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopEnd,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg .edgeTerminals{font-size:11px;line-height:initial;}#my-svg .classTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker extension classDiagram" id="my-svg_classDiagram-extensionStart"><path d="M 1,7 L18,13 V 1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker extension classDiagram" id="my-svg_classDiagram-extensionEnd"><path d="M 1,1 V 13 L18,7 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker composition classDiagram" id="my-svg_classDiagram-compositionStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker composition classDiagram" id="my-svg_classDiagram-compositionEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="6" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyStart"><path d="M 5,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="13" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyEnd"><path d="M 18,7 L9,13 L14,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="13" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopStart"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="1" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopEnd"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><g class="root"><g class="clusters"/><g class="edgePaths"><path style="fill:none" class="edge-pattern-solid relation" id="id_TrailerReservation_Trailer_1" d="M81.051,263L77.871,268.667C74.69,274.333,68.329,285.667,65.149,297C61.969,308.333,61.969,319.667,61.969,325.333L61.969,331"/><path style="fill:none" class="edge-pattern-solid relation" id="id_TrailerReservation_User_2" d="M152.609,263L152.609,268.667C152.609,274.333,152.609,285.667,157.049,298.833C161.489,312,170.369,327,174.809,334.5L179.249,342"/><path style="fill:none" class="edge-pattern-solid relation" id="id_TrailerReservation_User_3" d="M231.594,263L235.104,268.667C238.615,274.333,245.635,285.667,244.706,298.833C243.776,312,234.896,327,230.456,334.5L226.017,342"/></g><g class="edgeLabels"><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(59.4053421736739, 270.919346054189)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(72.21198785349168, 308.77448466334016)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g transform="translate(152.609375, 297)" class="edgeLabel"><g transform="translate(-32.0234375, -9)" class="label"><foreignObject height="18" width="64.046875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"><span class="edgeLabel">applicant</span></span></div></foreignObject></g></g><g transform="translate(137.86568884349458, 280.6494351366758)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(178.24197705723373, 314.29962991184635)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g><g transform="translate(252.65625, 297)" class="edgeLabel"><g transform="translate(-48.0234375, -9)" class="label"><foreignObject height="18" width="96.046875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"><span class="edgeLabel">confirmed_by</span></span></div></foreignObject></g></g><g transform="translate(228.05818717831355, 285.77608024983135)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g></g><g transform="translate(242.83917505280118, 329.5822485013881)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 36px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">0..1</span></div></foreignObject></g></g><g class="nodes"><g transform="translate(61.96875, 381.5)" id="classId-Trailer-0" class="node default"><rect height="101" width="107.9375" y="-50.5" x="-53.96875" class="outer title-state" style=""/><line y2="-20.5" y1="-20.5" x2="53.96875" x1="-53.96875" class="divider"/><line y2="39.5" y1="39.5" x2="53.96875" x1="-53.96875" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -24.015625, -43)" height="18" width="48.03125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Trailer</span></div></foreignObject><foreignObject transform="translate( -46.46875, -9)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -46.46875, 13)" height="18" width="92.9375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string name</span></div></foreignObject></g></g><g transform="translate(152.609375, 135.5)" id="classId-TrailerReservation-1" class="node default"><rect height="255" width="194.21875" y="-127.5" x="-97.109375" class="outer title-state" style=""/><line y2="-97.5" y1="-97.5" x2="97.109375" x1="-97.109375" class="divider"/><line y2="116.5" y1="116.5" x2="97.109375" x1="-97.109375" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -69.8125, -120)" height="18" width="139.625" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">TrailerReservation</span></div></foreignObject><foreignObject transform="translate( -89.609375, -86)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -89.609375, -64)" height="18" width="92.046875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int trailer_id</span></div></foreignObject><foreignObject transform="translate( -89.609375, -42)" height="18" width="116.09375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+date start_date</span></div></foreignObject><foreignObject transform="translate( -89.609375, -20)" height="18" width="111.671875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+date end_date</span></div></foreignObject><foreignObject transform="translate( -89.609375, 2)" height="18" width="125.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string time_desc</span></div></foreignObject><foreignObject transform="translate( -89.609375, 24)" height="18" width="96.515625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string usage</span></div></foreignObject><foreignObject transform="translate( -89.609375, 46)" height="18" width="156.109375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int user_id_applicant</span></div></foreignObject><foreignObject transform="translate( -89.609375, 68)" height="18" width="179.21875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int user_id_confirmation</span></div></foreignObject><foreignObject transform="translate( -89.609375, 90)" height="18" width="150.765625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+datetime created_at</span></div></foreignObject></g></g><g transform="translate(202.6328125, 381.5)" id="classId-User-2" class="node default"><rect height="79" width="50.578125" y="-39.5" x="-25.2890625" class="outer title-state" style=""/><line y2="-9.5" y1="-9.5" x2="25.2890625" x1="-25.2890625" class="divider"/><line y2="28.5" y1="28.5" x2="25.2890625" x1="-25.2890625" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -17.7890625, -32)" height="18" width="35.578125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">User</span></div></foreignObject><foreignObject transform="translate( -17.7890625, 2)" height="18" width="13.34375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">...</span></div></foreignObject></g></g></g></g></g></svg> | ||||||
| After Width: | Height: | Size: 14 KiB | 
							
								
								
									
										42
									
								
								doc/db/user.mermaid
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,42 @@ | |||||||
|  | classDiagram | ||||||
|  |     class User { | ||||||
|  |         +int id | ||||||
|  |         +string name | ||||||
|  |         +string pw | ||||||
|  |         +bool deleted | ||||||
|  |         +datetime last_access | ||||||
|  |         +string dob | ||||||
|  |         +string weight | ||||||
|  |         +string sex | ||||||
|  |         +string dirty_thirty | ||||||
|  |         +string dirty_dozen | ||||||
|  |         +string member_since_date | ||||||
|  |         +string birthdate | ||||||
|  |         +string mail | ||||||
|  |         +string nickname | ||||||
|  |         +string notes | ||||||
|  |         +string phone | ||||||
|  |         +string address | ||||||
|  |         +int family_id | ||||||
|  |         +blob membership_pdf | ||||||
|  |         +string user_token | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class Family { | ||||||
|  |         +int id | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class Role { | ||||||
|  |         +int id | ||||||
|  |         +string name | ||||||
|  |         +string cluster | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     class UserRole { | ||||||
|  |         +int user_id | ||||||
|  |         +int role_id | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     User "1" -- "*" UserRole | ||||||
|  |     Role "1" -- "*" UserRole | ||||||
|  |     User "1" -- "0..1" Family | ||||||
							
								
								
									
										1
									
								
								doc/db/user.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg aria-roledescription="classDiagram" role="graphics-document document" viewBox="0 0 402.4140625 664" style="max-width: 402.414px; background-color: transparent;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="100%" id="my-svg"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#my-svg .error-icon{fill:#a44141;}#my-svg .error-text{fill:#ddd;stroke:#ddd;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:lightgrey;stroke:lightgrey;}#my-svg .marker.cross{stroke:lightgrey;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg g.classGroup text{fill:#ccc;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#my-svg g.classGroup text .title{font-weight:bolder;}#my-svg .nodeLabel,#my-svg .edgeLabel{color:#e0dfdf;}#my-svg .edgeLabel .label rect{fill:#1f2020;}#my-svg .label text{fill:#e0dfdf;}#my-svg .edgeLabel .label span{background:#1f2020;}#my-svg .classTitle{font-weight:bolder;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#my-svg .divider{stroke:#ccc;stroke-width:1;}#my-svg g.clickable{cursor:pointer;}#my-svg g.classGroup rect{fill:#1f2020;stroke:#ccc;}#my-svg g.classGroup line{stroke:#ccc;stroke-width:1;}#my-svg .classLabel .box{stroke:none;stroke-width:0;fill:#1f2020;opacity:0.5;}#my-svg .classLabel .label{fill:#ccc;font-size:10px;}#my-svg .relation{stroke:lightgrey;stroke-width:1;fill:none;}#my-svg .dashed-line{stroke-dasharray:3;}#my-svg .dotted-line{stroke-dasharray:1 2;}#my-svg #compositionStart,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #compositionEnd,#my-svg .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #dependencyStart,#my-svg .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionStart,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #extensionEnd,#my-svg .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationStart,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #aggregationEnd,#my-svg .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopStart,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg #lollipopEnd,#my-svg .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#my-svg .edgeTerminals{font-size:11px;line-height:initial;}#my-svg .classTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker aggregation classDiagram" id="my-svg_classDiagram-aggregationEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker extension classDiagram" id="my-svg_classDiagram-extensionStart"><path d="M 1,7 L18,13 V 1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker extension classDiagram" id="my-svg_classDiagram-extensionEnd"><path d="M 1,1 V 13 L18,7 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="18" class="marker composition classDiagram" id="my-svg_classDiagram-compositionStart"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="1" class="marker composition classDiagram" id="my-svg_classDiagram-compositionEnd"><path d="M 18,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="6" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyStart"><path d="M 5,7 L9,13 L1,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="28" markerWidth="20" refY="7" refX="13" class="marker dependency classDiagram" id="my-svg_classDiagram-dependencyEnd"><path d="M 18,7 L9,13 L14,7 L9,1 Z"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="13" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopStart"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><defs><marker orient="auto" markerHeight="240" markerWidth="190" refY="7" refX="1" class="marker lollipop classDiagram" id="my-svg_classDiagram-lollipopEnd"><circle r="6" cy="7" cx="7" fill="transparent" stroke="black"/></marker></defs><g class="root"><g class="clusters"/><g class="edgePaths"><path style="fill:none" class="edge-pattern-solid relation" id="id_User_UserRole_1" d="M63.596,505L62.743,509.167C61.891,513.333,60.186,521.667,62.422,530C64.658,538.333,70.835,546.667,73.923,550.833L77.012,555"/><path style="fill:none" class="edge-pattern-solid relation" id="id_Role_UserRole_2" d="M315.83,318L308.6,353.333C301.37,388.667,286.909,459.333,261.526,503.341C236.143,547.348,199.837,564.697,181.684,573.371L163.531,582.045"/><path style="fill:none" class="edge-pattern-solid relation" id="id_User_Family_3" d="M220.891,380.93L242.145,405.775C263.398,430.62,305.906,480.31,327.16,511.155C348.414,542,348.414,554,348.414,560L348.414,566"/></g><g class="edgeLabels"><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(48.863498808878106, 521.5528105004219)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g></g><g transform="translate(75.90424420716624, 527.7282047434567)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(297.62591269237316, 332.1376838974091)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g></g><g transform="translate(180.78836069993307, 583.034192966888)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">*</span></div></foreignObject></g><g class="edgeLabel"><g transform="translate(0, 0)" class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel"></span></div></foreignObject></g></g><g transform="translate(220.86826990826685, 403.9791151705619)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"><foreignObject style="width: 9px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">1</span></div></foreignObject></g></g><g transform="translate(358.41406125, 543.4999989285715)" class="edgeTerminals"><g transform="translate(0, 0)" class="inner"/><foreignObject style="width: 36px; height: 12px;"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="edgeLabel">0..1</span></div></foreignObject></g></g><g class="nodes"><g transform="translate(114.4453125, 256.5)" id="classId-User-0" class="node default"><rect height="497" width="212.890625" y="-248.5" x="-106.4453125" class="outer title-state" style=""/><line y2="-218.5" y1="-218.5" x2="106.4453125" x1="-106.4453125" class="divider"/><line y2="237.5" y1="237.5" x2="106.4453125" x1="-106.4453125" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -17.7890625, -241)" height="18" width="35.578125" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">User</span></div></foreignObject><foreignObject transform="translate( -98.9453125, -207)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -98.9453125, -185)" height="18" width="92.9375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string name</span></div></foreignObject><foreignObject transform="translate( -98.9453125, -163)" height="18" width="73.375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string pw</span></div></foreignObject><foreignObject transform="translate( -98.9453125, -141)" height="18" width="96.53125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+bool deleted</span></div></foreignObject><foreignObject transform="translate( -98.9453125, -119)" height="18" width="158.75"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+datetime last_access</span></div></foreignObject><foreignObject transform="translate( -98.9453125, -97)" height="18" width="79.609375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string dob</span></div></foreignObject><foreignObject transform="translate( -98.9453125, -75)" height="18" width="99.171875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string weight</span></div></foreignObject><foreignObject transform="translate( -98.9453125, -53)" height="18" width="77.8125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string sex</span></div></foreignObject><foreignObject transform="translate( -98.9453125, -31)" height="18" width="126.71875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string dirty_thirty</span></div></foreignObject><foreignObject transform="translate( -98.9453125, -9)" height="18" width="135.640625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string dirty_dozen</span></div></foreignObject><foreignObject transform="translate( -98.9453125, 13)" height="18" width="197.890625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string member_since_date</span></div></foreignObject><foreignObject transform="translate( -98.9453125, 35)" height="18" width="115.1875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string birthdate</span></div></foreignObject><foreignObject transform="translate( -98.9453125, 57)" height="18" width="82.25"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string mail</span></div></foreignObject><foreignObject transform="translate( -98.9453125, 79)" height="18" width="121.390625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string nickname</span></div></foreignObject><foreignObject transform="translate( -98.9453125, 101)" height="18" width="92.0625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string notes</span></div></foreignObject><foreignObject transform="translate( -98.9453125, 123)" height="18" width="97.40625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string phone</span></div></foreignObject><foreignObject transform="translate( -98.9453125, 145)" height="18" width="109.84375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string address</span></div></foreignObject><foreignObject transform="translate( -98.9453125, 167)" height="18" width="93.828125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int family_id</span></div></foreignObject><foreignObject transform="translate( -98.9453125, 189)" height="18" width="163.21875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+blob membership_pdf</span></div></foreignObject><foreignObject transform="translate( -98.9453125, 211)" height="18" width="132.078125"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string user_token</span></div></foreignObject></g></g><g transform="translate(348.4140625, 605.5)" id="classId-Family-1" class="node default"><rect height="79" width="65.6875" y="-39.5" x="-32.84375" class="outer title-state" style=""/><line y2="-9.5" y1="-9.5" x2="32.84375" x1="-32.84375" class="divider"/><line y2="28.5" y1="28.5" x2="32.84375" x1="-32.84375" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -25.34375, -32)" height="18" width="50.6875" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Family</span></div></foreignObject><foreignObject transform="translate( -25.34375, 2)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject></g></g><g transform="translate(328.4140625, 256.5)" id="classId-Role-2" class="node default"><rect height="123" width="115.046875" y="-61.5" x="-57.5234375" class="outer title-state" style=""/><line y2="-31.5" y1="-31.5" x2="57.5234375" x1="-57.5234375" class="divider"/><line y2="50.5" y1="50.5" x2="57.5234375" x1="-57.5234375" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -17.3359375, -54)" height="18" width="34.671875" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Role</span></div></foreignObject><foreignObject transform="translate( -50.0234375, -20)" height="18" width="43.140625"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int id</span></div></foreignObject><foreignObject transform="translate( -50.0234375, 2)" height="18" width="92.9375"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string name</span></div></foreignObject><foreignObject transform="translate( -50.0234375, 24)" height="18" width="100.046875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+string cluster</span></div></foreignObject></g></g><g transform="translate(114.4453125, 605.5)" id="classId-UserRole-3" class="node default"><rect height="101" width="98.171875" y="-50.5" x="-49.0859375" class="outer title-state" style=""/><line y2="-20.5" y1="-20.5" x2="49.0859375" x1="-49.0859375" class="divider"/><line y2="39.5" y1="39.5" x2="49.0859375" x1="-49.0859375" class="divider"/><g class="label"><foreignObject height="0" width="0"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"></span></div></foreignObject><foreignObject transform="translate( -35.125, -43)" height="18" width="70.25" class="classTitle"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">UserRole</span></div></foreignObject><foreignObject transform="translate( -41.5859375, -9)" height="18" width="83.171875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int user_id</span></div></foreignObject><foreignObject transform="translate( -41.5859375, 13)" height="18" width="78.71875"><div style="display: inline-block; white-space: nowrap;" xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">+int role_id</span></div></foreignObject></g></g></g></g></g></svg> | ||||||
| After Width: | Height: | Size: 18 KiB | 
							
								
								
									
										94
									
								
								doc/nextcloud-notes.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,94 @@ | |||||||
|  | # Nextcloud integration | ||||||
|  |  | ||||||
|  | - Based on [this plugin](https://github.com/nextcloud/user_external) | ||||||
|  | - Install that plugin via web | ||||||
|  | - Connect to server, enter nextcloud-docker-image: `docker exec -it nextcloud-aio-nextcloud bash` | ||||||
|  | - Adapt `/var/www/html/custom_apps/user_external/lib/BasicAuth.php` to switch from BasicAuth to RowtAuth: | ||||||
|  | ```php | ||||||
|  | <?php | ||||||
|  | /** | ||||||
|  |  * Copyright (c) 2019 Lutz Freitag <lutz.freitag@gottliebtfreitag.de> | ||||||
|  |  * This file is licensed under the Affero General Public License version 3 or | ||||||
|  |  * later. | ||||||
|  |  * See the COPYING-README file. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | namespace OCA\UserExternal; | ||||||
|  |  | ||||||
|  | class BasicAuth extends Base { | ||||||
|  |     private $authUrl; | ||||||
|  |  | ||||||
|  |     public function __construct($authUrl) { | ||||||
|  |         parent::__construct($authUrl); | ||||||
|  |         $this->authUrl = $authUrl; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Check if the password is correct without logging in the user | ||||||
|  |      * | ||||||
|  |      * @param string $uid      The username | ||||||
|  |      * @param string $password The password | ||||||
|  |      * | ||||||
|  |      * @return true/false | ||||||
|  |      */ | ||||||
|  |     public function checkPassword($uid, $password) { | ||||||
|  |         // Prepare POST data with credentials | ||||||
|  |         $postData = http_build_query([ | ||||||
|  |             'name' => $uid, | ||||||
|  |             'password' => $password | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         // Create context with POST method | ||||||
|  |         $context = stream_context_create([ | ||||||
|  |             'http' => [ | ||||||
|  |                 'method' => 'POST', | ||||||
|  |                 'header' => 'Content-Type: application/x-www-form-urlencoded', | ||||||
|  |                 'content' => $postData, | ||||||
|  |                 'follow_location' => 0 | ||||||
|  |             ] | ||||||
|  |         ]); | ||||||
|  |  | ||||||
|  |         // Get the content of the response | ||||||
|  |         $content = @file_get_contents($this->authUrl, false, $context); | ||||||
|  |  | ||||||
|  |         if ($content === false) { | ||||||
|  |             \OC::$server->getLogger()->error( | ||||||
|  |                 'ERROR: Failed to get content from Auth Url: '.$this->authUrl, | ||||||
|  |                 ['app' => 'user_external'] | ||||||
|  |             ); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Check if the content is "SUCC" | ||||||
|  |         if (trim($content) === "SUCC") { | ||||||
|  |             $this->storeUser($uid); | ||||||
|  |             return $uid; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  | - In `/var/www/html/config/config.php` add this: | ||||||
|  | ``` | ||||||
|  |  'user_backends' =>  | ||||||
|  |   array ( | ||||||
|  |     0 =>  | ||||||
|  |     array ( | ||||||
|  |       'class' => '\\OCA\\UserExternal\\BasicAuth', | ||||||
|  |       'arguments' =>  | ||||||
|  |       array ( | ||||||
|  |         0 => 'https://app.rudernlinz.at/nxauth', | ||||||
|  |       ), | ||||||
|  |     ), | ||||||
|  |   ), | ||||||
|  | ``` | ||||||
|  | - In `/var/www/html/config/config.php` add this `'skeletondirectory' => '',` to disable default folders for new users | ||||||
|  | - To automatically add users to a group (e.g. `vorstand`), use the `Auto Groups` plugin | ||||||
|  | - Shared folders are not shared with new members due to [this bug](https://github.com/nextcloud/server/issues/25062#issuecomment-766445043) | ||||||
|  | 	- Find DB config: `docker exec nextcloud-aio-database env | grep POSTGRES` | ||||||
|  | 	- Workaround: Connect to docker-db: `docker exec -it nextcloud-aio-database bash` | ||||||
|  | 	- Connect to db: `psql -U nextcloud -d nextcloud_database` | ||||||
|  | 	- (with `\l` you see all dbs) | ||||||
|  | 	- Connect to nextcloud db: `\c nextcloud_database` | ||||||
|  | 	- Do query from issue: `UPDATE oc_share SET accepted = 1 WHERE share_type = 1;` | ||||||
							
								
								
									
										115
									
								
								doc/rudi/rudi-ruder-win.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,115 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
|  | <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> | ||||||
|  | <svg width="100%" height="100%" viewBox="0 0 583 276" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"> | ||||||
|  |     <g transform="matrix(1,0,0,1,-35.4077,-299.343)"> | ||||||
|  |         <g transform="matrix(-1,-0.000178685,0.000154251,-0.863253,717.569,685.115)"> | ||||||
|  |             <ellipse cx="349.686" cy="225.908" rx="151.555" ry="64.755" style="fill:rgb(255,44,29);stroke:black;stroke-width:1.07px;"/> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(1,0,0,1,28.4418,190.037)"> | ||||||
|  |             <path d="M286.601,229.218C289.276,248.622 296.342,264.086 310.327,279.874C300.812,262.599 294.125,245.355 297.093,228.218L286.601,229.218Z" style="fill:rgb(209,17,3);stroke:black;stroke-width:1px;"/> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(1,0,0,1,28.4418,190.037)"> | ||||||
|  |             <g transform="matrix(0.00205003,0.676578,-0.676578,0.00205003,289.722,204.596)"> | ||||||
|  |                 <circle cx="0" cy="0" r="36.391" style="fill:white;stroke:black;stroke-width:1.48px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(0.00362135,0.46002,-0.46002,0.00362135,293.586,211.476)"> | ||||||
|  |                 <circle cx="0" cy="0" r="36.391" style="stroke:black;stroke-width:2.17px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(0.00181067,0.23001,-0.23001,0.00181067,288.722,204.596)"> | ||||||
|  |                 <circle cx="0" cy="0" r="36.391" style="fill:white;stroke:black;stroke-width:4.35px;"/> | ||||||
|  |             </g> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(-1,0,0,1,706.392,190.037)"> | ||||||
|  |             <path d="M286.601,229.218C289.276,248.622 296.342,264.086 310.327,279.874C300.812,262.599 294.125,245.355 297.093,228.218L286.601,229.218Z" style="fill:rgb(209,17,3);stroke:black;stroke-width:1px;"/> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(1,0,0,1,28.4418,190.037)"> | ||||||
|  |             <g transform="matrix(0.00205003,0.676578,-0.676578,0.00205003,388.597,204.596)"> | ||||||
|  |                 <circle cx="0" cy="0" r="36.391" style="fill:white;stroke:black;stroke-width:1.48px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(0.00362135,0.46002,-0.46002,0.00362135,392.533,211.476)"> | ||||||
|  |                 <circle cx="0" cy="0" r="36.391" style="stroke:black;stroke-width:2.17px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(0.00181067,0.23001,-0.23001,0.00181067,387.567,204.596)"> | ||||||
|  |                 <circle cx="0" cy="0" r="36.391" style="fill:white;stroke:black;stroke-width:4.35px;"/> | ||||||
|  |             </g> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(0.729283,-2.96924e-17,3.31033e-17,-1.08337,127.964,834.503)"> | ||||||
|  |             <path d="M363.785,318.565C366.3,316.683 367.623,314.549 367.623,312.377C367.623,305.546 354.786,300 338.975,300C323.164,300 310.327,305.546 310.327,312.377C310.327,314.549 311.65,316.683 314.165,318.565C330.705,314.514 347.245,314.47 363.785,318.565Z" style="stroke:black;stroke-width:1.08px;"/> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(0.511811,0.00507451,0.00737211,-0.743545,200.689,730.834)"> | ||||||
|  |             <path d="M363.785,318.565C366.3,316.683 367.623,314.549 367.623,312.377C367.623,305.546 354.786,300 338.975,300C323.164,300 310.327,305.546 310.327,312.377C310.327,314.549 311.65,316.683 314.165,318.565C330.705,314.514 347.245,314.47 363.785,318.565Z" style="fill:white;stroke:black;stroke-width:1.57px;"/> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(1,0,0,1,29.4418,190.037)"> | ||||||
|  |             <g transform="matrix(1.52947,0.197824,-0.138277,1.06908,-155.205,-130.843)"> | ||||||
|  |                 <path d="M445.555,333.026L445.555,339.772C458.57,331.997 475.648,331.692 491.032,333.026C502.63,341.464 510.891,354.59 518.987,367.992C515.35,350.048 513.864,334.687 495.179,321.419C480.02,320.962 460.714,324.456 445.555,333.026Z" style="fill:rgb(255,0,4);stroke:black;stroke-width:0.75px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(1.38346,0.17894,-0.132531,1.02465,-106.692,-99.4881)"> | ||||||
|  |                 <path d="M445.555,333.026L445.555,339.772C458.57,331.997 475.648,331.692 491.032,333.026C502.63,341.464 510.891,354.59 518.987,367.992C515.35,350.048 513.864,334.687 495.179,321.419C480.02,320.962 460.714,324.456 445.555,333.026Z" style="fill:rgb(255,83,71);stroke:black;stroke-width:0.81px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(1.25063,0.161758,-0.11774,0.910302,-66.5748,-47.1693)"> | ||||||
|  |                 <path d="M445.555,333.026L445.555,339.772C458.57,331.997 475.648,331.692 491.032,333.026C502.63,341.464 510.891,354.59 518.987,367.992C515.35,350.048 513.864,334.687 495.179,321.419C480.02,320.962 460.714,324.456 445.555,333.026Z" style="fill:rgb(254,109,99);stroke:black;stroke-width:0.91px;"/> | ||||||
|  |             </g> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(1.11717,-0.0134911,0.0134911,1.11717,11.4404,152.276)"> | ||||||
|  |             <g transform="matrix(-1.52947,0.197824,0.138277,1.06908,857.149,-122.797)"> | ||||||
|  |                 <path d="M445.555,333.026L445.555,339.772C458.57,331.997 475.648,331.692 491.032,333.026C502.63,341.464 510.891,354.59 518.987,367.992C515.35,350.048 513.864,334.687 495.179,321.419C480.02,320.962 460.714,324.456 445.555,333.026Z" style="fill:rgb(255,0,4);stroke:black;stroke-width:0.67px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(-1.38346,0.17894,0.132531,1.02465,808.636,-91.4427)"> | ||||||
|  |                 <path d="M445.555,333.026L445.555,339.772C458.57,331.997 475.648,331.692 491.032,333.026C502.63,341.464 510.891,354.59 518.987,367.992C515.35,350.048 513.864,334.687 495.179,321.419C480.02,320.962 460.714,324.456 445.555,333.026Z" style="fill:rgb(255,83,71);stroke:black;stroke-width:0.73px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(-1.25063,0.161758,0.11774,0.910302,768.519,-39.1239)"> | ||||||
|  |                 <path d="M445.555,333.026L445.555,339.772C458.57,331.997 475.648,331.692 491.032,333.026C502.63,341.464 510.891,354.59 518.987,367.992C515.35,350.048 513.864,334.687 495.179,321.419C480.02,320.962 460.714,324.456 445.555,333.026Z" style="fill:rgb(254,109,99);stroke:black;stroke-width:0.81px;"/> | ||||||
|  |             </g> | ||||||
|  |         </g> | ||||||
|  |         <path d="M458.479,299.843L501.801,385.706L483.536,357.128L459.922,356.81C461.219,359.389 462.626,361.984 464.124,364.598C469.852,374.595 476.484,384.07 483.354,392.071L520.953,379.098C518.698,372.227 509.385,353.734 505.6,346.35C490.238,320.681 470.903,301.887 458.479,299.843Z" style="fill:rgb(255,0,0);stroke:black;stroke-width:1px;stroke-linecap:butt;stroke-miterlimit:2;"/> | ||||||
|  |         <path d="M476.397,345.957L449.131,303.294C444.35,310.247 446.55,326.436 454.694,345.665L476.397,345.957Z" style="fill:rgb(255,0,0);stroke:black;stroke-width:1px;stroke-linecap:butt;stroke-miterlimit:2;"/> | ||||||
|  |         <g transform="matrix(1.91904,0,0,1.91904,-538.727,-501.927)"> | ||||||
|  |             <g transform="matrix(1,0,0,1,-206.042,-24.7226)"> | ||||||
|  |                 <path d="M742.158,502.447C739.772,509.848 732.922,516.471 722.777,522.521C734.015,523.734 744.479,519.473 751.504,508.94L742.158,502.447Z" style="fill:rgb(254,109,99);stroke:black;stroke-width:0.52px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(1,0,0,1,-206.042,-24.7226)"> | ||||||
|  |                 <path d="M751.365,508.94C762.221,499.436 762.789,489.684 757.237,479.785C749.705,485.381 744.028,488.47 735.811,487.991C737.641,497.119 742.164,504.525 751.365,508.94Z" style="fill:rgb(255,83,71);stroke:black;stroke-width:0.52px;"/> | ||||||
|  |             </g> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(0.105752,-0.994393,0.994393,0.105752,9.03576,516.491)"> | ||||||
|  |             <g transform="matrix(-0.387971,-0.0870248,-0.0870248,0.387971,1030.34,-21.0172)"> | ||||||
|  |                 <path d="M1944.68,934.772C1921.09,896.523 1932.18,782.181 1974.56,655.531C1990.23,608.676 2009.04,563.787 2029.1,525.376L2156.88,568.132C2149.78,610.876 2137.79,658.046 2122.11,704.901C2079.26,832.966 2018.43,931.728 1976.52,946.409L2085.08,568.504L1944.68,934.772Z" style="fill:rgb(255,0,0);stroke:black;stroke-width:2.52px;stroke-linecap:butt;stroke-miterlimit:2;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(-0.995725,0.0923706,-0.0923706,-0.995725,428.457,726.747)"> | ||||||
|  |                 <path d="M182.692,268.985L182.692,174.156L193.766,174.156L193.766,262.63C191.152,263.999 188.537,265.462 185.934,267.001L182.692,268.985Z" style="stroke:black;stroke-width:1px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(-0.995725,0.0923706,-0.0923706,-0.995725,428.457,726.747)"> | ||||||
|  |                 <path d="M182.692,292.805L193.766,287.03L193.766,290.902L182.692,298.191L182.692,292.805Z" style="stroke:black;stroke-width:1px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(-0.995725,0.0923706,-0.0923706,-0.995725,428.457,726.747)"> | ||||||
|  |                 <path d="M186.692,318.086C188.312,317.289 189.931,316.492 191.551,315.695L193.766,314.518L193.766,497.999L191.577,497.079C190.016,496.484 188.455,495.89 186.895,495.295L182.692,493.866L182.692,319.896L186.692,318.086Z" style="stroke:black;stroke-width:1px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(-0.995725,0.0923706,-0.0923706,-0.995725,428.457,726.747)"> | ||||||
|  |                 <path d="M182.692,319.896L186.692,318.086C188.312,317.289 189.931,316.492 191.551,315.695L193.766,314.518L193.766,290.902L182.692,298.191L182.692,319.896Z" style="stroke:black;stroke-width:1px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(-0.995725,0.0923706,-0.0923706,-0.995725,428.457,726.747)"> | ||||||
|  |                 <path d="M182.692,512.971L182.692,571.911L193.766,571.911L193.766,519.91L192.912,519.38L182.692,512.971Z" style="stroke:black;stroke-width:1px;"/> | ||||||
|  |             </g> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(1,0,0,1,56,0)"> | ||||||
|  |             <path d="M198.549,354.049L220.458,354.343C218.026,359.991 215.073,365.879 211.645,371.859L202.175,386.73C199.034,391.211 195.753,395.446 192.416,399.332L173.717,392.881L198.549,354.049Z" style="fill:rgb(255,0,0);stroke:black;stroke-width:1px;stroke-linecap:butt;stroke-miterlimit:2;"/> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(1,0,0,1,56,0)"> | ||||||
|  |             <path d="M205.636,343.069L226.639,310.556C230.599,316.315 229.769,328.411 224.739,343.326L205.636,343.069Z" style="fill:rgb(255,0,0);stroke:black;stroke-width:1px;stroke-linecap:butt;stroke-miterlimit:2;"/> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(1,0,0,1,56,0)"> | ||||||
|  |             <path d="M205.636,343.069L224.739,343.326L223.254,347.509C222.638,349.062 222.023,350.614 221.407,352.167L220.458,354.343L198.549,354.049L199.09,353.202L205.636,343.069Z" style="stroke:rgb(6,2,2);stroke-width:1px;stroke-linecap:butt;stroke-miterlimit:2;"/> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(1,0,0,1,56,0)"> | ||||||
|  |             <path d="M173.717,392.881L154.817,386.36C157.874,377.045 162.499,366.877 168.23,356.88C176.635,342.215 186.42,329.568 195.697,320.682L195.659,320.331C200.211,317.065 213.64,307.706 217.291,307.105L198.317,344.921L198.307,344.827L173.717,392.881Z" style="fill:rgb(255,0,0);stroke:black;stroke-width:1px;stroke-linecap:butt;stroke-miterlimit:2;"/> | ||||||
|  |         </g> | ||||||
|  |         <g transform="matrix(-2.14337,0.024885,0.024885,2.14337,1380.31,-613.484)"> | ||||||
|  |             <g transform="matrix(1,0,0,1,-206.042,-24.7226)"> | ||||||
|  |                 <path d="M742.158,502.447C739.772,509.848 732.922,516.471 722.777,522.521C734.015,523.734 744.479,519.473 751.504,508.94L742.158,502.447Z" style="fill:rgb(254,109,99);stroke:black;stroke-width:0.47px;"/> | ||||||
|  |             </g> | ||||||
|  |             <g transform="matrix(1,0,0,1,-206.042,-24.7226)"> | ||||||
|  |                 <path d="M751.365,508.94C762.221,499.436 762.789,489.684 757.237,479.785C749.705,485.381 744.028,488.47 735.811,487.991C737.641,497.119 742.164,504.525 751.365,508.94Z" style="fill:rgb(255,83,71);stroke:black;stroke-width:0.47px;"/> | ||||||
|  |             </g> | ||||||
|  |         </g> | ||||||
|  |     </g> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 12 KiB | 
							
								
								
									
										156
									
								
								doc/wordpress-notes.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,156 @@ | |||||||
|  | # Wordpress auth | ||||||
|  |  | ||||||
|  | Add the following code to `wp-content/themes/bravada/functions.php`: | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | function rot_auth( $user, $username, $password ){ | ||||||
|  |     // Make sure a username and password are present for us to work with | ||||||
|  |     if($username == '' || $password == '') return; | ||||||
|  |  | ||||||
|  | 	$ch = curl_init(); | ||||||
|  | 	 | ||||||
|  | 	curl_setopt($ch, CURLOPT_URL, 'https://app.rudernlinz.at/wikiauth'); | ||||||
|  | 	curl_setopt($ch, CURLOPT_POST, 1); | ||||||
|  | 	curl_setopt($ch, CURLOPT_POSTFIELDS, "name=$username&password=$password"); | ||||||
|  | 	curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);	 | ||||||
|  | 	 | ||||||
|  | 	// Execute the cURL session and get the response | ||||||
|  | 	$response = curl_exec($ch); | ||||||
|  | 	 | ||||||
|  | 	// Check for cURL errors | ||||||
|  | 	if(curl_errno($ch)){ | ||||||
|  |         	$user = new WP_Error( 'denied', __('Curl error: ' . curl_error($ch)) ); | ||||||
|  | 	} | ||||||
|  | 	 | ||||||
|  | 	// Close the cURL session | ||||||
|  | 	curl_close($ch); | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 	if (strpos($response, 'SUCC') !== false) { | ||||||
|  |         	$user = get_user_by('login', $username); | ||||||
|  |         	 | ||||||
|  |         	if (!$user) { | ||||||
|  |         	   // User does not exist, create a new one | ||||||
|  |         	   $userdata = array( | ||||||
|  |         	       'user_email' => $username, | ||||||
|  |         	       'user_login' => $username,  | ||||||
|  |         	       'first_name' => $username, | ||||||
|  |         	       'last_name' => '' | ||||||
|  |         	   ); | ||||||
|  |         	   $new_user_id = wp_insert_user($userdata); | ||||||
|  |  | ||||||
|  |         	   if (!is_wp_error($new_user_id)) { | ||||||
|  |         	       // Load the new user info | ||||||
|  |         	       $user = new WP_User($new_user_id); | ||||||
|  |         	        | ||||||
|  |         	       // Set role based on username | ||||||
|  |         	       if ($username == 'Philipp Hofer' || $username == 'Marie Birner') { | ||||||
|  |         	           $user->set_role('administrator'); | ||||||
|  |         	       } else { | ||||||
|  |         	           $user->set_role('editor'); | ||||||
|  |         	       } | ||||||
|  |         	   } else { | ||||||
|  |         	       // Handle error in user creation | ||||||
|  |         	       return $new_user_id; | ||||||
|  |         	   } | ||||||
|  |         	} else { | ||||||
|  |         	} | ||||||
|  | 	 | ||||||
|  | 	} else { | ||||||
|  |         	$user = new WP_Error( 'denied', __("Falscher Benutzername/Passwort. Verwendest du deine Accountdaten vom Ruderassistenten?") ); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |      return $user; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Comment this line if you wish to fall back on WordPress authentication | ||||||
|  | // Useful for times when the external service is offline | ||||||
|  | remove_action('authenticate', 'wp_authenticate_username_password', 20); | ||||||
|  |  | ||||||
|  | add_filter( 'authenticate', 'rot_auth', 10, 3 ); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Wordpress notify rowt on newly published article | ||||||
|  |  | ||||||
|  | Add the following code to `wp-content/themes/bravada/functions.php`: | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | function send_article_url_on_publish($new_status, $old_status, $post) { | ||||||
|  |     // Check if the post is transitioning to 'publish' status | ||||||
|  |     if ($new_status == 'publish' && $old_status != 'publish' && $post->post_type == 'post') { | ||||||
|  |         // Get the URL of the newly published article | ||||||
|  |         $article_url = get_permalink($post->ID); | ||||||
|  |         $article_title = get_the_title($post->ID); | ||||||
|  |          | ||||||
|  |         // URL to send the POST request to | ||||||
|  |         $api_url = 'https://app.rudernlinz.at/new-blogpost'; | ||||||
|  |          | ||||||
|  |         // Prepare the data for the POST request | ||||||
|  |         $body = array( | ||||||
|  |             'article_url' => $article_url, | ||||||
|  |             'article_title' => $article_title, | ||||||
|  |             'pw' => "wordpress_key" | ||||||
|  |         ); | ||||||
|  |          | ||||||
|  |         // Prepare the arguments for wp_remote_post | ||||||
|  |         $args = array( | ||||||
|  |             'body' => $body, | ||||||
|  |             'timeout' => '5', | ||||||
|  |             'redirection' => '5', | ||||||
|  |             'httpversion' => '1.0', | ||||||
|  |             'blocking' => true, | ||||||
|  |             'headers' => array(), | ||||||
|  |             'cookies' => array() | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         // Send the POST request | ||||||
|  |         $response = wp_remote_post($api_url, $args); | ||||||
|  |          | ||||||
|  |         // Optional: Check if the request was successful | ||||||
|  |         if (is_wp_error($response)) { | ||||||
|  |             error_log('Failed to send POST request: ' . $response->get_error_message()); | ||||||
|  |         } else { | ||||||
|  |             error_log('POST request sent successfully with article URL: ' . $article_url); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     if ($new_status != 'publish' && $old_status == 'publish' && $post->post_type == 'post') { | ||||||
|  |         $article_url = get_permalink($post->ID); | ||||||
|  |         // URL to send the POST request to | ||||||
|  |         $api_url = 'https://app.rudernlinz.at/blogpost-unpublished'; | ||||||
|  |          | ||||||
|  |         // Prepare the data for the POST request | ||||||
|  |         $body = array( | ||||||
|  |             'article_url' => $article_url, | ||||||
|  |             'pw' => "wordpress_key" | ||||||
|  |         ); | ||||||
|  |          | ||||||
|  |         // Prepare the arguments for wp_remote_post | ||||||
|  |         $args = array( | ||||||
|  |             'body' => $body, | ||||||
|  |             'timeout' => '5', | ||||||
|  |             'redirection' => '5', | ||||||
|  |             'httpversion' => '1.0', | ||||||
|  |             'blocking' => true, | ||||||
|  |             'headers' => array(), | ||||||
|  |             'cookies' => array() | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         // Send the POST request | ||||||
|  |         $response = wp_remote_post($api_url, $args); | ||||||
|  |          | ||||||
|  |         // Optional: Check if the request was successful | ||||||
|  |         if (is_wp_error($response)) { | ||||||
|  |             error_log('Failed to send POST request: ' . $response->get_error_message()); | ||||||
|  |         } else { | ||||||
|  |             error_log('POST request sent successfully with article URL: ' . $article_url); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Hook the function to the 'transition_post_status' action | ||||||
|  | add_action('transition_post_status', 'send_article_url_on_publish', 10, 3); | ||||||
|  | ``` | ||||||
							
								
								
									
										5
									
								
								fd
									
									
									
									
									
										Executable file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | |||||||
|  | #!/bin/bash | ||||||
|  |  | ||||||
|  | scp root@app.rudernlinz.at:/root/rowing-prod/db.sqlite db.sqlite | ||||||
|  | #sqlite3 db.sqlite < seeds.sql | ||||||
|  |  | ||||||
| @@ -1,77 +1,76 @@ | |||||||
| import * as d3 from 'd3'; | import * as d3 from "d3"; | ||||||
|  |  | ||||||
| export interface Data { | export interface Data { | ||||||
|   date: Date; |   date: Date; | ||||||
|   km: number; |   km: number; | ||||||
| } | } | ||||||
|  |  | ||||||
| if(sessionStorage.getItem('userStats')) { | if (sessionStorage.getItem("userStats")) { | ||||||
|   const data = JSON.parse(sessionStorage.getItem('userStats') || '{}') as Data[]; |   const data = JSON.parse( | ||||||
|  |     sessionStorage.getItem("userStats") || "{}", | ||||||
|  |   ) as Data[]; | ||||||
|  |  | ||||||
|   if(data.length >= 2) { |   if (data.length >= 2) { | ||||||
|     const margin = { top: 20, right: 20, bottom: 50, left: 50 }; |     const margin = { top: 20, right: 20, bottom: 50, left: 50 }; | ||||||
|     const width: number = 960 - margin.left - margin.right; |     const width: number = 960 - margin.left - margin.right; | ||||||
|     const height: number = 500 - margin.top - margin.bottom; |     const height: number = 500 - margin.top - margin.bottom; | ||||||
|  |  | ||||||
|     data.forEach((d: Data) => { |     data.forEach((d: Data) => { | ||||||
|       d.date = <Date> new Date(d.date) |       d.date = <Date>new Date(d.date); | ||||||
|       d.km = +d.km; |       d.km = +d.km; | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     const x = d3.scaleTime() |     const x = d3 | ||||||
|  |       .scaleTime() | ||||||
|       .domain(<[Date, Date]>d3.extent(data, (d: Data) => d.date)) |       .domain(<[Date, Date]>d3.extent(data, (d: Data) => d.date)) | ||||||
|       .range([0, width]); |       .range([0, width]); | ||||||
|  |  | ||||||
|     const y = d3.scaleLinear() |     const y = d3 | ||||||
|  |       .scaleLinear() | ||||||
|       .domain([0, Number(d3.max(data, (d: Data) => d.km))]) |       .domain([0, Number(d3.max(data, (d: Data) => d.km))]) | ||||||
|       .range([height, 0]); |       .range([height, 0]); | ||||||
|  |  | ||||||
|     const line = d3.line<Data>() |     const line = d3 | ||||||
|  |       .line<Data>() | ||||||
|       .x((d: Data) => x(d.date)) |       .x((d: Data) => x(d.date)) | ||||||
|       .y((d: Data) => y(d.km)); |       .y((d: Data) => y(d.km)); | ||||||
|  |  | ||||||
|     const svg = d3.select('#container') |     const svg = d3 | ||||||
|       .append('svg') |       .select("#container") | ||||||
|       .attr('width', width + margin.left + margin.right) |       .append("svg") | ||||||
|       .attr('height', height + margin.top + margin.bottom) |       .attr("width", width + margin.left + margin.right) | ||||||
|  |       .attr("height", height + margin.top + margin.bottom) | ||||||
|       .call(responsivefy) |       .call(responsivefy) | ||||||
|       .append('g') |       .append("g") | ||||||
|       .attr('transform', `translate(${margin.left},${margin.top})`); |       .attr("transform", `translate(${margin.left},${margin.top})`); | ||||||
|  |  | ||||||
|     svg.append('path') |     svg.append("path").data([data]).attr("class", "line").attr("d", line); | ||||||
|       .data([data]) |  | ||||||
|       .attr('class', 'line') |  | ||||||
|       .attr('d', line); |  | ||||||
|  |  | ||||||
|     svg.append('g') |     svg | ||||||
|       .attr('transform', `translate(0,${height})`) |       .append("g") | ||||||
|  |       .attr("transform", `translate(0,${height})`) | ||||||
|       .call(d3.axisBottom(x)); |       .call(d3.axisBottom(x)); | ||||||
|  |  | ||||||
|     svg.append('g') |     svg.append("g").call(d3.axisLeft(y)); | ||||||
|       .call(d3.axisLeft(y)); |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| function responsivefy(svg: any) { | function responsivefy(svg: any) { | ||||||
|   const container = d3.select(svg.node().parentNode); |   const container = d3.select(svg.node().parentNode); | ||||||
|   const width = parseInt(svg.style('width'), 10); |   const width = parseInt(svg.style("width"), 10); | ||||||
|   const height = parseInt(svg.style('height'), 10); |   const height = parseInt(svg.style("height"), 10); | ||||||
|   const aspect = width / height; |   const aspect = width / height; | ||||||
|  |  | ||||||
|   svg.attr('viewBox', `0 0 ${width} ${height}`) |   svg | ||||||
|     .attr('preserveAspectRatio', 'xMinYMid') |     .attr("viewBox", `0 0 ${width} ${height}`) | ||||||
|  |     .attr("preserveAspectRatio", "xMinYMid") | ||||||
|     .call(resize); |     .call(resize); | ||||||
|  |  | ||||||
|   d3.select(window).on( |   d3.select(window).on("resize." + container.attr("id"), resize); | ||||||
|     'resize.' + container.attr('id'), |  | ||||||
|     resize |  | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   function resize() { |   function resize() { | ||||||
|     const w = parseInt(container.style('width')); |     const w = parseInt(container.style("width")); | ||||||
|     svg.attr('width', w); |     svg.attr("width", w); | ||||||
|     svg.attr('height', Math.round(w / aspect)); |     svg.attr("height", Math.round(w / aspect)); | ||||||
|   } |   } | ||||||
| } | } | ||||||
							
								
								
									
										906
									
								
								frontend/main.ts
									
									
									
									
									
								
							
							
						
						| @@ -21,7 +21,7 @@ | |||||||
|     "vite-plugin-static-copy": "^0.13.1" |     "vite-plugin-static-copy": "^0.13.1" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "choices.js": "^10.2.0", |     "choices.js": "^11.1.0", | ||||||
|     "d3": "^7.8.5", |     "d3": "^7.8.5", | ||||||
|     "terser": "^5.21.0" |     "terser": "^5.21.0" | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -24,7 +24,7 @@ export default defineConfig({ | |||||||
|   /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ |   /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ | ||||||
|   use: { |   use: { | ||||||
|     /* Base URL to use in actions like `await page.goto('/')`. */ |     /* Base URL to use in actions like `await page.goto('/')`. */ | ||||||
|     // baseURL: 'http://127.0.0.1:3000', |     baseURL: 'http://127.0.0.1:8000', | ||||||
|  |  | ||||||
|     /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ |     /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ | ||||||
|     trace: 'on-first-retry', |     trace: 'on-first-retry', | ||||||
| @@ -70,6 +70,8 @@ export default defineConfig({ | |||||||
|  |  | ||||||
|   /* Run your local dev server before starting the tests */ |   /* Run your local dev server before starting the tests */ | ||||||
|   webServer: { |   webServer: { | ||||||
|  |     timeout: 15 * 60 * 1000, | ||||||
|     command: 'cd .. && ./test_db.sh && cargo r', |     command: 'cd .. && ./test_db.sh && cargo r', | ||||||
|  |     url: 'http://127.0.0.1:8000' | ||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -12,3 +12,5 @@ | |||||||
| @import 'components/chart'; | @import 'components/chart'; | ||||||
| @import 'components/search'; | @import 'components/search'; | ||||||
| @import 'components/important'; | @import 'components/important'; | ||||||
|  | @import 'components/searchable-table'; | ||||||
|  | @import 'components/notification'; | ||||||
|   | |||||||
| @@ -28,4 +28,8 @@ | |||||||
|   &[aria-pressed='true'] { |   &[aria-pressed='true'] { | ||||||
|     @apply outline outline-2 outline-offset-2 outline-primary-600 bg-primary-100 text-primary-950; |     @apply outline outline-2 outline-offset-2 outline-primary-600 bg-primary-100 text-primary-950; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   &-hidden { | ||||||
|  |     @apply hidden; | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -5,3 +5,7 @@ | |||||||
| .h2 { | .h2 { | ||||||
|   @apply font-bold uppercase tracking-wide text-center rounded-t-md text-primary-950 dark:text-white bg-gray-200 dark:bg-primary-950 bg-opacity-80 text-lg px-3 py-3; |   @apply font-bold uppercase tracking-wide text-center rounded-t-md text-primary-950 dark:text-white bg-gray-200 dark:bg-primary-950 bg-opacity-80 text-lg px-3 py-3; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .h3 { | ||||||
|  |   @apply text-center text-xl uppercase tracking-wide font-bold text-primary-900 dark:text-white; | ||||||
|  | } | ||||||
| @@ -2,3 +2,12 @@ | |||||||
|     border-top-left-radius: 0px !important; |     border-top-left-radius: 0px !important; | ||||||
|     border-top-right-radius: 0px !important; |     border-top-right-radius: 0px !important; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .rounded-l-none-important { | ||||||
|  |   border-bottom-left-radius: 0px !important; | ||||||
|  |   border-top-left-radius: 0px !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .rounded-none-important { | ||||||
|  |   border-radius: 0px !important; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -2,6 +2,26 @@ | |||||||
|   @apply relative block w-full bg-white dark:bg-black border-0 py-1.5 px-2 text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-black placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-primary-600 sm:text-sm sm:leading-6; |   @apply relative block w-full bg-white dark:bg-black border-0 py-1.5 px-2 text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-black placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-primary-600 sm:text-sm sm:leading-6; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .input-group { | ||||||
|  |   @apply flex; | ||||||
|  |  | ||||||
|  |   input[readonly],  | ||||||
|  |   select[disabled] { | ||||||
|  |     opacity: .7; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &.editable { | ||||||
|  |       input[type="reset"], | ||||||
|  |       input[type="submit"] { | ||||||
|  |           @apply block; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       button[type="button"] { | ||||||
|  |           @apply hidden; | ||||||
|  |       } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| select { | select { | ||||||
|   background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"); |   background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"); | ||||||
|   background-repeat: no-repeat; |   background-repeat: no-repeat; | ||||||
|   | |||||||
| @@ -10,4 +10,12 @@ | |||||||
|   &-white { |   &-white { | ||||||
|     @apply text-white hover:text-primary-100 underline; |     @apply text-white hover:text-primary-100 underline; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   &-black { | ||||||
|  |     @apply text-black hover:text-primary-950 dark:text-white hover:dark:text-primary-300 underline; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &-no-underline { | ||||||
|  |     @apply no-underline; | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								frontend/scss/components/_notification.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | |||||||
|  | .notification { | ||||||
|  |     right: -.2rem; | ||||||
|  |     top: -.1rem; | ||||||
|  |     font-size: .5rem; | ||||||
|  | } | ||||||
							
								
								
									
										178
									
								
								frontend/scss/components/_searchable-table.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,178 @@ | |||||||
|  | /*! | ||||||
|  |  * JSTable v1.6.5 | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  |  .dt-container{ | ||||||
|  |   position:relative; | ||||||
|  |   display: block; | ||||||
|  |   width: 100%; | ||||||
|  |   overflow-x: auto; | ||||||
|  |   -webkit-overflow-scrolling: touch; | ||||||
|  |   -ms-overflow-style: -ms-autohiding-scrollbar; | ||||||
|  |   overflow-y: hidden; | ||||||
|  |  | ||||||
|  |   .dt-message { | ||||||
|  |       text-align: center; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .dt-loading{ | ||||||
|  |       position: absolute; | ||||||
|  |       top: 50%; | ||||||
|  |       left: 50%; | ||||||
|  |       width: 100%; | ||||||
|  |       margin-left: -50%; | ||||||
|  |       margin-top: -20px; | ||||||
|  |       height: 40px; | ||||||
|  |       text-align: center; | ||||||
|  |       background-color: white; | ||||||
|  |       display: flex; | ||||||
|  |       justify-content: center; | ||||||
|  |       align-items: center; | ||||||
|  |       background: linear-gradient(to right,rgba(255,255,255,0) 0,rgba(255,255,255,0.9) 25%,rgba(255,255,255,0.9) 75%,rgba(255,255,255,0) 100%); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dt-top, | ||||||
|  | .dt-bottom { | ||||||
|  |   padding: 8px 10px; | ||||||
|  |   display:flex; | ||||||
|  |   justify-content:space-between; | ||||||
|  |  | ||||||
|  |   .dt-info { | ||||||
|  |       margin: 7px 0; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* PAGER */ | ||||||
|  | .dt-pagination { | ||||||
|  |  | ||||||
|  |   ul { | ||||||
|  |       margin: 0; | ||||||
|  |       padding-left: 0; | ||||||
|  |       li { | ||||||
|  |           list-style: none; | ||||||
|  |           float: left; | ||||||
|  |       } | ||||||
|  |   } | ||||||
|  |   a, span{ | ||||||
|  |       border: 1px solid transparent; | ||||||
|  |       float: left; | ||||||
|  |       margin-left: 2px; | ||||||
|  |       padding: 6px 12px; | ||||||
|  |       position: relative; | ||||||
|  |       text-decoration: none; | ||||||
|  |       color: inherit; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   a:hover { | ||||||
|  |       background-color: #d9d9d9; | ||||||
|  |   } | ||||||
|  |   .active a{ | ||||||
|  |       &, &:focus, &:hover{ | ||||||
|  |           background-color: #d9d9d9; | ||||||
|  |           cursor: default; | ||||||
|  |       } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .dt-ellipsis span{ | ||||||
|  |       cursor: not-allowed; | ||||||
|  |   }  | ||||||
|  |   .disabled a{ | ||||||
|  |       &, &:focus, &:hover{ | ||||||
|  |           cursor: not-allowed; | ||||||
|  |           opacity: 0.4; | ||||||
|  |       } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .pager a { | ||||||
|  |       font-weight: bold; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .dt-table { | ||||||
|  |   max-width: 100%; | ||||||
|  |   width: 100%; | ||||||
|  |   border-spacing: 0; | ||||||
|  |  | ||||||
|  |   & > tbody, > tfoot, > thead{ | ||||||
|  |       & > tr{ | ||||||
|  |           & > td, & > th{ | ||||||
|  |               vertical-align: top; | ||||||
|  |               padding: 8px 10px; | ||||||
|  |               white-space: nowrap; | ||||||
|  |           } | ||||||
|  |       } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   & > thead > tr{  | ||||||
|  |       & > th, & > td{ | ||||||
|  |           vertical-align: bottom; | ||||||
|  |           text-align: left; | ||||||
|  |           border-bottom: 1px solid #d9d9d9; | ||||||
|  |       } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   & > tfoot  > tr{  | ||||||
|  |       & > th, & > td{ | ||||||
|  |           vertical-align: bottom; | ||||||
|  |           text-align: left; | ||||||
|  |           border-top: 1px solid #d9d9d9; | ||||||
|  |       } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   th { | ||||||
|  |       vertical-align: bottom; | ||||||
|  |       text-align: left; | ||||||
|  |  | ||||||
|  |       &.dt-sorter { | ||||||
|  |           position: relative; | ||||||
|  |           cursor: pointer; | ||||||
|  |           padding-right:20px; | ||||||
|  |  | ||||||
|  |           &::before, | ||||||
|  |           &::after { | ||||||
|  |               content: ""; | ||||||
|  |               height: 0; | ||||||
|  |               width: 0; | ||||||
|  |               position: absolute; | ||||||
|  |               right: 7px; | ||||||
|  |               border-left: 4px solid transparent; | ||||||
|  |               border-right: 4px solid transparent; | ||||||
|  |               opacity: 0.2; | ||||||
|  |           } | ||||||
|  |  | ||||||
|  |           &::before { | ||||||
|  |               border-top: 4px solid #000; | ||||||
|  |               top: 18px; | ||||||
|  |           } | ||||||
|  |  | ||||||
|  |           &::after { | ||||||
|  |               border-bottom: 4px solid #000; | ||||||
|  |               border-top: 4px solid transparent; | ||||||
|  |               bottom: 22px; | ||||||
|  |           } | ||||||
|  |  | ||||||
|  |           &.asc::after, | ||||||
|  |           &.desc::before { | ||||||
|  |               opacity: 0.6; | ||||||
|  |           } | ||||||
|  |  | ||||||
|  |       } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dt-loading.hidden{ | ||||||
|  |   display:none!important; | ||||||
|  |   opacity:0!important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dt-input { | ||||||
|  |   @extend .input; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .dt-selector { | ||||||
|  |   @extend .input; | ||||||
|  | } | ||||||
| @@ -10,6 +10,7 @@ | |||||||
|  |  | ||||||
|   &.open { |   &.open { | ||||||
|       display: block; |       display: block; | ||||||
|  |       height: 100dvh; | ||||||
|       height: 100vh; |       height: 100vh; | ||||||
|       right: 0; |       right: 0; | ||||||
|       top: 0; |       top: 0; | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								frontend/static/images/android-chrome-192x192.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 10 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/static/images/android-chrome-512x512.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 33 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/static/images/apple-touch-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 9.5 KiB | 
							
								
								
									
										9
									
								
								frontend/static/images/browserconfig.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <browserconfig> | ||||||
|  |     <msapplication> | ||||||
|  |         <tile> | ||||||
|  |             <square150x150logo src="/mstile-150x150.png"/> | ||||||
|  |             <TileColor>#da532c</TileColor> | ||||||
|  |         </tile> | ||||||
|  |     </msapplication> | ||||||
|  | </browserconfig> | ||||||
							
								
								
									
										
											BIN
										
									
								
								frontend/static/images/favicon copy.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 15 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/static/images/favicon-16x16.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/static/images/favicon-32x32.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										36
									
								
								frontend/static/images/favicon.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,36 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
|  | <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> | ||||||
|  | <svg width="100%" height="100%" viewBox="0 0 372 372" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> | ||||||
|  |     <g transform="matrix(17.8229,1.77636e-15,-1.77636e-15,17.8229,-6843.65,-3821.15)"> | ||||||
|  |         <path d="M397.575,225.019C397.575,225.386 397.493,225.701 397.329,225.965C397.165,226.229 396.93,226.444 396.626,226.612C396.883,226.808 397.079,227.039 397.211,227.307C397.344,227.574 397.411,227.903 397.411,228.294C397.411,228.646 397.339,228.957 397.197,229.229C397.054,229.5 396.852,229.729 396.59,229.914C396.329,230.1 396.013,230.24 395.644,230.336C395.275,230.432 394.862,230.48 394.405,230.48C394.01,230.48 393.623,230.439 393.242,230.359C392.861,230.279 392.521,230.145 392.222,229.955C391.923,229.766 391.683,229.514 391.502,229.199C391.32,228.885 391.229,228.493 391.229,228.024L392.858,228.019C392.858,228.249 392.903,228.438 392.993,228.584C393.083,228.73 393.201,228.847 393.347,228.933C393.494,229.019 393.66,229.078 393.845,229.111C394.031,229.145 394.217,229.161 394.405,229.161C394.854,229.161 395.197,229.083 395.433,228.927C395.669,228.771 395.788,228.562 395.788,228.3C395.788,228.171 395.76,228.056 395.706,227.954C395.651,227.853 395.556,227.758 395.421,227.67C395.287,227.582 395.107,227.496 394.882,227.412C394.658,227.328 394.376,227.237 394.036,227.14C393.598,227.022 393.208,226.895 392.864,226.756C392.52,226.617 392.228,226.452 391.988,226.261C391.748,226.069 391.564,225.845 391.437,225.587C391.31,225.329 391.247,225.022 391.247,224.667C391.247,224.308 391.33,223.994 391.496,223.727C391.662,223.459 391.897,223.239 392.202,223.067C391.944,222.868 391.748,222.635 391.613,222.367C391.478,222.1 391.411,221.771 391.411,221.38C391.411,221.044 391.482,220.738 391.625,220.463C391.767,220.188 391.97,219.953 392.234,219.76C392.498,219.566 392.816,219.417 393.189,219.312C393.562,219.206 393.977,219.153 394.434,219.153C394.903,219.153 395.325,219.208 395.7,219.317C396.075,219.427 396.392,219.588 396.652,219.801C396.912,220.014 397.112,220.276 397.252,220.589C397.393,220.901 397.463,221.259 397.463,221.661L395.829,221.661C395.829,221.493 395.799,221.337 395.741,221.192C395.682,221.048 395.594,220.922 395.477,220.814C395.36,220.707 395.214,220.622 395.041,220.56C394.867,220.497 394.665,220.466 394.434,220.466C394.192,220.466 393.983,220.49 393.807,220.539C393.631,220.588 393.487,220.653 393.374,220.735C393.26,220.817 393.177,220.913 393.125,221.022C393.072,221.132 393.045,221.247 393.045,221.368C393.045,221.517 393.07,221.644 393.119,221.749C393.168,221.855 393.256,221.951 393.385,222.039C393.514,222.127 393.691,222.21 393.916,222.288C394.14,222.366 394.428,222.452 394.78,222.546C395.225,222.663 395.622,222.791 395.969,222.93C396.317,223.068 396.61,223.233 396.848,223.425C397.086,223.616 397.267,223.841 397.39,224.099C397.513,224.356 397.575,224.663 397.575,225.019ZM394.206,223.917C393.901,223.835 393.62,223.747 393.362,223.653C393.19,223.735 393.065,223.852 392.987,224.002C392.909,224.152 392.87,224.323 392.87,224.515C392.87,224.671 392.894,224.805 392.943,224.916C392.992,225.027 393.081,225.13 393.21,225.224C393.338,225.317 393.515,225.406 393.74,225.49C393.964,225.574 394.252,225.669 394.604,225.774C394.756,225.817 394.903,225.859 395.044,225.9C395.184,225.941 395.321,225.985 395.454,226.032C395.622,225.942 395.751,225.823 395.84,225.675C395.93,225.526 395.975,225.358 395.975,225.171C395.975,225.03 395.947,224.905 395.89,224.796C395.834,224.687 395.737,224.583 395.6,224.485C395.463,224.388 395.282,224.294 395.055,224.204C394.829,224.114 394.545,224.019 394.206,223.917Z" style="fill:white;fill-rule:nonzero;"/> | ||||||
|  |     </g> | ||||||
|  |     <g transform="matrix(-0.695637,0.718394,-0.718394,-0.695637,185.736,185.735)"> | ||||||
|  |         <circle cx="0" cy="0" r="185.772" style="fill:url(#_Linear1);"/> | ||||||
|  |     </g> | ||||||
|  |     <g transform="matrix(0.291595,0,0,0.291595,185.736,185.735)"> | ||||||
|  |         <g transform="matrix(1,0,0,1,-291.5,-512)"> | ||||||
|  |             <clipPath id="_clip2"> | ||||||
|  |                 <rect x="0" y="0" width="583" height="1024"/> | ||||||
|  |             </clipPath> | ||||||
|  |             <g clip-path="url(#_clip2)"> | ||||||
|  |                 <g transform="matrix(1,0,0,1,-1574,-536.199)"> | ||||||
|  |                     <g transform="matrix(1,0,0,1,0,10.8235)"> | ||||||
|  |                         <g transform="matrix(0.948324,0.317305,0.307947,-0.920356,-304.665,1886.18)"> | ||||||
|  |                             <rect x="1838.29" y="1006.52" width="17.353" height="644.204" style="fill:white;"/> | ||||||
|  |                         </g> | ||||||
|  |                         <path d="M1944.68,934.772C1921.09,896.523 1932.18,782.181 1974.56,655.531C1990.23,608.676 2009.04,563.787 2029.1,525.376L2156.88,568.132C2149.78,610.876 2137.79,658.046 2122.11,704.901C2079.26,832.966 2018.43,931.728 1976.52,946.409L2085.08,568.504L1944.68,934.772Z" style="fill:white;"/> | ||||||
|  |                     </g> | ||||||
|  |                     <g transform="matrix(-1,0,0,1,3730.88,10.8235)"> | ||||||
|  |                         <g transform="matrix(0.948324,0.317305,0.307947,-0.920356,-304.665,1886.18)"> | ||||||
|  |                             <rect x="1838.29" y="1006.52" width="17.353" height="644.204" style="fill:white;"/> | ||||||
|  |                         </g> | ||||||
|  |                         <path d="M1944.68,934.772C1921.09,896.523 1932.18,782.181 1974.56,655.531C1990.23,608.676 2009.04,563.787 2029.1,525.376L2156.88,568.132C2149.78,610.876 2137.79,658.046 2122.11,704.901C2079.26,832.966 2018.43,931.728 1976.52,946.409L2085.08,568.504L1944.68,934.772Z" style="fill:white;"/> | ||||||
|  |                     </g> | ||||||
|  |                 </g> | ||||||
|  |             </g> | ||||||
|  |         </g> | ||||||
|  |     </g> | ||||||
|  |     <defs> | ||||||
|  |         <linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(371.543,-2.84217e-14,2.84217e-14,371.543,-185.772,1.13687e-13)"><stop offset="0" style="stop-color:rgb(131,0,0);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(255,0,0);stop-opacity:1"/></linearGradient> | ||||||
|  |     </defs> | ||||||
|  | </svg> | ||||||
| After Width: | Height: | Size: 6.0 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/static/images/mstile-144x144.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 7.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/static/images/mstile-150x150.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 7.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/static/images/mstile-310x150.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 8.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/static/images/mstile-310x310.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 16 KiB | 
							
								
								
									
										
											BIN
										
									
								
								frontend/static/images/mstile-70x70.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 5.0 KiB | 
							
								
								
									
										19
									
								
								frontend/static/images/site.webmanifest
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,19 @@ | |||||||
|  | { | ||||||
|  |     "name": "", | ||||||
|  |     "short_name": "", | ||||||
|  |     "icons": [ | ||||||
|  |         { | ||||||
|  |             "src": "/android-chrome-192x192.png", | ||||||
|  |             "sizes": "192x192", | ||||||
|  |             "type": "image/png" | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |             "src": "/android-chrome-512x512.png", | ||||||
|  |             "sizes": "512x512", | ||||||
|  |             "type": "image/png" | ||||||
|  |         } | ||||||
|  |     ], | ||||||
|  |     "theme_color": "#ffffff", | ||||||
|  |     "background_color": "#ffffff", | ||||||
|  |     "display": "standalone" | ||||||
|  | } | ||||||
							
								
								
									
										4
									
								
								frontend/static/jstable.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | |||||||
|  | /*! | ||||||
|  |  * JSTable v1.6.5 | ||||||
|  |  */ | ||||||
|  | const JSTableDefaultConfig={perPage:5,perPageSelect:[5,10,15,20,25],sortable:!0,searchable:!0,nextPrev:!0,firstLast:!1,prevText:"‹",nextText:"›",firstText:"«",lastText:"»",ellipsisText:"…",truncatePager:!0,pagerDelta:2,classes:{top:"dt-top",info:"dt-info",input:"dt-input",table:"dt-table",bottom:"dt-bottom",search:"dt-search",sorter:"dt-sorter",wrapper:"dt-wrapper",dropdown:"dt-dropdown",ellipsis:"dt-ellipsis",selector:"dt-selector",container:"dt-container",pagination:"dt-pagination",loading:"dt-loading",message:"dt-message"},labels:{placeholder:"Search...",perPage:"{select} entries per page",noRows:"No entries found",info:"Showing {start} to {end} of {rows} entries",loading:"Loading...",infoFiltered:"Showing {start} to {end} of {rows} entries (filtered from {rowsTotal} entries)"},layout:{top:"{select}{search}",bottom:"{info}{pager}"},serverSide:!1,deferLoading:null,ajax:null,ajaxParams:{},queryParams:{page:"page",search:"search",sortColumn:"sortColumn",sortDirection:"sortDirection",perPage:"perPage"},addQueryParams:!0,rowAttributesCreator:null,searchDelay:null,method:"GET"};class JSTable{constructor(e,t={}){let s=e;"string"==typeof e&&(s=document.querySelector(e)),null!==s&&(this.config=this._merge(JSTableDefaultConfig,t),this.table=new JSTableElement(s),this.currentPage=1,this.columnRenderers=[],this.columnsNotSearchable=[],this.searchQuery=null,this.sortColumn=null,this.sortDirection="asc",this.isSearching=!1,this.dataCount=null,this.filteredDataCount=null,this.searchTimeout=null,this.pager=new JSTablePager(this),this._build(),this._buildColumns(),this.update(null===this.config.deferLoading),this._bindEvents(),this._emit("init"),this._parseQueryParams())}_build(){let e=this.config;this.wrapper=document.createElement("div"),this.wrapper.className=e.classes.wrapper;var t=["<div class='",e.classes.top,"'>",e.layout.top,"</div>","<div class='",e.classes.container,"'>","<div class='",e.classes.loading," hidden'>",e.labels.loading,"</div>","</div>","<div class='",e.classes.bottom,"'>",e.layout.bottom,"</div>"].join("");if(t=t.replace("{info}","<div class='"+e.classes.info+"'></div>"),e.perPageSelect){var s=["<div class='",e.classes.dropdown,"'>","<label>",e.labels.perPage,"</label>","</div>"].join(""),a=document.createElement("select");a.className=e.classes.selector,e.perPageSelect.forEach((function(t){var s=t===e.perPage,r=new Option(t,t,s,s);a.add(r)})),s=s.replace("{select}",a.outerHTML),t=t.replace(/\{select\}/g,s)}else t=t.replace(/\{select\}/g,"");if(e.searchable){var r=["<div class='",e.classes.search,"'>","<input class='",e.classes.input,"' placeholder='",e.labels.placeholder,"' type='text'>","</div>"].join("");t=t.replace(/\{search\}/g,r)}else t=t.replace(/\{search\}/g,"");this.table.element.classList.add(e.classes.table),t=t.replace("{pager}","<div class='"+e.classes.pagination+"'></div>"),this.wrapper.innerHTML=t,this.table.element.parentNode.replaceChild(this.wrapper,this.table.element),this.wrapper.querySelector("."+e.classes.container).appendChild(this.table.element),this._updatePagination(),this._updateInfo()}async update(e=!0){var t=this;this.currentPage>this.pager.getPages()&&(this.currentPage=this.pager.getPages());let s=t.wrapper.querySelector(" ."+t.config.classes.loading);if(s.classList.remove("hidden"),this.table.header.getCells().forEach((function(e,s){let a=t.table.head.rows[0].cells[s];a.innerHTML=e.getInnerHTML(),e.classes.length>0&&(a.className=e.classes.join(" "));for(let t in e.attributes)a.setAttribute(t,e.attributes[t]);a.setAttribute("data-sortable",e.isSortable)})),e)return this.getPageData(this.currentPage).then((function(e){t.table.element.classList.remove("hidden"),t.table.body.innerHTML="",e.forEach((function(e){t.table.body.appendChild(e.getFormatted(t.columnRenderers,t.config.rowAttributesCreator))})),s.classList.add("hidden")})).then((function(){t.getDataCount()<=0&&(t.wrapper.classList.remove("search-results"),t.setMessage(t.config.labels.noRows)),t._emit("update")})).then((function(){t._updatePagination(),t._updateInfo()}));t.table.element.classList.remove("hidden"),t.table.body.innerHTML="",this.getDataCount()<=0&&(t.wrapper.classList.remove("search-results"),t.setMessage(t.config.labels.noRows)),this._getData().forEach((function(e){t.table.body.appendChild(e.getFormatted(t.columnRenderers,t.config.rowAttributesCreator))})),s.classList.add("hidden")}_updatePagination(){let e=this.wrapper.querySelector(" ."+this.config.classes.pagination);e.innerHTML="",e.appendChild(this.pager.render(this.currentPage))}_updateInfo(){let e=this.wrapper.querySelector(" ."+this.config.classes.info),t=this.isSearching?this.config.labels.infoFiltered:this.config.labels.info;if(e&&t.length){var s=t.replace("{start}",this.getDataCount()>0?this._getPageStartIndex()+1:0).replace("{end}",this._getPageEndIndex()+1).replace("{page}",this.currentPage).replace("{pages}",this.pager.getPages()).replace("{rows}",this.getDataCount()).replace("{rowsTotal}",this.getDataCountTotal());e.innerHTML=s}}_getPageStartIndex(){return(this.currentPage-1)*this.config.perPage}_getPageEndIndex(){let e=this.currentPage*this.config.perPage-1;return e>this.getDataCount()-1?this.getDataCount()-1:e}_getData(){return this._emit("getData",this.table.dataRows),this.table.dataRows.filter((function(e){return e.visible}))}_fetchData(){var e=this;let t={searchQuery:this.searchQuery,sortColumn:this.sortColumn,sortDirection:this.sortDirection,start:this._getPageStartIndex(),length:this.config.perPage,datatable:1};t=Object.assign({},this.config.ajaxParams,t);let s=this.config.ajax+"?"+this._queryParams(t);return fetch(s,{method:this.config.method,credentials:"same-origin",headers:{Accept:"application/json","Content-Type":"application/json"}}).then((function(e){return e.json()})).then((function(t){return e._emit("fetchData",t),e.dataCount=t.recordsTotal,e.filteredDataCount=t.recordsFiltered,t.data})).then((function(e){let t=[];return e.forEach((function(e){t.push(JSTableRow.createFromData(e))})),t})).catch((function(e){console.error(e)}))}_queryParams(e){return Object.keys(e).map((t=>encodeURIComponent(t)+"="+encodeURIComponent(e[t]))).join("&")}getDataCount(){return this.isSearching?this.getDataCountFiltered():this.getDataCountTotal()}getDataCountFiltered(){return this.config.serverSide?this.filteredDataCount:this._getData().length}getDataCountTotal(){return this.config.serverSide?null!==this.config.deferLoading?this.config.deferLoading:this.dataCount:this.table.dataRows.length}getPageData(){if(this.config.serverSide)return this._fetchData();let e=this._getPageStartIndex();var t=this._getPageEndIndex();return Promise.resolve(this._getData()).then((function(s){return s.filter((function(s,a){return a>=e&&a<=t}))}))}async search(e){var t=this;if(this.searchQuery===e.toLowerCase())return!1;if(this.searchQuery=e.toLowerCase(),this.config.searchDelay){if(this.searchTimeout)return!1;this.searchTimeout=setTimeout((function(){t.searchTimeout=null}),this.config.searchDelay)}return this.currentPage=1,this.isSearching=!0,this.searchQuery.length?(this.config.serverSide||this.table.dataRows.forEach((function(e){e.visible=!1,t.searchQuery.split(" ").reduce((function(s,a){var r;let i=e.getCells();return i=i.filter((function(e,s){if(t.columnsNotSearchable.indexOf(s)<0)return!0})),r=i.some((function(e,t){if(e.getTextContent().toLowerCase().indexOf(a)>=0)return!0})),s&&r}),!0)&&(e.visible=!0)})),this.wrapper.classList.add("search-results"),this.update().then((function(){t._emit("search",e)}))):(this.table.dataRows.forEach((function(e){e.visible=!0})),this.isSearching=!1,t.wrapper.classList.remove("search-results"),t.update(),!1)}sort(e,t,s=!1){var a=this;if(this.sortColumn=e||0,this.sortDirection=t,this.sortColumn<0||this.sortColumn>this.table.getColumnCount()-1)return!1;var r=this.table.header.getCell(this.sortColumn),i=this.table.dataRows;this.table.header.getCells().forEach((function(e){e.removeClass("asc"),e.removeClass("desc")})),r.addClass(this.sortDirection),this.config.serverSide||(i=i.sort((function(e,t){var s=e.getCellTextContent(a.sortColumn).toLowerCase(),r=t.getCellTextContent(a.sortColumn).toLowerCase();return s=s.replace(/(\$|\,|\s|%)/g,""),r=r.replace(/(\$|\,|\s|%)/g,""),s=isNaN(s)||""===s?s:parseFloat(s),r=isNaN(r)||""===r?r:parseFloat(r),""===s&&""!==r||!isNaN(s)&&isNaN(r)?"asc"===a.sortDirection?1:-1:""!==s&&""===r||isNaN(s)&&!isNaN(r)?"asc"===a.sortDirection?-1:1:"asc"===a.sortDirection?s===r?0:s>r?1:-1:s===r?0:s<r?1:-1})),this.table.dataRows=i),this.config.serverSide&&s||this.update(),this._emit("sort",this.sortColumn,this.sortDirection)}async paginate(e){var t=this;return this.currentPage=e,this.update().then((function(){t._emit("paginate",t.currentPage,e)}))}_setQueryParam(e,t){if(!this.config.addQueryParams)return;const s=new URL(window.location.href);s.searchParams.set(this.config.queryParams[e],t),window.history.replaceState(null,null,s)}_bindEvents(){var e=this;this.wrapper.addEventListener("click",(function(t){var s=t.target;if(s.hasAttribute("data-page")){t.preventDefault();let a=parseInt(s.getAttribute("data-page"),10);e.paginate(a),e._setQueryParam("page",a)}if("TH"===s.nodeName&&s.hasAttribute("data-sortable")){if("false"===s.getAttribute("data-sortable"))return!1;t.preventDefault();let a=s.classList.contains("asc")?"desc":"asc";e.sort(s.cellIndex,a),e._setQueryParam("sortColumn",s.cellIndex),e._setQueryParam("sortDirection",a)}})),this.config.perPageSelect&&this.wrapper.addEventListener("change",(function(t){var s=t.target;if("SELECT"===s.nodeName&&s.classList.contains(e.config.classes.selector)){t.preventDefault();let a=parseInt(s.value,10);e._emit("perPageChange",e.config.perPage,a),e.config.perPage=a,e.update(),e._setQueryParam("perPage",a)}})),this.config.searchable&&this.wrapper.addEventListener("keyup",(function(t){"INPUT"===t.target.nodeName&&t.target.classList.contains(e.config.classes.input)&&(t.preventDefault(),e.search(t.target.value),e._setQueryParam("search",t.target.value))}))}on(e,t){this.events=this.events||{},this.events[e]=this.events[e]||[],this.events[e].push(t)}off(e,t){this.events=this.events||{},e in this.events!=!1&&this.events[e].splice(this.events[e].indexOf(t),1)}_emit(e){if(this.events=this.events||{},e in this.events!=!1)for(var t=0;t<this.events[e].length;t++)this.events[e][t].apply(this,Array.prototype.slice.call(arguments,1))}setMessage(e){var t=this.table.getColumnCount(),s=document.createElement("tr");s.innerHTML='<td class="'+this.config.classes.message+'" colspan="'+t+'">'+e+"</td>",this.table.body.innerHTML="",this.table.body.appendChild(s)}_buildColumns(){var e=this;let t=null,s=null;this.config.columns&&this.config.columns.forEach((function(a){isNaN(a.select)||(a.select=[a.select]),a.select.forEach((function(r){var i=e.table.header.getCell(r);if(void 0!==i){if(a.hasOwnProperty("render")&&"function"==typeof a.render&&(e.columnRenderers[r]=a.render),a.hasOwnProperty("sortable")){let r=!1;i.hasSortable?r=i.isSortable:(r=a.sortable,i.setSortable(r)),r&&(i.addClass(e.config.classes.sorter),a.hasOwnProperty("sort")&&1===a.select.length&&(t=a.select[0],s=a.sort))}a.hasOwnProperty("searchable")&&(i.addAttribute("data-searchable",a.searchable),!1===a.searchable&&e.columnsNotSearchable.push(r))}}))})),this.table.header.getCells().forEach((function(a,r){null===a.isSortable&&a.setSortable(e.config.sortable),a.isSortable&&(a.addClass(e.config.classes.sorter),a.hasSort&&(t=r,s=a.sortDirection))})),null!==t&&e.sort(t,s,!0)}_merge(e,t){var s=this;return Object.keys(e).forEach((function(a){!t.hasOwnProperty(a)||"object"!=typeof t[a]||t[a]instanceof Array||null===t[a]?t.hasOwnProperty(a)||(t[a]=e[a]):s._merge(e[a],t[a])})),t}async _parseQueryParams(){const e=new URLSearchParams(window.location.search);let t=e.get(this.config.queryParams.perPage);if(t){t=parseInt(t),this.config.perPage=t,this.wrapper.querySelectorAll("."+this.config.classes.selector).forEach((function(e){e.querySelectorAll("option").forEach((e=>e.removeAttribute("selected"))),e.value=t,e.querySelector(`option[value='${t}']`).setAttribute("selected","")})),this.update()}let s=e.get(this.config.queryParams.search);if(s){this.wrapper.querySelectorAll("."+this.config.classes.input).forEach((function(e){e.value=s})),await this.search(s)}let a=e.get(this.config.queryParams.page);a&&await this.paginate(parseInt(a));let r=e.get(this.config.queryParams.sortColumn);if(r){r=parseInt(r);let t=e.get(this.config.queryParams.sortDirection);t=null==t?"asc":t,this.sort(r,t)}}}class JSTableElement{constructor(e){this.element=e,this.body=this.element.tBodies[0],this.head=this.element.tHead,this.rows=Array.from(this.element.rows).map((function(e,t){return new JSTableRow(e,e.parentNode.nodeName,t)})),this.dataRows=this._getBodyRows(),this.header=this._getHeaderRow()}_getBodyRows(){return this.rows.filter((function(e){return!e.isHeader&&!e.isFooter}))}_getHeaderRow(){return this.rows.find((function(e){return e.isHeader}))}getColumnCount(){return this.header.getColumnCount()}getFooterRow(){return this.rows.find((function(e){return e.isFooter}))}}class JSTableRow{constructor(e,t="",s=null){this.cells=Array.from(e.cells).map((function(e){return new JSTableCell(e)})),this.d=this.cells.length,this.isHeader="THEAD"===t,this.isFooter="TFOOT"===t,this.visible=!0,this.rowID=s;var a=this;this.attributes={},[...e.attributes].forEach((function(e){a.attributes[e.name]=e.value}))}getCells(){return Array.from(this.cells)}getColumnCount(){return this.cells.length}getCell(e){return this.cells[e]}getCellTextContent(e){return this.getCell(e).getTextContent()}static createFromData(e){let t=document.createElement("tr");if(e.hasOwnProperty("data")){if(e.hasOwnProperty("attributes"))for(const s in e.attributes)t.setAttribute(s,e.attributes[s]);e=e.data}return e.forEach((function(e){let s=document.createElement("td");if(s.innerHTML=e&&e.hasOwnProperty("data")?e.data:e,e&&e.hasOwnProperty("attributes"))for(const t in e.attributes)s.setAttribute(t,e.attributes[t]);t.appendChild(s)})),new JSTableRow(t)}getFormatted(e,t=null){let s=document.createElement("tr");var a=this;for(let e in this.attributes)s.setAttribute(e,this.attributes[e]);let r=t?t.call(this,this.getCells()):{};for(const e in r)s.setAttribute(e,r[e]);return this.getCells().forEach((function(t,r){var i=document.createElement("td");i.innerHTML=t.getInnerHTML(),e.hasOwnProperty(r)&&(i.innerHTML=e[r].call(a,t.getElement(),r)),t.classes.length>0&&(i.className=t.classes.join(" "));for(let e in t.attributes)i.setAttribute(e,t.attributes[e]);s.appendChild(i)})),s}setCellClass(e,t){this.cells[e].addClass(t)}}class JSTableCell{constructor(e){this.textContent=e.textContent,this.innerHTML=e.innerHTML,this.className="",this.element=e,this.hasSortable=e.hasAttribute("data-sortable"),this.isSortable=this.hasSortable?"true"===e.getAttribute("data-sortable"):null,this.hasSort=e.hasAttribute("data-sort"),this.sortDirection=e.getAttribute("data-sort"),this.classes=[];var t=this;this.attributes={},[...e.attributes].forEach((function(e){t.attributes[e.name]=e.value}))}getElement(){return this.element}getTextContent(){return this.textContent}getInnerHTML(){return this.innerHTML}setClass(e){this.className=e}setSortable(e){this.isSortable=e}addClass(e){this.classes.push(e)}removeClass(e){this.classes.indexOf(e)>=0&&this.classes.splice(this.classes.indexOf(e),1)}addAttribute(e,t){this.attributes[e]=t}}class JSTablePager{constructor(e){this.instance=e}getPages(){let e=Math.ceil(this.instance.getDataCount()/this.instance.config.perPage);return 0===e?1:e}render(){var e=this.instance.config;let t=this.getPages(),s=document.createElement("ul");if(t>1){let a=1===this.instance.currentPage?1:this.instance.currentPage-1,r=this.instance.currentPage===t?t:this.instance.currentPage+1;e.firstLast&&s.appendChild(this.createItem("pager",1,e.firstText)),e.nextPrev&&s.appendChild(this.createItem("pager",a,e.prevText)),this.truncate().forEach((function(e){s.appendChild(e)})),e.nextPrev&&s.appendChild(this.createItem("pager",r,e.nextText)),e.firstLast&&s.appendChild(this.createItem("pager",t,e.lastText))}return s}createItem(e,t,s,a){let r=document.createElement("li");return r.className=e,r.innerHTML=a?"<span>"+s+"</span>":'<a href="#" data-page="'+t+'">'+s+"</a>",r}isValidPage(e){return e>0&&e<=this.getPages()}truncate(){var e,t=this,s=t.instance.config,a=2*s.pagerDelta,r=t.instance.currentPage,i=r-s.pagerDelta,n=r+s.pagerDelta,o=this.getPages(),l=[],c=[];if(this.instance.config.truncatePager){r<4-s.pagerDelta+a?n=3+a:r>this.getPages()-(3-s.pagerDelta+a)&&(i=this.getPages()-(2+a));for(var h=1;h<=o;h++)(1===h||h===o||h>=i&&h<=n)&&l.push(h);l.forEach((function(a){e&&(a-e==2?c.push(t.createItem("",e+1,e+1)):a-e!=1&&c.push(t.createItem(s.classes.ellipsis,0,s.ellipsisText,!0))),c.push(t.createItem(a==r?"active":"",a,a)),e=a}))}else for(let e=1;e<=this.getPages();e++)c.push(this.createItem(e===r?"active":"",e,e));return c}}window.JSTable=JSTable; | ||||||
							
								
								
									
										21
									
								
								frontend/table.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | // @ts-ignore   | ||||||
|  | new JSTable('#basic', { | ||||||
|  |   perPage: 100, | ||||||
|  |   perPageSelect: [10,100], | ||||||
|  |  | ||||||
|  |    // Customise the display text | ||||||
|  |   labels: { | ||||||
|  |       placeholder: 'Suchen (z.B. "Linz")', | ||||||
|  |       perPage: '{select} per Seite', | ||||||
|  |       noRows: 'Keine Einträge gefunden', | ||||||
|  |       info: 'Zeigt {start} bis {end} von {rows} Einträgen', | ||||||
|  |       loading: 'Laden...', | ||||||
|  |       infoFiltered: 'Zeigt {start} bis {end} von {rows} Einträgen (gefiltert aus {rowsTotal} Einträgen)' | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   // Customise the layout | ||||||
|  |   layout: { | ||||||
|  |       top: '{search}{select}', | ||||||
|  |       bottom: '{info}{pager}' | ||||||
|  |   }, | ||||||
|  | }); | ||||||
| @@ -1,114 +1,195 @@ | |||||||
| import { test, expect, Page } from '@playwright/test'; | import { test, expect } from "@playwright/test"; | ||||||
|  |  | ||||||
| test('cox can create and delete trip', async ({ page }) => { | test("cox can create and delete trip", async ({ page }) => { | ||||||
|   await page.goto('http://localhost:8000/auth'); |   await page.goto("/auth"); | ||||||
|   await page.getByPlaceholder('Name').click(); |   await page.getByPlaceholder("Name").click(); | ||||||
|   await page.getByPlaceholder('Name').fill('cox'); |   await page.getByPlaceholder("Name").fill("cox"); | ||||||
|   await page.getByPlaceholder('Name').press('Tab'); |   await page.getByPlaceholder("Name").press("Tab"); | ||||||
|   await page.getByPlaceholder('Passwort').fill('cox'); |   await page.getByPlaceholder("Passwort").fill("cox"); | ||||||
|   await page.getByPlaceholder('Passwort').press('Enter'); |   await page.getByPlaceholder("Passwort").press("Enter"); | ||||||
|   await page.getByRole('link', { name: 'Geplante Ausfahrten' }).click(); |   await page.locator('li').filter({ hasText: 'Geplante Ausfahrten' }).getByRole('link').click(); | ||||||
|   await page.locator('.relative').first().click(); |   await page.locator('a[href="#"]:has-text("Ausfahrt")').first().click(); | ||||||
|   await page.locator('#sidebar #planned_starting_time').click(); |   await page.locator("#sidebar #planned_starting_time").click(); | ||||||
|   await page.locator('#sidebar #planned_starting_time').fill('18:00'); |   await page.locator("#sidebar #planned_starting_time").fill("18:00"); | ||||||
|   await page.locator('#sidebar #planned_starting_time').press('Tab'); |   await page.locator("#sidebar #planned_starting_time").press("Tab"); | ||||||
|   await page.locator('#sidebar #planned_starting_time').press('Tab'); |   await page.locator("#sidebar #planned_starting_time").press("Tab"); | ||||||
|   await page.getByRole('spinbutton').fill('5'); |   await page.getByRole("spinbutton").fill("5"); | ||||||
|   await page.getByRole('button', { name: 'Erstellen', exact: true }).click(); |   await page.getByRole("button", { name: "Erstellen", exact: true }).click(); | ||||||
|   await expect(page.locator('body')).toContainText('18:00 Uhr (cox) Details'); |   await expect(page.locator("body")).toContainText("18:00 Uhr (cox) Details"); | ||||||
|  |  | ||||||
|   await page.goto('http://localhost:8000/planned'); |   await page.goto("/planned"); | ||||||
|   await page.getByRole('link', { name: 'Details' }).click(); |   await page.getByRole('link', { name: 'Details' }).nth(1).click(); | ||||||
|   await page.getByRole('link', { name: 'Termin löschen' }).click(); |   await page.getByRole("link", { name: "Termin löschen" }).click(); | ||||||
|   await expect(page.locator('body')).toContainText('Erfolgreich gelöscht!'); |   await expect(page.locator("body")).toContainText("Erfolgreich gelöscht!"); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| // TODO: group -> cox can create trips | // TODO: group -> cox can create trips | ||||||
| // TODO: cox can help/register at trips/events | // TODO: cox can help/register at trips/events | ||||||
|  |  | ||||||
| test.describe('cox can edit trips', () => { | test.describe("cox can edit trips", () => { | ||||||
|   let sharedPage: Page; |   let sharedPage: Page; | ||||||
|  |  | ||||||
|   test.beforeEach(async ({ browser }) => { |   test.beforeAll(async ({ browser }) => { | ||||||
|     const page = await browser.newPage(); |     const page = await browser.newPage(); | ||||||
|  |  | ||||||
|     await page.goto('http://localhost:8000/auth'); |     await page.goto("/auth"); | ||||||
|     await page.getByPlaceholder('Name').click(); |     await page.getByPlaceholder("Name").click(); | ||||||
|     await page.getByPlaceholder('Name').fill('cox'); |     await page.getByPlaceholder("Name").fill("cox"); | ||||||
|     await page.getByPlaceholder('Name').press('Tab'); |     await page.getByPlaceholder("Name").press("Tab"); | ||||||
|     await page.getByPlaceholder('Passwort').fill('cox'); |     await page.getByPlaceholder("Passwort").fill("cox"); | ||||||
|     await page.getByPlaceholder('Passwort').press('Enter'); |     await page.getByPlaceholder("Passwort").press("Enter"); | ||||||
|     await page.getByRole('link', { name: 'Geplante Ausfahrten' }).click(); |     await page.locator('li').filter({ hasText: 'Geplante Ausfahrten' }).getByRole('link').click(); | ||||||
|     await page.locator('.relative').first().click(); |     await page.locator('a[href="#"]:has-text("Ausfahrt")').first().click(); | ||||||
|     await page.locator('#sidebar #planned_starting_time').click(); |     await page.locator("#sidebar #planned_starting_time").click(); | ||||||
|     await page.locator('#sidebar #planned_starting_time').fill('18:00'); |     await page.locator("#sidebar #planned_starting_time").fill("18:00"); | ||||||
|     await page.locator('#sidebar #planned_starting_time').press('Tab'); |     await page.locator("#sidebar #planned_starting_time").press("Tab"); | ||||||
|     await page.locator('#sidebar #planned_starting_time').press('Tab'); |     await page.locator("#sidebar #planned_starting_time").press("Tab"); | ||||||
|     await page.getByRole('spinbutton').fill('5'); |     await page.getByRole("spinbutton").fill("5"); | ||||||
|     await page.getByRole('button', { name: 'Erstellen', exact: true }).click(); |     await page.getByRole("button", { name: "Erstellen", exact: true }).click(); | ||||||
|  |  | ||||||
|     sharedPage = page; |     sharedPage = page; | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   test('edit remarks', async () => { |   test("edit remarks", async () => { | ||||||
|     await sharedPage.goto('http://localhost:8000/planned'); |     await sharedPage.goto("/planned"); | ||||||
|     await sharedPage.getByRole('link', { name: 'Details' }).click(); |     await sharedPage.getByRole('link', { name: 'Details' }).nth(1).click(); | ||||||
|     await sharedPage.locator('#sidebar #notes').click(); |     await sharedPage.locator("#sidebar #notes").click(); | ||||||
|     await sharedPage.locator('#sidebar #notes').fill('Meine Anmerkung'); |     await sharedPage.locator("#sidebar #notes").fill("Meine Anmerkung"); | ||||||
|     await sharedPage.getByRole('button', { name: 'Speichern' }).click(); |     await sharedPage.getByRole("button", { name: "Speichern" }).click(); | ||||||
|     await sharedPage.getByRole('link', { name: 'Details' }).click(); |     await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); | ||||||
|     await expect(sharedPage.locator('#sidebar')).toContainText('Meine Anmerkung'); |     await expect(sharedPage.locator("#sidebar")).toContainText( | ||||||
|  |       "Meine Anmerkung", | ||||||
|  |     ); | ||||||
|  |  | ||||||
|     await sharedPage.getByRole('button', { name: 'Ausfahrt erstellen schließen' }).click(); |     await sharedPage | ||||||
|  |       .getByRole("button", { name: "Ausfahrt erstellen schließen" }) | ||||||
|  |       .click(); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   test('add and remove guest', async () => { |   test("add and remove guest", async () => { | ||||||
|     await sharedPage.goto('http://localhost:8000/planned'); |     await sharedPage.goto("/planned"); | ||||||
|     await sharedPage.getByRole('link', { name: 'Details' }).click(); |     await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); | ||||||
|     await sharedPage.locator('#sidebar #user_note').click(); |     await sharedPage.locator("#sidebar #user_note").click(); | ||||||
|     await sharedPage.locator('#sidebar #user_note').fill('Mein Gast'); |     await sharedPage.locator("#sidebar #user_note").fill("Mein Gast"); | ||||||
|     await sharedPage.getByRole('button', { name: 'Gast hinzufügen' }).click(); |     await sharedPage.getByRole("button", { name: "Gast hinzufügen" }).click(); | ||||||
|     await expect(sharedPage.locator('body')).toContainText('Erfolgreich angemeldet!'); |     await expect(sharedPage.locator("body")).toContainText( | ||||||
|     await sharedPage.getByRole('link', { name: 'Details' }).click(); |       "Erfolgreich angemeldet!", | ||||||
|     await expect(sharedPage.locator('#sidebar')).toContainText('Freie Plätze: 4'); |     ); | ||||||
|     await expect(sharedPage.locator('#sidebar')).toContainText('Mein Gast (Gast) Abmelden'); |     await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); | ||||||
|     await expect(sharedPage.getByRole('link', { name: 'Termin löschen' })).not.toBeVisible(); |     await expect(sharedPage.locator("#sidebar")).toContainText( | ||||||
|  |       "Freie Plätze: 4", | ||||||
|  |     ); | ||||||
|  |     await expect(sharedPage.locator("#sidebar")).toContainText( | ||||||
|  |       "Mein Gast (Gast) Abmelden", | ||||||
|  |     ); | ||||||
|  |     await expect( | ||||||
|  |       sharedPage.getByRole("link", { name: "Termin löschen" }), | ||||||
|  |     ).not.toBeVisible(); | ||||||
|  |  | ||||||
|  |     await sharedPage.getByRole("link", { name: "Abmelden" }).click(); | ||||||
|  |     await expect(sharedPage.locator("body")).toContainText( | ||||||
|  |       "Erfolgreich abgemeldet!", | ||||||
|  |     ); | ||||||
|  |     await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); | ||||||
|  |     await expect(sharedPage.locator("#sidebar")).toContainText( | ||||||
|  |       "Freie Plätze: 5", | ||||||
|  |     ); | ||||||
|  |     await expect(sharedPage.locator("#sidebar")).toContainText( | ||||||
|  |       "Keine Ruderer angemeldet", | ||||||
|  |     ); | ||||||
|  |     await expect( | ||||||
|  |       sharedPage.getByRole("link", { name: "Termin löschen" }), | ||||||
|  |     ).toBeVisible(); | ||||||
|  |  | ||||||
|  |     await sharedPage | ||||||
|  |       .getByRole("button", { name: "Ausfahrt erstellen schließen" }) | ||||||
|  |       .click(); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   test("change amount rower", async () => { | ||||||
|  |     await sharedPage.goto("/planned"); | ||||||
|  |     await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); | ||||||
|  |     await expect(sharedPage.locator("#sidebar")).toContainText( | ||||||
|  |       "Freie Plätze: 5", | ||||||
|  |     ); | ||||||
|  |     await sharedPage.getByRole("spinbutton").click(); | ||||||
|  |     await sharedPage.getByRole("spinbutton").fill("3"); | ||||||
|  |     await sharedPage.getByRole("button", { name: "Speichern" }).click(); | ||||||
|  |     await expect(sharedPage.locator("body")).toContainText( | ||||||
|  |       "Ausfahrt erfolgreich aktualisiert.", | ||||||
|  |     ); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   test("call off trip", async () => { | ||||||
|  |     // Someone registers... | ||||||
|  |     await sharedPage.goto("/auth/logout"); | ||||||
|  |     await sharedPage.goto("/auth"); | ||||||
|  |     await sharedPage.getByPlaceholder("Name").click(); | ||||||
|  |     await sharedPage.getByPlaceholder("Name").fill("rower"); | ||||||
|  |     await sharedPage.getByPlaceholder("Name").press("Tab"); | ||||||
|  |     await sharedPage.getByPlaceholder("Passwort").fill("rower"); | ||||||
|  |     await sharedPage.getByPlaceholder("Passwort").press("Enter"); | ||||||
|  |  | ||||||
|  |     await sharedPage.goto("/planned"); | ||||||
|  |     await sharedPage.getByRole('link', { name: 'Mitrudern' }).nth(1).click(); | ||||||
|  |  | ||||||
|  |      | ||||||
|  |     // Login as cox again | ||||||
|  |     await sharedPage.goto("/auth/logout"); | ||||||
|  |     await sharedPage.goto("/auth"); | ||||||
|  |     await sharedPage.getByPlaceholder("Name").click(); | ||||||
|  |     await sharedPage.getByPlaceholder("Name").fill("cox"); | ||||||
|  |     await sharedPage.getByPlaceholder("Name").press("Tab"); | ||||||
|  |     await sharedPage.getByPlaceholder("Passwort").fill("cox"); | ||||||
|  |     await sharedPage.getByPlaceholder("Passwort").press("Enter"); | ||||||
|  |  | ||||||
|  |     await sharedPage.goto("/planned"); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     // ... now I can cancel trip | ||||||
|  |     await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); | ||||||
|  |     await sharedPage.getByRole("button", { name: "Ausfahrt absagen" }).click(); | ||||||
|  |     await expect(sharedPage.locator("body")).toContainText( | ||||||
|  |       "Ausfahrt erfolgreich aktualisiert.", | ||||||
|  |     ); | ||||||
|  |     await expect(sharedPage.locator("body")).toContainText("(Absage cox)"); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     // Done with the test -> cancel the cancellation of the trip, otherwise the afterAll function below fails | ||||||
|  |     await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); | ||||||
|  |     await sharedPage.getByRole("spinbutton").click(); | ||||||
|  |     await sharedPage.getByRole("spinbutton").fill("3"); | ||||||
|  |     await sharedPage.getByRole("button", { name: "Speichern" }).click(); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     // deregistering | ||||||
|  |     await sharedPage.goto("/auth/logout"); | ||||||
|  |     await sharedPage.goto("/auth"); | ||||||
|  |     await sharedPage.getByPlaceholder("Name").click(); | ||||||
|  |     await sharedPage.getByPlaceholder("Name").fill("rower"); | ||||||
|  |     await sharedPage.getByPlaceholder("Name").press("Tab"); | ||||||
|  |     await sharedPage.getByPlaceholder("Passwort").fill("rower"); | ||||||
|  |     await sharedPage.getByPlaceholder("Passwort").press("Enter"); | ||||||
|  |  | ||||||
|  |     await sharedPage.goto("/planned"); | ||||||
|     await sharedPage.getByRole('link', { name: 'Abmelden' }).click(); |     await sharedPage.getByRole('link', { name: 'Abmelden' }).click(); | ||||||
|     await expect(sharedPage.locator('body')).toContainText('Erfolgreich abgemeldet!'); |  | ||||||
|     await sharedPage.getByRole('link', { name: 'Details' }).click(); |  | ||||||
|     await expect(sharedPage.locator('#sidebar')).toContainText('Freie Plätze: 5'); |  | ||||||
|     await expect(sharedPage.locator('#sidebar')).toContainText('Keine Ruderer angemeldet'); |  | ||||||
|     await expect(sharedPage.getByRole('link', { name: 'Termin löschen' })).toBeVisible(); |  | ||||||
|  |  | ||||||
|     await sharedPage.getByRole('button', { name: 'Ausfahrt erstellen schließen' }).click(); |  | ||||||
|  |     // now cox can delete trip again in afterAll | ||||||
|  |     await sharedPage.goto("/auth/logout"); | ||||||
|  |     await sharedPage.goto("/auth"); | ||||||
|  |     await sharedPage.getByPlaceholder("Name").click(); | ||||||
|  |     await sharedPage.getByPlaceholder("Name").fill("cox"); | ||||||
|  |     await sharedPage.getByPlaceholder("Name").press("Tab"); | ||||||
|  |     await sharedPage.getByPlaceholder("Passwort").fill("cox"); | ||||||
|  |     await sharedPage.getByPlaceholder("Passwort").press("Enter"); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   test('change amount rower', async () => { |   test.afterAll(async () => { | ||||||
|     await sharedPage.goto('http://localhost:8000/planned'); |     await sharedPage.goto("/planned"); | ||||||
|     await sharedPage.getByRole('link', { name: 'Details' }).click(); |     await sharedPage.getByRole('link', { name: 'Details' }).nth(1).click(); | ||||||
|     await expect(sharedPage.locator('#sidebar')).toContainText('Freie Plätze: 5'); |     await sharedPage.getByRole("link", { name: "Termin löschen" }).click(); | ||||||
|     await sharedPage.getByRole('spinbutton').click(); |  | ||||||
|     await sharedPage.getByRole('spinbutton').fill('3'); |  | ||||||
|     await sharedPage.getByRole('button', { name: 'Speichern' }).click(); |  | ||||||
|     await expect(sharedPage.locator('body')).toContainText('Ausfahrt erfolgreich aktualisiert.'); |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   test('call off trip', async () => { |  | ||||||
|     await sharedPage.goto('http://localhost:8000/planned'); |  | ||||||
|     await sharedPage.getByRole('link', { name: 'Details' }).click(); |  | ||||||
|     await expect(sharedPage.locator('#sidebar')).toContainText('Freie Plätze: 5'); |  | ||||||
|     await sharedPage.getByRole('spinbutton').click(); |  | ||||||
|     await sharedPage.getByRole('spinbutton').fill('0'); |  | ||||||
|     await sharedPage.getByRole('button', { name: 'Speichern' }).click(); |  | ||||||
|     await expect(sharedPage.locator('body')).toContainText('Ausfahrt erfolgreich aktualisiert.'); |  | ||||||
|     await expect(sharedPage.locator('body')).toContainText('(Absage cox )'); |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   test.afterEach(async () => { |  | ||||||
|     await sharedPage.goto('http://localhost:8000/planned'); |  | ||||||
|     await sharedPage.getByRole('link', { name: 'Details' }).click(); |  | ||||||
|     await sharedPage.getByRole('link', { name: 'Termin löschen' }).click(); |  | ||||||
|     await sharedPage.close(); |     await sharedPage.close(); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										381
									
								
								frontend/tests/log.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,381 @@ | |||||||
|  | import { test, expect } from "@playwright/test"; | ||||||
|  |  | ||||||
|  | test("Cox can start and cancel trip", async ({ page }, testInfo) => { | ||||||
|  |   await page.goto("/auth"); | ||||||
|  |   await page.getByPlaceholder("Name").click(); | ||||||
|  |   await page.getByPlaceholder("Name").fill("cox2"); | ||||||
|  |   await page.getByPlaceholder("Name").press("Tab"); | ||||||
|  |   await page.getByPlaceholder("Passwort").fill("cox"); | ||||||
|  |   await page.getByPlaceholder("Passwort").press("Enter"); | ||||||
|  |  | ||||||
|  |   await page.goto("/"); | ||||||
|  |   await page.getByRole("link", { name: "Ausfahrt eintragen" }).click(); | ||||||
|  |   if (testInfo.project.name.includes("Mobile")) { | ||||||
|  |     // No left boat selector on mobile views | ||||||
|  |     await page.getByText('-- Wähle ein Boot aus ---').nth(1).click(); | ||||||
|  |     await page.getByRole("option", { name: "Joe" }).click(); | ||||||
|  |   } else { | ||||||
|  |     await page.getByText('2x', { exact: true }).click(); | ||||||
|  |     await page.getByText("Joe", { exact: true }).click(); | ||||||
|  |   } | ||||||
|  |   await page.getByLabel('Remove item: \'6\'').click(); // remove pre-filled cox2 | ||||||
|  |   await page.getByPlaceholder("Ruderer auswählen").click(); | ||||||
|  |   await page.getByRole("option", { name: "rower2" }).click(); | ||||||
|  |   await page.getByRole("option", { name: "cox2" }).click(); | ||||||
|  |   await expect(page.getByRole("listbox")).toContainText( | ||||||
|  |     "Nur 2 Ruderer können hinzugefügt werden", | ||||||
|  |   ); | ||||||
|  |   await expect(page.locator("#shipmaster-newrowerjs")).toContainText("cox"); | ||||||
|  |   await expect(page.locator("#steering_person-newrowerjs")).toContainText( | ||||||
|  |     "rower2 cox", | ||||||
|  |   ); | ||||||
|  |   await page.getByRole("button", { name: "Ausfahrt eintragen" }).click(); | ||||||
|  |   await expect(page.locator("body")).toContainText( | ||||||
|  |     "Ausfahrt erfolgreich hinzugefügt", | ||||||
|  |   ); | ||||||
|  |   await expect(page.locator("body")).toContainText("Joe"); | ||||||
|  |  | ||||||
|  |   await page.getByRole("link", { name: "Joe" }).click(); | ||||||
|  |   page.once("dialog", (dialog) => { | ||||||
|  |     dialog.accept().catch(() => {}); | ||||||
|  |   }); | ||||||
|  |   await page.getByRole("link", { name: "Löschen" }).click(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | test("Cox can start and finish trip", async ({ page }, testInfo) => { | ||||||
|  |   await page.goto("/auth"); | ||||||
|  |   await page.getByPlaceholder("Name").click(); | ||||||
|  |   await page.getByPlaceholder("Name").fill("cox2"); | ||||||
|  |   await page.getByPlaceholder("Name").press("Tab"); | ||||||
|  |   await page.getByPlaceholder("Passwort").fill("cox"); | ||||||
|  |   await page.getByPlaceholder("Passwort").press("Enter"); | ||||||
|  |  | ||||||
|  |   await page.goto("/"); | ||||||
|  |   await page.getByRole("link", { name: "Ausfahrt eintragen" }).click(); | ||||||
|  |   if (testInfo.project.name.includes("Mobile")) { | ||||||
|  |     // No left boat selector on mobile views | ||||||
|  |     await page.getByText('-- Wähle ein Boot aus ---').nth(1).click(); | ||||||
|  |     await page.getByRole("option", { name: "Joe" }).click(); | ||||||
|  |   } else { | ||||||
|  |     await page.getByText('2x', { exact: true }).click(); | ||||||
|  |     await page.getByText("Joe", { exact: true }).click(); | ||||||
|  |   } | ||||||
|  |   await page.getByLabel('Remove item: \'6\'').click(); // remove pre-filled cox2 | ||||||
|  |   await page.getByPlaceholder("Ruderer auswählen").click(); | ||||||
|  |   await page.getByRole("option", { name: "rower2" }).click(); | ||||||
|  |   await page.getByRole("option", { name: "cox2" }).click(); | ||||||
|  |   await expect(page.getByRole("listbox")).toContainText( | ||||||
|  |     "Nur 2 Ruderer können hinzugefügt werden", | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   // Trip starts 2 hours ago | ||||||
|  |   const datetimeSelector = '#departure'; | ||||||
|  |   const currentValue = await page.$eval(datetimeSelector, el => el.value); | ||||||
|  |   const currentDate = new Date(currentValue); | ||||||
|  |   currentDate.setMinutes(currentDate.getMinutes()); | ||||||
|  |   currentDate.setHours(currentDate.getHours() - new Date().getTimezoneOffset()/60 - 2); | ||||||
|  |   const newDatetime = currentDate.toISOString().slice(0, 16); | ||||||
|  |   await page.$eval(datetimeSelector, (el, value) => el.value = value, newDatetime); | ||||||
|  |  | ||||||
|  |   await expect(page.locator("#shipmaster-newrowerjs")).toContainText("cox"); | ||||||
|  |   await expect(page.locator("#steering_person-newrowerjs")).toContainText( | ||||||
|  |     "rower2 cox", | ||||||
|  |   ); | ||||||
|  |   await page.getByRole("button", { name: "Ausfahrt eintragen" }).click(); | ||||||
|  |   await expect(page.locator("body")).toContainText( | ||||||
|  |     "Ausfahrt erfolgreich hinzugefügt", | ||||||
|  |   ); | ||||||
|  |   await expect(page.locator("body")).toContainText("Joe"); | ||||||
|  |  | ||||||
|  |   await page.goto("/log"); | ||||||
|  |   await page.locator("div:nth-child(2) > .border-0").click(); | ||||||
|  |  | ||||||
|  |   await page.getByRole("combobox", { name: "Destination" }).click(); | ||||||
|  |   await page.getByRole("combobox", { name: "Destination" }).fill("Ottensheim"); | ||||||
|  |   await page.getByRole("button", { name: "Ausfahrt beenden" }).click(); | ||||||
|  |   await expect(page.locator("body")).toContainText( | ||||||
|  |     "Ausfahrt korrekt eingetragen", | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   await page.goto('/log/show'); | ||||||
|  |   await expect(page.locator('body')).toContainText('Joe'); | ||||||
|  |   await expect(page.locator('body')).toContainText('(cox2)'); | ||||||
|  |   await expect(page.locator('body')).toContainText('Ottensheim (25 km)'); | ||||||
|  |   await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2'); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   //Ausloggen... | ||||||
|  |   await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click(); | ||||||
|  |   await page.getByRole('link', { name: 'Ausloggen' }).click(); | ||||||
|  |   // Login as admin | ||||||
|  |   await page.getByPlaceholder("Name").click(); | ||||||
|  |   await page.getByPlaceholder("Name").fill("main"); | ||||||
|  |   await page.getByPlaceholder("Name").press("Tab"); | ||||||
|  |   await page.getByPlaceholder("Passwort").fill("admin"); | ||||||
|  |   await page.getByPlaceholder("Passwort").press("Enter"); | ||||||
|  |  | ||||||
|  |   await page.goto("/log/show"); | ||||||
|  |   await page.getByRole('link', { name: 'Joe' }).nth(1).click(); | ||||||
|  |   page.once("dialog", (dialog) => { | ||||||
|  |     dialog.accept().catch(() => {}); | ||||||
|  |   }); | ||||||
|  |   await page.getByRole('link', { name: 'Löschen' }).click(); | ||||||
|  |  | ||||||
|  |   //Ausloggen... | ||||||
|  |   await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click(); | ||||||
|  |   await page.getByRole('link', { name: 'Ausloggen' }).click(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | test("Kiosk can start and cancel trip", async ({ page }, testInfo) => { | ||||||
|  |   await page.goto("/log/kiosk/ekrv2019/Linz"); | ||||||
|  |   if (testInfo.project.name.includes("Mobile")) { | ||||||
|  |     // No left boat selector on mobile views | ||||||
|  |     await page.getByText('-- Wähle ein Boot aus ---').nth(1).click(); | ||||||
|  |     await page.getByRole("option", { name: "Joe" }).click(); | ||||||
|  |   } else { | ||||||
|  |     await page.getByText('2x', { exact: true }).click(); | ||||||
|  |     await page.getByText("Joe", { exact: true }).click(); | ||||||
|  |   } | ||||||
|  |   await page.getByPlaceholder("Ruderer auswählen").click(); | ||||||
|  |   await page.getByRole("option", { name: "rower2" }).click(); | ||||||
|  |   await page.getByRole("option", { name: "cox2" }).click(); | ||||||
|  |   await expect(page.getByRole("listbox")).toContainText( | ||||||
|  |     "Nur 2 Ruderer können hinzugefügt werden", | ||||||
|  |   ); | ||||||
|  |   await expect(page.locator("#shipmaster-newrowerjs")).toContainText("cox"); | ||||||
|  |   await expect(page.locator("#steering_person-newrowerjs")).toContainText( | ||||||
|  |     "rower2 cox", | ||||||
|  |   ); | ||||||
|  |   await page.getByRole("button", { name: "Ausfahrt eintragen" }).click(); | ||||||
|  |   await expect(page.locator("body")).toContainText( | ||||||
|  |     "Ausfahrt erfolgreich hinzugefügt", | ||||||
|  |   ); | ||||||
|  |   await expect(page.locator("body")).toContainText("Joe"); | ||||||
|  |  | ||||||
|  |   await page.getByRole("link", { name: "Joe" }).click(); | ||||||
|  |   page.once("dialog", (dialog) => { | ||||||
|  |     dialog.accept().catch(() => {}); | ||||||
|  |   }); | ||||||
|  |   await page.getByRole("link", { name: "Löschen" }).click(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | test("Kiosk can start and finish trip", async ({ page }, testInfo) => { | ||||||
|  |   await page.goto("/log/kiosk/ekrv2019/Linz"); | ||||||
|  |  | ||||||
|  |   if (testInfo.project.name.includes("Mobile")) { | ||||||
|  |     // No left boat selector on mobile views | ||||||
|  |     await page.getByText('-- Wähle ein Boot aus ---').nth(1).click(); | ||||||
|  |     await page.getByRole("option", { name: "Joe" }).click(); | ||||||
|  |   } else { | ||||||
|  |     await page.getByText('2x', { exact: true }).click(); | ||||||
|  |     await page.getByText("Joe", { exact: true }).click(); | ||||||
|  |   } | ||||||
|  |   await page.getByPlaceholder("Ruderer auswählen").click(); | ||||||
|  |   await page.getByRole("option", { name: "rower2" }).click(); | ||||||
|  |   await page.getByRole("option", { name: "cox2" }).click(); | ||||||
|  |   await expect(page.getByRole("listbox")).toContainText( | ||||||
|  |     "Nur 2 Ruderer können hinzugefügt werden", | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   // Trip starts 2 hours ago | ||||||
|  |   const datetimeSelector = '#departure'; | ||||||
|  |   const currentValue = await page.$eval(datetimeSelector, el => el.value); | ||||||
|  |   const currentDate = new Date(currentValue); | ||||||
|  |   currentDate.setMinutes(currentDate.getMinutes()); | ||||||
|  |   currentDate.setHours(currentDate.getHours() - new Date().getTimezoneOffset()/60 - 2); | ||||||
|  |   const newDatetime = currentDate.toISOString().slice(0, 16); | ||||||
|  |   await page.$eval(datetimeSelector, (el, value) => el.value = value, newDatetime); | ||||||
|  |  | ||||||
|  |   await expect(page.locator("#shipmaster-newrowerjs")).toContainText("cox"); | ||||||
|  |   await expect(page.locator("#steering_person-newrowerjs")).toContainText( | ||||||
|  |     "rower2 cox", | ||||||
|  |   ); | ||||||
|  |   await page.getByRole("button", { name: "Ausfahrt eintragen" }).click(); | ||||||
|  |   await expect(page.locator("body")).toContainText( | ||||||
|  |     "Ausfahrt erfolgreich hinzugefügt", | ||||||
|  |   ); | ||||||
|  |   await expect(page.locator("body")).toContainText("Joe"); | ||||||
|  |  | ||||||
|  |   await page.goto("/log"); | ||||||
|  |   await page.locator('div:nth-child(2) > .pt-2 > div > div > div:nth-child(2) > .border-0').click(); // 2 trips currently running, try to close second one | ||||||
|  |    | ||||||
|  |   await page.getByRole("combobox", { name: "Destination" }).click(); | ||||||
|  |   await page.getByRole("combobox", { name: "Destination" }).fill("Ottensheim"); | ||||||
|  |   await page.getByRole("button", { name: "Ausfahrt beenden" }).click(); | ||||||
|  |   await expect(page.locator("body")).toContainText( | ||||||
|  |     "Ausfahrt korrekt eingetragen", | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   await page.getByRole('link', { name: 'Logbuch' }).click(); | ||||||
|  |   await expect(page.locator('body')).toContainText('Joe'); | ||||||
|  |   await expect(page.locator('body')).toContainText('Ottensheim (25 km)'); | ||||||
|  |   await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2'); | ||||||
|  |    | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   //Ausloggen... | ||||||
|  |   await page.context().clearCookies(); | ||||||
|  |   await page.goto("/auth"); | ||||||
|  |   // Login as admin | ||||||
|  |   await page.getByPlaceholder("Name").click(); | ||||||
|  |   await page.getByPlaceholder("Name").fill("main"); | ||||||
|  |   await page.getByPlaceholder("Name").press("Tab"); | ||||||
|  |   await page.getByPlaceholder("Passwort").fill("admin"); | ||||||
|  |   await page.getByPlaceholder("Passwort").press("Enter"); | ||||||
|  |  | ||||||
|  |   await page.goto("/log/show"); | ||||||
|  |   await page.getByRole('link', { name: 'Joe' }).nth(1).click(); | ||||||
|  |   page.once("dialog", (dialog) => { | ||||||
|  |     dialog.accept().catch(() => {}); | ||||||
|  |   }); | ||||||
|  |   await page.getByRole('link', { name: 'Löschen' }).click(); | ||||||
|  |  | ||||||
|  |   //Ausloggen... | ||||||
|  |   await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click(); | ||||||
|  |   await page.getByRole('link', { name: 'Ausloggen' }).click(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | test("Cox can start and finish trip with cox steering only", async ({ page }, testInfo) => { | ||||||
|  |   await page.goto("/auth"); | ||||||
|  |   await page.getByPlaceholder("Name").click(); | ||||||
|  |   await page.getByPlaceholder("Name").fill("cox2"); | ||||||
|  |   await page.getByPlaceholder("Name").press("Tab"); | ||||||
|  |   await page.getByPlaceholder("Passwort").fill("cox"); | ||||||
|  |   await page.getByPlaceholder("Passwort").press("Enter"); | ||||||
|  |  | ||||||
|  |   await page.goto("/"); | ||||||
|  |   await page.getByRole("link", { name: "Ausfahrt eintragen" }).click(); | ||||||
|  |   if (testInfo.project.name.includes("Mobile")) { | ||||||
|  |     // No left boat selector on mobile views | ||||||
|  |     await page.getByText('-- Wähle ein Boot aus ---').nth(1).click(); | ||||||
|  |     await page.getByRole("option", { name: "cox_only_steering_boat" }).click(); | ||||||
|  |   } else { | ||||||
|  |     await page.getByText('2+', { exact: true }).click(); | ||||||
|  |     await page.getByText("cox_only_steering_boat", { exact: true }).click(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Trip starts 2 hours ago | ||||||
|  |   const datetimeSelector = '#departure'; | ||||||
|  |   const currentValue = await page.$eval(datetimeSelector, el => el.value); | ||||||
|  |   const currentDate = new Date(currentValue); | ||||||
|  |   currentDate.setMinutes(currentDate.getMinutes()); | ||||||
|  |   currentDate.setHours(currentDate.getHours() - new Date().getTimezoneOffset()/60 - 2); | ||||||
|  |   const newDatetime = currentDate.toISOString().slice(0, 16); | ||||||
|  |   await page.$eval(datetimeSelector, (el, value) => el.value = value, newDatetime); | ||||||
|  |  | ||||||
|  |   await expect(page.locator("#shipmaster-newrowerjs")).toContainText("cox"); | ||||||
|  |   await expect(page.locator("#steering_person-newrowerjs")).toContainText( | ||||||
|  |     "cox", | ||||||
|  |   ); | ||||||
|  |   await page.getByRole("button", { name: "Ausfahrt eintragen" }).click(); | ||||||
|  |   await expect(page.locator("body")).toContainText( | ||||||
|  |     "Ausfahrt erfolgreich hinzugefügt", | ||||||
|  |   ); | ||||||
|  |   await expect(page.locator("body")).toContainText("cox_only_steering_boat"); | ||||||
|  |  | ||||||
|  |   await page.goto("/log"); | ||||||
|  |   await page.locator("div:nth-child(2) > .border-0").click(); | ||||||
|  |  | ||||||
|  |   await page.getByRole("combobox", { name: "Destination" }).click(); | ||||||
|  |   await page.getByRole("combobox", { name: "Destination" }).fill("Ottensheim"); | ||||||
|  |   await page.getByRole("button", { name: "Ausfahrt beenden" }).click(); | ||||||
|  |   await expect(page.locator("body")).toContainText( | ||||||
|  |     "Ausfahrt korrekt eingetragen", | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   await page.goto('/log/show'); | ||||||
|  |   await expect(page.locator('body')).toContainText('cox_only_steering_boat'); | ||||||
|  |   await expect(page.locator('body')).toContainText('Ottensheim (25 km)'); | ||||||
|  |    | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   //Ausloggen... | ||||||
|  |   await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click(); | ||||||
|  |   await page.getByRole('link', { name: 'Ausloggen' }).click(); | ||||||
|  |   // Login as admin | ||||||
|  |   await page.getByPlaceholder("Name").click(); | ||||||
|  |   await page.getByPlaceholder("Name").fill("main"); | ||||||
|  |   await page.getByPlaceholder("Name").press("Tab"); | ||||||
|  |   await page.getByPlaceholder("Passwort").fill("admin"); | ||||||
|  |   await page.getByPlaceholder("Passwort").press("Enter"); | ||||||
|  |  | ||||||
|  |   await page.goto("/log/show"); | ||||||
|  |   await page.getByRole("link", { name: "cox_only_steering_boat" }).click(); | ||||||
|  |   page.once("dialog", (dialog) => { | ||||||
|  |     dialog.accept().catch(() => {}); | ||||||
|  |   }); | ||||||
|  |   await page.getByRole('link', { name: 'Löschen' }).click(); | ||||||
|  |  | ||||||
|  |   //Ausloggen... | ||||||
|  |   await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click(); | ||||||
|  |   await page.getByRole('link', { name: 'Ausloggen' }).click(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | test("Kiosk can start and finish trip in one stop", async ({ page }, testInfo) => { | ||||||
|  |   await page.goto("/log/kiosk/ekrv2019/Linz"); | ||||||
|  |  | ||||||
|  |   if (testInfo.project.name.includes("Mobile")) { | ||||||
|  |     // No left boat selector on mobile views | ||||||
|  |     await page.getByText('-- Wähle ein Boot aus ---').nth(1).click(); | ||||||
|  |     await page.getByRole("option", { name: "Joe" }).click(); | ||||||
|  |   } else { | ||||||
|  |     await page.getByText('2x', { exact: true }).click(); | ||||||
|  |     await page.getByText("Joe", { exact: true }).click(); | ||||||
|  |   } | ||||||
|  |   await page.getByPlaceholder("Ruderer auswählen").click(); | ||||||
|  |   await page.getByRole("option", { name: "rower2" }).click(); | ||||||
|  |   await page.getByRole("option", { name: "cox2" }).click(); | ||||||
|  |   await expect(page.getByRole("listbox")).toContainText( | ||||||
|  |     "Nur 2 Ruderer können hinzugefügt werden", | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   // Trip starts 2 hours ago | ||||||
|  |   const datetimeSelector = '#departure'; | ||||||
|  |   const currentValue = await page.$eval(datetimeSelector, el => el.value); | ||||||
|  |   const currentDate = new Date(currentValue); | ||||||
|  |   currentDate.setMinutes(currentDate.getMinutes()); | ||||||
|  |   currentDate.setHours(currentDate.getHours() - new Date().getTimezoneOffset()/60 - 2); | ||||||
|  |   const newDatetime = currentDate.toISOString().slice(0, 16); | ||||||
|  |   await page.$eval(datetimeSelector, (el, value) => el.value = value, newDatetime); | ||||||
|  |  | ||||||
|  |   await page.getByLabel('Ankunftszeit').click(); | ||||||
|  |   await page.locator('#destination').fill('a'); | ||||||
|  |   await page.getByLabel('Distanz').fill('1'); | ||||||
|  |  | ||||||
|  |   await expect(page.locator("#shipmaster-newrowerjs")).toContainText("cox"); | ||||||
|  |   await expect(page.locator("#steering_person-newrowerjs")).toContainText( | ||||||
|  |     "rower2 cox", | ||||||
|  |   ); | ||||||
|  |   await page.getByRole("button", { name: "Ausfahrt eintragen" }).click(); | ||||||
|  |   await expect(page.locator("body")).toContainText( | ||||||
|  |     "Ausfahrt erfolgreich hinzugefügt", | ||||||
|  |   ); | ||||||
|  |   await page.getByRole('link', { name: 'Logbuch' }).click(); | ||||||
|  |   await expect(page.locator('body')).toContainText('Joe'); | ||||||
|  |   await expect(page.locator('body')).toContainText('(cox2)'); | ||||||
|  |   await expect(page.locator('body')).toContainText('a (1 km)'); | ||||||
|  |   await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2'); | ||||||
|  |    | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   //Ausloggen... | ||||||
|  |   await page.context().clearCookies(); | ||||||
|  |   await page.goto("/auth"); | ||||||
|  |   // Login as admin | ||||||
|  |   await page.getByPlaceholder("Name").click(); | ||||||
|  |   await page.getByPlaceholder("Name").fill("main"); | ||||||
|  |   await page.getByPlaceholder("Name").press("Tab"); | ||||||
|  |   await page.getByPlaceholder("Passwort").fill("admin"); | ||||||
|  |   await page.getByPlaceholder("Passwort").press("Enter"); | ||||||
|  |  | ||||||
|  |   await page.goto("/log/show"); | ||||||
|  |   await page.getByRole('link', { name: 'Joe' }).nth(1).click(); | ||||||
|  |   page.once("dialog", (dialog) => { | ||||||
|  |     dialog.accept().catch(() => {}); | ||||||
|  |   }); | ||||||
|  |   await page.getByRole('link', { name: 'Löschen' }).click(); | ||||||
|  |  | ||||||
|  |   //Ausloggen... | ||||||
|  |   await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click(); | ||||||
|  |   await page.getByRole('link', { name: 'Ausloggen' }).click(); | ||||||
|  | }); | ||||||
| @@ -15,5 +15,6 @@ | |||||||
|     "noUnusedParameters": true, |     "noUnusedParameters": true, | ||||||
|     "noImplicitReturns": true, |     "noImplicitReturns": true, | ||||||
|     "skipLibCheck": true |     "skipLibCheck": true | ||||||
|   } |   }, | ||||||
|  |   "exclude": ["tests/"] | ||||||
| } | } | ||||||
|   | |||||||
| @@ -24,6 +24,7 @@ export default defineConfig({ | |||||||
|       input: { |       input: { | ||||||
|         main: './main.ts', |         main: './main.ts', | ||||||
|         logbook: './logbook.ts', |         logbook: './logbook.ts', | ||||||
|  |         table: './table.ts', | ||||||
|         // Example for more entry points |         // Example for more entry points | ||||||
|         // test: './src/test.ts', |         // test: './src/test.ts', | ||||||
|       }, |       }, | ||||||
|   | |||||||
							
								
								
									
										116
									
								
								migration.sql
									
									
									
									
									
								
							
							
						
						| @@ -16,7 +16,9 @@ CREATE TABLE IF NOT EXISTS "user" ( | |||||||
| 	"notes" text, | 	"notes" text, | ||||||
| 	"phone" text, | 	"phone" text, | ||||||
| 	"address" text, | 	"address" text, | ||||||
| 	"family_id" INTEGER REFERENCES family(id) | 	"family_id" INTEGER REFERENCES family(id), | ||||||
|  | 	"membership_pdf" BLOB, | ||||||
|  |         "user_token" TEXT NOT NULL DEFAULT (lower(hex(randomblob(16)))) | ||||||
| ); | ); | ||||||
|  |  | ||||||
| CREATE TABLE IF NOT EXISTS "family" ( | CREATE TABLE IF NOT EXISTS "family" ( | ||||||
| @@ -25,7 +27,11 @@ CREATE TABLE IF NOT EXISTS "family" ( | |||||||
|  |  | ||||||
| CREATE TABLE IF NOT EXISTS "role" ( | CREATE TABLE IF NOT EXISTS "role" ( | ||||||
| 	"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, | 	"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||||
| 	"name" text NOT NULL UNIQUE | 	"name" text NOT NULL UNIQUE, | ||||||
|  | 	"formatted_name" text, | ||||||
|  | 	"desc" text, | ||||||
|  | 	"cluster" text, | ||||||
|  | 	"hide_in_lists" BOOLEAN NOT NULL DEFAULT false | ||||||
| ); | ); | ||||||
|  |  | ||||||
| CREATE TABLE IF NOT EXISTS "user_role" ( | CREATE TABLE IF NOT EXISTS "user_role" ( | ||||||
| @@ -98,9 +104,11 @@ CREATE TABLE IF NOT EXISTS "boat" ( | |||||||
| 	"year_built" INTEGER, | 	"year_built" INTEGER, | ||||||
| 	"boatbuilder" TEXT, | 	"boatbuilder" TEXT, | ||||||
| 	"default_shipmaster_only_steering" boolean default false not null, | 	"default_shipmaster_only_steering" boolean default false not null, | ||||||
|  | 	"convert_handoperated_possible" boolean default false not null, | ||||||
| 	"default_destination" text, | 	"default_destination" text, | ||||||
| 	"skull" boolean default true NOT NULL, -- false => riemen | 	"skull" boolean default true NOT NULL, -- false => riemen | ||||||
| 	"external" boolean default false NOT NULL -- false => owned by different club | 	"external" boolean default false NOT NULL, -- false => owned by different club | ||||||
|  | 	"deleted" boolean NOT NULL DEFAULT FALSE | ||||||
| ); | ); | ||||||
|  |  | ||||||
| CREATE TABLE IF NOT EXISTS "logbook_type" ( | CREATE TABLE IF NOT EXISTS "logbook_type" ( | ||||||
| @@ -140,3 +148,105 @@ CREATE TABLE IF NOT EXISTS "boat_damage" ( | |||||||
| 	"verified_at" datetime, | 	"verified_at" datetime, | ||||||
| 	"lock_boat" boolean not null default false -- if true: noone can use the boat  | 	"lock_boat" boolean not null default false -- if true: noone can use the boat  | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | CREATE TABLE IF NOT EXISTS "boathouse" ( | ||||||
|  |     "id" INTEGER PRIMARY KEY AUTOINCREMENT, | ||||||
|  |     "boat_id" INTEGER NOT NULL REFERENCES boat(id), | ||||||
|  |     "aisle" TEXT NOT NULL CHECK (aisle in ('water', 'middle', 'mountain')), | ||||||
|  |     "side" TEXT NOT NULL CHECK(side IN ('mountain', 'water')), | ||||||
|  |     "level" INTEGER NOT NULL CHECK(level BETWEEN 0 AND 11), | ||||||
|  |     CONSTRAINT unq UNIQUE (aisle, side, level) -- only 1 boat allowed to rest at each space  | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | CREATE TABLE IF NOT EXISTS "notification" ( | ||||||
|  | 	"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||||
|  | 	"user_id" INTEGER NOT NULL REFERENCES user(id), | ||||||
|  | 	"message" TEXT NOT NULL, | ||||||
|  | 	"read_at" DATETIME, | ||||||
|  | 	"created_at" DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, | ||||||
|  | 	"category" TEXT NOT NULL, | ||||||
|  | 	"action_after_reading" TEXT, | ||||||
|  | 	"link" TEXT | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | CREATE TABLE IF NOT EXISTS "boat_reservation" ( | ||||||
|  | 	"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||||
|  | 	"boat_id" INTEGER NOT NULL REFERENCES boat(id),  | ||||||
|  | 	"start_date" DATE NOT NULL, | ||||||
|  | 	"end_date" DATE NOT NULL, | ||||||
|  | 	"time_desc" TEXT NOT NULL, | ||||||
|  | 	"usage" TEXT NOT NULL, | ||||||
|  | 	"user_id_applicant" INTEGER NOT NULL REFERENCES user(id),  | ||||||
|  | 	"user_id_confirmation" INTEGER REFERENCES user(id),  | ||||||
|  | 	"created_at" datetime not null default CURRENT_TIMESTAMP | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | CREATE TABLE IF NOT EXISTS "waterlevel" ( | ||||||
|  | 	"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||||
|  | 	"day" DATE NOT NULL, | ||||||
|  | 	"time" TEXT NOT NULL, | ||||||
|  | 	"max" INTEGER NOT NULL, | ||||||
|  | 	"min" INTEGER NOT NULL, | ||||||
|  | 	"mittel" INTEGER NOT NULL, | ||||||
|  | 	"tumax" INTEGER NOT NULL, | ||||||
|  | 	"tumin" INTEGER NOT NULL, | ||||||
|  | 	"tumittel" INTEGER NOT NULL | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | CREATE TABLE IF NOT EXISTS "weather" ( | ||||||
|  | 	"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||||
|  | 	"day" DATE NOT NULL, | ||||||
|  | 	"max_temp" FLOAT NOT NULL, | ||||||
|  | 	"wind_gust" FLOAT NOT NULL, | ||||||
|  | 	"rain_mm" FLOAT NOT NULL | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | CREATE TABLE IF NOT EXISTS "trailer" ( | ||||||
|  | 	"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||||
|  | 	"name" text NOT NULL UNIQUE | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | CREATE TABLE IF NOT EXISTS "trailer_reservation" ( | ||||||
|  | 	"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||||
|  | 	"trailer_id" INTEGER NOT NULL REFERENCES trailer(id),  | ||||||
|  | 	"start_date" DATE NOT NULL, | ||||||
|  | 	"end_date" DATE NOT NULL, | ||||||
|  | 	"time_desc" TEXT NOT NULL, | ||||||
|  | 	"usage" TEXT NOT NULL, | ||||||
|  | 	"user_id_applicant" INTEGER NOT NULL REFERENCES user(id),  | ||||||
|  | 	"user_id_confirmation" INTEGER REFERENCES user(id),  | ||||||
|  | 	"created_at" datetime not null default CURRENT_TIMESTAMP | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | CREATE TABLE IF NOT EXISTS "distance" ( | ||||||
|  | 	"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||||
|  | 	"destination" text NOT NULL, | ||||||
|  | 	"distance_in_km" integer NOT NULL | ||||||
|  | ); | ||||||
|  |  | ||||||
|  |  | ||||||
|  | CREATE TABLE IF NOT EXISTS "activity" ( | ||||||
|  |     id INTEGER PRIMARY KEY AUTOINCREMENT, | ||||||
|  |     created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||||
|  |     text TEXT NOT NULL, | ||||||
|  |     relevant_for TEXT NOT NULL,  -- e.g. user_id=123;trip_id=456 | ||||||
|  |     keep_until DATETIME | ||||||
|  | ); | ||||||
|  |  | ||||||
|  |  | ||||||
|  | CREATE TRIGGER IF NOT EXISTS prevent_multiple_roles_same_cluster | ||||||
|  | BEFORE INSERT ON user_role | ||||||
|  | BEGIN | ||||||
|  |     SELECT CASE  | ||||||
|  |         WHEN EXISTS ( | ||||||
|  |             SELECT 1 | ||||||
|  |             FROM user_role ur | ||||||
|  |             JOIN role r1 ON ur.role_id = r1.id | ||||||
|  |             JOIN role r2 ON r1."cluster" = r2."cluster" | ||||||
|  |             WHERE ur.user_id = NEW.user_id | ||||||
|  |             AND r2.id = NEW.role_id | ||||||
|  |             AND r1.id != NEW.role_id | ||||||
|  |         ) | ||||||
|  |         THEN RAISE(ABORT, 'User already has a role in this cluster') | ||||||
|  |     END; | ||||||
|  | END; | ||||||
|   | |||||||
							
								
								
									
										73
									
								
								notes.md
									
									
									
									
									
								
							
							
						
						| @@ -1,73 +0,0 @@ | |||||||
| # Wordpress auth |  | ||||||
|  |  | ||||||
| Add the following code to `wp-content/themes/bravada/functions.php`: |  | ||||||
|  |  | ||||||
| ``` |  | ||||||
| function rot_auth( $user, $username, $password ){ |  | ||||||
|     // Make sure a username and password are present for us to work with |  | ||||||
|     if($username == '' || $password == '') return; |  | ||||||
|  |  | ||||||
| 	$ch = curl_init(); |  | ||||||
| 	 |  | ||||||
| 	curl_setopt($ch, CURLOPT_URL, 'https://app.rudernlinz.at/wikiauth'); |  | ||||||
| 	curl_setopt($ch, CURLOPT_POST, 1); |  | ||||||
| 	curl_setopt($ch, CURLOPT_POSTFIELDS, "name=$username&password=$password"); |  | ||||||
| 	curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);	 |  | ||||||
| 	 |  | ||||||
| 	// Execute the cURL session and get the response |  | ||||||
| 	$response = curl_exec($ch); |  | ||||||
| 	 |  | ||||||
| 	// Check for cURL errors |  | ||||||
| 	if(curl_errno($ch)){ |  | ||||||
|         	$user = new WP_Error( 'denied', __('Curl error: ' . curl_error($ch)) ); |  | ||||||
| 	} |  | ||||||
| 	 |  | ||||||
| 	// Close the cURL session |  | ||||||
| 	curl_close($ch); |  | ||||||
|  |  | ||||||
|  |  | ||||||
| 	if (strpos($response, 'SUCC') !== false) { |  | ||||||
|         	$user = get_user_by('login', $username); |  | ||||||
|         	 |  | ||||||
|         	if (!$user) { |  | ||||||
|         	   // User does not exist, create a new one |  | ||||||
|         	   $userdata = array( |  | ||||||
|         	       'user_email' => $username, |  | ||||||
|         	       'user_login' => $username,  |  | ||||||
|         	       'first_name' => $username, |  | ||||||
|         	       'last_name' => '' |  | ||||||
|         	   ); |  | ||||||
|         	   $new_user_id = wp_insert_user($userdata); |  | ||||||
|  |  | ||||||
|         	   if (!is_wp_error($new_user_id)) { |  | ||||||
|         	       // Load the new user info |  | ||||||
|         	       $user = new WP_User($new_user_id); |  | ||||||
|         	        |  | ||||||
|         	       // Set role based on username |  | ||||||
|         	       if ($username == 'Philipp Hofer' || $username == 'Marie Birner') { |  | ||||||
|         	           $user->set_role('administrator'); |  | ||||||
|         	       } else { |  | ||||||
|         	           $user->set_role('editor'); |  | ||||||
|         	       } |  | ||||||
|         	   } else { |  | ||||||
|         	       // Handle error in user creation |  | ||||||
|         	       return $new_user_id; |  | ||||||
|         	   } |  | ||||||
|         	} else { |  | ||||||
|         	} |  | ||||||
| 	 |  | ||||||
| 	} else { |  | ||||||
|         	$user = new WP_Error( 'denied', __("Falscher Benutzername/Passwort. Verwendest du deine Accountdaten vom Ruderassistenten?") ); |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|      return $user; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // Comment this line if you wish to fall back on WordPress authentication |  | ||||||
| // Useful for times when the external service is offline |  | ||||||
| remove_action('authenticate', 'wp_authenticate_username_password', 20); |  | ||||||
|  |  | ||||||
| add_filter( 'authenticate', 'rot_auth', 10, 3 ); |  | ||||||
| ``` |  | ||||||
| @@ -4,12 +4,16 @@ Description=Rot | |||||||
| [Service] | [Service] | ||||||
| User=root | User=root | ||||||
| Group=root | Group=root | ||||||
| WorkingDirectory=/home/k004373/rowing | WorkingDirectory=/home/rowing | ||||||
| Environment="ROCKET_ENV=prod" | Environment="ROCKET_ENV=prod" | ||||||
| Environment="ROCKET_ADDRESS=127.0.0.1" | Environment="ROCKET_ADDRESS=127.0.0.1" | ||||||
| Environment="ROCKET_PORT=8001" | Environment="ROCKET_PORT=8001" | ||||||
| Environment="RUST_LOG=info" | Environment="RUST_LOG=info" | ||||||
| ExecStart=/home/k004373/rowing/rot | Environment="DATABASE_URL=sqliteL///home/stationslauf/db.sqlite" | ||||||
|  | ExecStart=/home/rowing/rot | ||||||
|  | Restart=always | ||||||
|  | RestartSec=10 | ||||||
|  |  | ||||||
|  |  | ||||||
| [Install] | [Install] | ||||||
| WantedBy=multi-user.target | WantedBy=multi-user.target | ||||||
|   | |||||||
| @@ -4,12 +4,14 @@ Description=Rot Staging | |||||||
| [Service] | [Service] | ||||||
| User=root | User=root | ||||||
| Group=root | Group=root | ||||||
| WorkingDirectory=/home/k004373/rowing-staging | WorkingDirectory=/home/rowing-staging | ||||||
| Environment="ROCKET_ENV=prod" | Environment="ROCKET_ENV=prod" | ||||||
| Environment="ROCKET_ADDRESS=127.0.0.1" | Environment="ROCKET_ADDRESS=127.0.0.1" | ||||||
| Environment="ROCKET_PORT=7999" | Environment="ROCKET_PORT=7999" | ||||||
| Environment="ROCKET_LOG=info" | Environment="ROCKET_LOG=info" | ||||||
| ExecStart=/home/k004373/rowing-staging/rot | ExecStart=/home/rowing-staging/rot | ||||||
|  | Restart=always | ||||||
|  | RestartSec=10 | ||||||
|  |  | ||||||
| [Install] | [Install] | ||||||
| WantedBy=multi-user.target | WantedBy=multi-user.target | ||||||
|   | |||||||
							
								
								
									
										35
									
								
								seeds.sql
									
									
									
									
									
								
							
							
						
						| @@ -3,7 +3,17 @@ INSERT INTO "role" (name) VALUES ('cox'); | |||||||
| INSERT INTO "role" (name) VALUES ('scheckbuch'); | INSERT INTO "role" (name) VALUES ('scheckbuch'); | ||||||
| INSERT INTO "role" (name) VALUES ('tech'); | INSERT INTO "role" (name) VALUES ('tech'); | ||||||
| INSERT INTO "role" (name) VALUES ('Donau Linz'); | INSERT INTO "role" (name) VALUES ('Donau Linz'); | ||||||
| INSERT INTO "role" (name) VALUES ('planned_event'); | INSERT INTO "role" (name) VALUES ('manage_events'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('Rennrudern'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('paid'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('Vorstand'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('Bootsführer'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('schnupperant'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('kassier'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('schriftfuehrer'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('no-einschreibgebuehr'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('schnupper-betreuer'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('allow_website_login'); | ||||||
| INSERT INTO "user" (name, pw) VALUES('admin', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM'); | INSERT INTO "user" (name, pw) VALUES('admin', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM'); | ||||||
| INSERT INTO "user_role" (user_id, role_id) VALUES(1,1); | INSERT INTO "user_role" (user_id, role_id) VALUES(1,1); | ||||||
| INSERT INTO "user_role" (user_id, role_id) VALUES(1,2); | INSERT INTO "user_role" (user_id, role_id) VALUES(1,2); | ||||||
| @@ -17,6 +27,7 @@ INSERT INTO "user_role" (user_id, role_id) VALUES(3,3); | |||||||
| INSERT INTO "user" (name, pw) VALUES('cox', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$lnWzHx3DdqS9GQyWYel82kIotZuK2wk9EyfhPFtjNzs'); | INSERT INTO "user" (name, pw) VALUES('cox', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$lnWzHx3DdqS9GQyWYel82kIotZuK2wk9EyfhPFtjNzs'); | ||||||
| INSERT INTO "user_role" (user_id, role_id) VALUES(4,5); | INSERT INTO "user_role" (user_id, role_id) VALUES(4,5); | ||||||
| INSERT INTO "user_role" (user_id, role_id) VALUES(4,2); | INSERT INTO "user_role" (user_id, role_id) VALUES(4,2); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(4,8); | ||||||
| INSERT INTO "user" (name) VALUES('new'); | INSERT INTO "user" (name) VALUES('new'); | ||||||
| INSERT INTO "user_role" (user_id, role_id) VALUES(5,5); | INSERT INTO "user_role" (user_id, role_id) VALUES(5,5); | ||||||
| INSERT INTO "user" (name, pw) VALUES('cox2', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$lnWzHx3DdqS9GQyWYel82kIotZuK2wk9EyfhPFtjNzs'); | INSERT INTO "user" (name, pw) VALUES('cox2', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$lnWzHx3DdqS9GQyWYel82kIotZuK2wk9EyfhPFtjNzs'); | ||||||
| @@ -24,13 +35,25 @@ INSERT INTO "user_role" (user_id, role_id) VALUES(6,5); | |||||||
| INSERT INTO "user_role" (user_id, role_id) VALUES(6,2); | INSERT INTO "user_role" (user_id, role_id) VALUES(6,2); | ||||||
| INSERT INTO "user" (name,  pw) VALUES('rower2', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$jWKzDmI0jqT2dqINFt6/1NjVF4Dx15n07PL1ZMBmFsY'); | INSERT INTO "user" (name,  pw) VALUES('rower2', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$jWKzDmI0jqT2dqINFt6/1NjVF4Dx15n07PL1ZMBmFsY'); | ||||||
| INSERT INTO "user_role" (user_id, role_id) VALUES(7,5); | INSERT INTO "user_role" (user_id, role_id) VALUES(7,5); | ||||||
|  | INSERT INTO "user" (name,  pw) VALUES('teen', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$jWKzDmI0jqT2dqINFt6/1NjVF4Dx15n07PL1ZMBmFsY'); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(8,5); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(8,7); | ||||||
|  | INSERT INTO "user" (name,  pw) VALUES('Vorstandsmitglied', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$jWKzDmI0jqT2dqINFt6/1NjVF4Dx15n07PL1ZMBmFsY'); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(9,5); | ||||||
|  | INSERT INTO "user" (name, pw) VALUES('main', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM'); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(10,1); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(10,2); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(10,5); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(10,6); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(10,9); | ||||||
|  |  | ||||||
| INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('10:00', 2, '1970-01-01', 'trip_details for a planned event'); | INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('10:00', 2, date('now'), 'trip_details for a planned event'); | ||||||
| INSERT INTO "planned_event" (name, planned_amount_cox, trip_details_id) VALUES('test-planned-event', 2, 1); | INSERT INTO "planned_event" (name, planned_amount_cox, trip_details_id) VALUES('test-planned-event', 2, 1); | ||||||
|  |  | ||||||
| INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('11:00', 1, '1970-01-02', 'trip_details for trip from cox'); | INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('11:00', 1, date('now', '+1 day'), 'trip_details for trip from cox'); | ||||||
| INSERT INTO "trip" (cox_id, trip_details_id) VALUES(4, 2); | INSERT INTO "trip" (cox_id, trip_details_id) VALUES(4, 2); | ||||||
|  |  | ||||||
|  | INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('10:00', 2, date('now'), 'same trip_details as id=1'); | ||||||
| INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Regatta', 'Regatta!', 'Kein normales Event. Das ist eine Regatta! Willst du wirklich teilnehmen?', '🏅'); | INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Regatta', 'Regatta!', 'Kein normales Event. Das ist eine Regatta! Willst du wirklich teilnehmen?', '🏅'); | ||||||
| INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Lange Ausfahrt', 'Lange Ausfahrt!', 'Das ist eine lange Ausfahrt! Willst du wirklich teilnehmen?', '💪'); | INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Lange Ausfahrt', 'Lange Ausfahrt!', 'Das ist eine lange Ausfahrt! Willst du wirklich teilnehmen?', '💪'); | ||||||
| INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Wanderfahrt', 'Wanderfahrt!', 'Kein normales Event. Das ist eine Wanderfahrt! Bitte überprüfe ob du alle Anforderungen erfüllst. Willst du wirklich teilnehmen?', '⛱'); | INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Wanderfahrt', 'Wanderfahrt!', 'Kein normales Event. Das ist eine Wanderfahrt! Bitte überprüfe ob du alle Anforderungen erfüllst. Willst du wirklich teilnehmen?', '⛱'); | ||||||
| @@ -43,6 +66,7 @@ INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Kaputtes Boot :-(' | |||||||
| INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Sehr kaputtes Boot :-((', 7, 1); | INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Sehr kaputtes Boot :-((', 7, 1); | ||||||
| INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Ottensheim Boot', 7, 2); | INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Ottensheim Boot', 7, 2); | ||||||
| INSERT INTO "boat" (name, amount_seats, location_id, owner) VALUES ('second_private_boat_from_rower', 1, 1, 2); | INSERT INTO "boat" (name, amount_seats, location_id, owner) VALUES ('second_private_boat_from_rower', 1, 1, 2); | ||||||
|  | INSERT INTO "boat" (name, amount_seats, location_id, default_shipmaster_only_steering) VALUES ('cox_only_steering_boat', 3, 1, true); | ||||||
| INSERT INTO "logbook_type" (name) VALUES ('Wanderfahrt'); | INSERT INTO "logbook_type" (name) VALUES ('Wanderfahrt'); | ||||||
| INSERT INTO "logbook_type" (name) VALUES ('Regatta'); | INSERT INTO "logbook_type" (name) VALUES ('Regatta'); | ||||||
| INSERT INTO "logbook" (boat_id, shipmaster,steering_person, shipmaster_only_steering, departure) VALUES (2, 2, 2, false, strftime('%Y', 'now') || '-12-24 10:00'); | INSERT INTO "logbook" (boat_id, shipmaster,steering_person, shipmaster_only_steering, departure) VALUES (2, 2, 2, false, strftime('%Y', 'now') || '-12-24 10:00'); | ||||||
| @@ -51,3 +75,8 @@ INSERT INTO "logbook" (boat_id, shipmaster, steering_person, shipmaster_only_ste | |||||||
| INSERT INTO "rower" (logbook_id, rower_id) VALUES(3,3); | INSERT INTO "rower" (logbook_id, rower_id) VALUES(3,3); | ||||||
| INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at) VALUES(4,'Dolle bei Position 2 fehlt', 5, '2142-12-24 15:02'); | INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at) VALUES(4,'Dolle bei Position 2 fehlt', 5, '2142-12-24 15:02'); | ||||||
| INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at, lock_boat) VALUES(5, 'TOHT', 5, '2142-12-24 15:02', 1); | INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at, lock_boat) VALUES(5, 'TOHT', 5, '2142-12-24 15:02', 1); | ||||||
|  | INSERT INTO "notification" (user_id, message, category) VALUES (1, 'This is a test notification', 'test-cat'); | ||||||
|  | INSERT INTO "trailer" (name) VALUES('Großer Hänger'); | ||||||
|  | INSERT INTO "trailer" (name) VALUES('Kleiner Hänger'); | ||||||
|  | insert into distance(destination, distance_in_km) values('Ottensheim', 25); | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										104
									
								
								seeds_demo.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,104 @@ | |||||||
|  | INSERT INTO "role" (name) VALUES ('admin'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('cox'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('scheckbuch'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('tech'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('Donau Linz'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('manage_events'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('Rennrudern'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('paid'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('Vorstand'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('Bootsführer'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('schnupperant'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('kassier'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('schriftfuehrer'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('no-einschreibgebuehr'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('schnupper-betreuer'); | ||||||
|  | INSERT INTO "role" (name) VALUES ('allow_website_login'); | ||||||
|  | INSERT INTO "user" (name, pw) VALUES('admin', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM'); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(1,1); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(1,2); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(1,5); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(1,6); | ||||||
|  | INSERT INTO "user" (name, pw) VALUES('rower', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$jWKzDmI0jqT2dqINFt6/1NjVF4Dx15n07PL1ZMBmFsY'); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(2,5); | ||||||
|  | INSERT INTO "user" (name, pw) VALUES('guest', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$GF6gizbI79Bh0zA9its8S0gram956v+YIV8w8VpwJnQ'); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(3,5); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(3,3); | ||||||
|  | INSERT INTO "user" (name, pw) VALUES('cox', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$lnWzHx3DdqS9GQyWYel82kIotZuK2wk9EyfhPFtjNzs'); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(4,5); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(4,2); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(4,8); | ||||||
|  | INSERT INTO "user" (name) VALUES('new'); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(5,5); | ||||||
|  | INSERT INTO "user" (name, pw) VALUES('cox2', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$lnWzHx3DdqS9GQyWYel82kIotZuK2wk9EyfhPFtjNzs'); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(6,5); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(6,2); | ||||||
|  | INSERT INTO "user" (name,  pw) VALUES('rower2', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$jWKzDmI0jqT2dqINFt6/1NjVF4Dx15n07PL1ZMBmFsY'); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(7,5); | ||||||
|  | INSERT INTO "user" (name,  pw) VALUES('teen', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$jWKzDmI0jqT2dqINFt6/1NjVF4Dx15n07PL1ZMBmFsY'); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(8,5); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(8,7); | ||||||
|  | INSERT INTO "user" (name,  pw) VALUES('Vorstandsmitglied', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$jWKzDmI0jqT2dqINFt6/1NjVF4Dx15n07PL1ZMBmFsY'); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(9,5); | ||||||
|  | INSERT INTO "user" (name, pw) VALUES('main', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM'); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(10,1); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(10,2); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(10,5); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(10,6); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(10,9); | ||||||
|  | INSERT INTO "user" (name, pw) VALUES('Lukas Rudinger', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM'); --11 | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(11,5); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(11,2); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(11,8); | ||||||
|  | INSERT INTO "user" (name, pw) VALUES('Claudia Fröhlich', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM'); --12 | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(12,6); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(12,5); | ||||||
|  | INSERT INTO "user" (name, pw) VALUES('Adeline Krebs', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM'); --13 | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(13,5); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(13,2); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(13,8); | ||||||
|  | INSERT INTO "user" (name, pw) VALUES('Michael Schweiß', '$argon2id$v=19$m=19456,t=2,p=1$dS/X5/sPEKTj4Rzs/CuvzQ$4P4NCw4Ukhv80/eQYTsarHhnw61JuL1KMx/L9dm82YM'); --13 | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(14,5); | ||||||
|  | INSERT INTO "user_role" (user_id, role_id) VALUES(14,8); | ||||||
|  |  | ||||||
|  | INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('06:00', 4, date('now'), ''); | ||||||
|  | INSERT INTO "trip" (cox_id, trip_details_id) VALUES(13, 1); | ||||||
|  |  | ||||||
|  | INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('14:00', 8, date('now'), 'Lasst uns den Markt entern!!'); | ||||||
|  | INSERT INTO "planned_event" (name, planned_amount_cox, trip_details_id) VALUES('Marktfahrt', 2, 2); | ||||||
|  |  | ||||||
|  | INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('17:00', 4, date('now'), 'Feierabend-Ausfahrt'); | ||||||
|  | INSERT INTO "trip" (cox_id, trip_details_id) VALUES(11, 3); | ||||||
|  |  | ||||||
|  | INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('18:00', 8, date('now'), ''); | ||||||
|  | INSERT INTO "planned_event" (name, planned_amount_cox, trip_details_id) VALUES('Anfängertraining Ergo', 1, 4); | ||||||
|  |  | ||||||
|  | INSERT INTO "trip_details" (planned_starting_time, max_people, day, notes) VALUES('14:00', 4, date('now', '+1 day'), 'Der frühe Wurm wird vom Vogel gefressen!'); | ||||||
|  | INSERT INTO "trip" (cox_id, trip_details_id) VALUES(13, 5); | ||||||
|  |  | ||||||
|  | INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Regatta', 'Regatta!', 'Kein normales Event. Das ist eine Regatta! Willst du wirklich teilnehmen?', '🏅'); | ||||||
|  | INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Lange Ausfahrt', 'Lange Ausfahrt!', 'Das ist eine lange Ausfahrt! Willst du wirklich teilnehmen?', '💪'); | ||||||
|  | INSERT INTO "trip_type" (name, desc, question, icon) VALUES ('Wanderfahrt', 'Wanderfahrt!', 'Kein normales Event. Das ist eine Wanderfahrt! Bitte überprüfe ob du alle Anforderungen erfüllst. Willst du wirklich teilnehmen?', '⛱'); | ||||||
|  | INSERT INTO "location" (name) VALUES ('Linz'); | ||||||
|  | INSERT INTO "location" (name) VALUES ('Ottensheim'); | ||||||
|  | INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Haichenbach', 1, 1); | ||||||
|  | INSERT INTO "boat" (name, amount_seats, location_id, owner) VALUES ('private_boat_from_rower', 1, 1, 2); | ||||||
|  | INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Joe', 2, 1); | ||||||
|  | INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Kaputtes Boot :-(', 7, 1); | ||||||
|  | INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Sehr kaputtes Boot :-((', 7, 1); | ||||||
|  | INSERT INTO "boat" (name, amount_seats, location_id) VALUES ('Ottensheim Boot', 7, 2); | ||||||
|  | INSERT INTO "boat" (name, amount_seats, location_id, owner) VALUES ('second_private_boat_from_rower', 1, 1, 2); | ||||||
|  | INSERT INTO "boat" (name, amount_seats, location_id, default_shipmaster_only_steering) VALUES ('cox_only_steering_boat', 3, 1, true); | ||||||
|  | INSERT INTO "logbook_type" (name) VALUES ('Wanderfahrt'); | ||||||
|  | INSERT INTO "logbook_type" (name) VALUES ('Regatta'); | ||||||
|  | INSERT INTO "logbook" (boat_id, shipmaster,steering_person, shipmaster_only_steering, departure) VALUES (2, 2, 2, false, strftime('%Y', 'now') || '-12-24 10:00'); | ||||||
|  | INSERT INTO "logbook" (boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km) VALUES (1, 4, 4, false, strftime('%Y', 'now') || '-12-24 10:00', strftime('%Y', 'now') || '-12-24 15:00', 'Ottensheim', 25); | ||||||
|  | INSERT INTO "logbook" (boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km) VALUES (3, 4, 4, false, strftime('%Y', 'now') || '-12-24 10:00', strftime('%Y', 'now') || '-12-24 11:30', 'Ottensheim + Regattastrecke', 29); | ||||||
|  | INSERT INTO "rower" (logbook_id, rower_id) VALUES(3,3); | ||||||
|  | INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at) VALUES(4,'Dolle bei Position 2 fehlt', 5, '2142-12-24 15:02'); | ||||||
|  | INSERT INTO "boat_damage" (boat_id, desc, user_id_created, created_at, lock_boat) VALUES(5, 'TOHT', 5, '2142-12-24 15:02', 1); | ||||||
|  | INSERT INTO "notification" (user_id, message, category) VALUES (1, 'This is a test notification', 'test-cat'); | ||||||
|  | INSERT INTO "trailer" (name) VALUES('Großer Hänger'); | ||||||
|  | INSERT INTO "trailer" (name) VALUES('Kleiner Hänger'); | ||||||
|  | insert into distance(destination, distance_in_km) values('Ottensheim', 25); | ||||||
|  |  | ||||||
							
								
								
									
										89
									
								
								src/lib.rs
									
									
									
									
									
								
							
							
						
						| @@ -1,3 +1,7 @@ | |||||||
|  | #![allow(clippy::blocks_in_conditions)] | ||||||
|  |  | ||||||
|  | use std::ops::Deref; | ||||||
|  |  | ||||||
| pub mod model; | pub mod model; | ||||||
|  |  | ||||||
| #[cfg(feature = "rowing-tera")] | #[cfg(feature = "rowing-tera")] | ||||||
| @@ -6,6 +10,91 @@ pub mod tera; | |||||||
| #[cfg(feature = "rest")] | #[cfg(feature = "rest")] | ||||||
| pub mod rest; | pub mod rest; | ||||||
|  |  | ||||||
|  | pub mod scheduled; | ||||||
|  |  | ||||||
|  | pub(crate) const AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD: i64 = 10; | ||||||
|  | pub(crate) const RENNRUDERBEITRAG: i64 = 11000; | ||||||
|  | pub(crate) const BOAT_STORAGE: i64 = 4500; | ||||||
|  | pub(crate) const FAMILY_TWO: i64 = 30000; | ||||||
|  | pub(crate) const FAMILY_THREE_OR_MORE: i64 = 35000; | ||||||
|  | pub(crate) const STUDENT_OR_PUPIL: i64 = 8000; | ||||||
|  | pub(crate) const REGULAR: i64 = 22000; | ||||||
|  | pub(crate) const UNTERSTUETZEND: i64 = 2500; | ||||||
|  | pub(crate) const FOERDERND: i64 = 8500; | ||||||
|  | pub(crate) const SCHECKBUCH: i64 = 3000; | ||||||
|  | pub(crate) const EINSCHREIBGEBUEHR: i64 = 3000; | ||||||
|  | pub(crate) const DUAL_MEMBERSHIP: i64 = 18000; | ||||||
|  | pub(crate) const TRIAL_ROWING: i64 = 12000; | ||||||
|  | pub(crate) const TRIAL_ROWING_REDUCED: i64 = 6000; | ||||||
|  |  | ||||||
|  | #[derive(Debug, Clone, PartialEq, Eq)] | ||||||
|  | pub struct NonEmptyString(String); | ||||||
|  |  | ||||||
|  | impl NonEmptyString { | ||||||
|  |     pub fn new(s: String) -> Option<Self> { | ||||||
|  |         if s.is_empty() { | ||||||
|  |             None | ||||||
|  |         } else { | ||||||
|  |             Some(NonEmptyString(s)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub fn as_str(&self) -> &str { | ||||||
|  |         &self.0 | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub fn into_string(self) -> String { | ||||||
|  |         self.0 | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Implement Deref to allow automatic dereferencing to &str | ||||||
|  | impl Deref for NonEmptyString { | ||||||
|  |     type Target = str; | ||||||
|  |  | ||||||
|  |     fn deref(&self) -> &Self::Target { | ||||||
|  |         &self.0 | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // This allows &NonEmptyString to be converted to &str | ||||||
|  | impl AsRef<str> for NonEmptyString { | ||||||
|  |     fn as_ref(&self) -> &str { | ||||||
|  |         &self.0 | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // This allows NonEmptyString to be converted to String with .into() | ||||||
|  | impl From<NonEmptyString> for String { | ||||||
|  |     fn from(s: NonEmptyString) -> Self { | ||||||
|  |         s.0 | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl TryFrom<&str> for NonEmptyString { | ||||||
|  |     type Error = &'static str; | ||||||
|  |  | ||||||
|  |     fn try_from(s: &str) -> Result<Self, Self::Error> { | ||||||
|  |         if s.is_empty() { | ||||||
|  |             Err("String cannot be empty") | ||||||
|  |         } else { | ||||||
|  |             Ok(NonEmptyString(s.to_string())) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl TryFrom<String> for NonEmptyString { | ||||||
|  |     type Error = &'static str; | ||||||
|  |  | ||||||
|  |     fn try_from(s: String) -> Result<Self, Self::Error> { | ||||||
|  |         if s.is_empty() { | ||||||
|  |             Err("String cannot be empty") | ||||||
|  |         } else { | ||||||
|  |             Ok(NonEmptyString(s)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| #[cfg(test)] | #[cfg(test)] | ||||||
| #[macro_export] | #[macro_export] | ||||||
| macro_rules! testdb { | macro_rules! testdb { | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						| @@ -1,11 +1,14 @@ | |||||||
|  | #![allow(clippy::blocks_in_conditions)] | ||||||
|  |  | ||||||
| use std::str::FromStr; | use std::str::FromStr; | ||||||
|  |  | ||||||
| #[cfg(feature = "rest")] | #[cfg(feature = "rest")] | ||||||
| use rot::rest; | use rot::rest; | ||||||
| #[cfg(feature = "rowing-tera")] | #[cfg(feature = "rowing-tera")] | ||||||
| use rot::tera; | use rot::tera; | ||||||
|  | use rot::{scheduled, tera::Config}; | ||||||
|  |  | ||||||
| use sqlx::{pool::PoolOptions, sqlite::SqliteConnectOptions, ConnectOptions}; | use sqlx::{ConnectOptions, pool::PoolOptions, sqlite::SqliteConnectOptions}; | ||||||
|  |  | ||||||
| #[macro_use] | #[macro_use] | ||||||
| extern crate rocket; | extern crate rocket; | ||||||
| @@ -24,7 +27,7 @@ async fn rocket() -> _ { | |||||||
|         .await |         .await | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
|  |  | ||||||
|     let rocket = rocket::build().manage(db); |     let rocket = rocket::build().manage(db.clone()); | ||||||
|  |  | ||||||
|     #[cfg(feature = "rowing-tera")] |     #[cfg(feature = "rowing-tera")] | ||||||
|     let rocket = tera::config(rocket); |     let rocket = tera::config(rocket); | ||||||
| @@ -32,5 +35,11 @@ async fn rocket() -> _ { | |||||||
|     #[cfg(feature = "rest")] |     #[cfg(feature = "rest")] | ||||||
|     let rocket = rest::config(rocket); |     let rocket = rest::config(rocket); | ||||||
|  |  | ||||||
|  |     let config: Config = rocket | ||||||
|  |         .figment() | ||||||
|  |         .extract() | ||||||
|  |         .expect("Config extraction failed"); | ||||||
|  |     scheduled::schedule(&db, &config); | ||||||
|  |  | ||||||
|     rocket |     rocket | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										270
									
								
								src/model/activity.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,270 @@ | |||||||
|  | use std::ops::DerefMut; | ||||||
|  |  | ||||||
|  | use super::{ | ||||||
|  |     logbook::{Logbook, LogbookWithBoatAndRowers}, | ||||||
|  |     role::Role, | ||||||
|  |     user::{ManageUserUser, User}, | ||||||
|  | }; | ||||||
|  | use chrono::{DateTime, Duration, Local, NaiveDateTime, TimeZone, Utc}; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  | use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; | ||||||
|  |  | ||||||
|  | #[derive(FromRow, Debug, Serialize, Deserialize, Clone)] | ||||||
|  | pub struct Activity { | ||||||
|  |     pub id: i64, | ||||||
|  |     pub created_at: NaiveDateTime, | ||||||
|  |     pub text: String, | ||||||
|  |     pub relevant_for: String, | ||||||
|  |     pub keep_until: Option<NaiveDateTime>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Serialize, Deserialize, Debug)] | ||||||
|  | pub struct ActivityWithDetails { | ||||||
|  |     #[serde(flatten)] | ||||||
|  |     pub(crate) activity: Activity, | ||||||
|  |     keep_until_days: Option<i64>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl From<Activity> for ActivityWithDetails { | ||||||
|  |     fn from(activity: Activity) -> Self { | ||||||
|  |         let keep_until_days = activity.keep_until.map(|keep_until| { | ||||||
|  |             let now = Utc::now().naive_utc(); | ||||||
|  |             let duration = keep_until.signed_duration_since(now); | ||||||
|  |             duration.num_days() | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         Self { | ||||||
|  |             keep_until_days, | ||||||
|  |             activity, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // TODO: add `reason` as additional db field, to be able to query and show this to the users | ||||||
|  | pub enum Reason<'a> { | ||||||
|  |     Auth(ReasonAuth<'a>), | ||||||
|  |     Logbook(ReasonLogbook<'a>), | ||||||
|  |     // `User` changed the data of `User`, explanation in `String` | ||||||
|  |     UserDataChange(&'a ManageUserUser, &'a User, String), | ||||||
|  |     // New Note for User | ||||||
|  |     NewUserNote(&'a ManageUserUser, &'a User, String), | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl From<Reason<'_>> for ActivityBuilder { | ||||||
|  |     fn from(value: Reason<'_>) -> Self { | ||||||
|  |         match value { | ||||||
|  |             Reason::Auth(auth) => auth.into(), | ||||||
|  |             Reason::UserDataChange(changed_by, changed_user, explanation) => Self::new(&format!( | ||||||
|  |                 "{changed_by} hat die Daten von {changed_user} aktualisiert: {explanation}" | ||||||
|  |             )) | ||||||
|  |             .user(changed_user), | ||||||
|  |             Reason::NewUserNote(changed_by, user, explanation) => { | ||||||
|  |                 Self::new(&format!("({changed_by}) {explanation}")).user(user) | ||||||
|  |             } | ||||||
|  |             Reason::Logbook(logbook) => logbook.into(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub enum ReasonAuth<'a> { | ||||||
|  |     // `User` tried to login with `String` as UserAgent | ||||||
|  |     SuccLogin(&'a User, String), | ||||||
|  |     // `User` tried to login which was already deleted | ||||||
|  |     DeletedUserLogin(&'a User), | ||||||
|  |     // `User` tried to login, supplied wrong PW | ||||||
|  |     WrongPw(&'a User), | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl<'a> From<ReasonAuth<'a>> for Reason<'a> { | ||||||
|  |     fn from(auth_reason: ReasonAuth<'a>) -> Self { | ||||||
|  |         Reason::Auth(auth_reason) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl From<ReasonAuth<'_>> for ActivityBuilder { | ||||||
|  |     fn from(value: ReasonAuth<'_>) -> Self { | ||||||
|  |         match value { | ||||||
|  |             ReasonAuth::SuccLogin(user, agent) => { | ||||||
|  |                 Self::new(&format!("{user} hat sich eingeloggt (User-Agent: {agent})")) | ||||||
|  |                     .user(user) | ||||||
|  |                     .keep_until_days(7) | ||||||
|  |             } | ||||||
|  |             ReasonAuth::DeletedUserLogin(user) => Self::new(&format!( | ||||||
|  |                 "{user} wollte sich einloggen, klappte jedoch nicht weil der Account gelöscht wurde." | ||||||
|  |             )) | ||||||
|  |             .user(user) | ||||||
|  |             .keep_until_days(30), | ||||||
|  |             ReasonAuth::WrongPw(user) => Self::new(&format!( | ||||||
|  |                 "User {user} wollte sich einloggen, hat jedoch das falsche Passwort angegeben." | ||||||
|  |             )) | ||||||
|  |             .user(user) | ||||||
|  |             .keep_until_days(7), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub enum ReasonLogbook<'a> { | ||||||
|  |     // `User` tried to login with `String` as UserAgent | ||||||
|  |     BoardOrAdminDeleted(&'a User, &'a LogbookWithBoatAndRowers), | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl<'a> From<ReasonLogbook<'a>> for Reason<'a> { | ||||||
|  |     fn from(logbook_reason: ReasonLogbook<'a>) -> Self { | ||||||
|  |         Reason::Logbook(logbook_reason) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl From<ReasonLogbook<'_>> for ActivityBuilder { | ||||||
|  |     fn from(value: ReasonLogbook<'_>) -> Self { | ||||||
|  |         match value { | ||||||
|  |             ReasonLogbook::BoardOrAdminDeleted(user, logbook) => Self::new(&format!( | ||||||
|  |                 "{user} hat den Logbuch-Eintrag gelöscht: {logbook}" | ||||||
|  |             )) | ||||||
|  |             .user(user) | ||||||
|  |             .logbook(&logbook.logbook) | ||||||
|  |             .keep_until_days(7), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub struct ActivityBuilder { | ||||||
|  |     text: String, | ||||||
|  |     relevant_for: String, | ||||||
|  |     keep_until: Option<NaiveDateTime>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl ActivityBuilder { | ||||||
|  |     /// TODO: maybe make this private, and only allow specific acitivites defined in `Reason` | ||||||
|  |     #[must_use] | ||||||
|  |     pub fn new(text: &str) -> Self { | ||||||
|  |         Self { | ||||||
|  |             text: text.into(), | ||||||
|  |             relevant_for: String::new(), | ||||||
|  |             keep_until: None, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[must_use] | ||||||
|  |     pub fn user(self, user: &User) -> Self { | ||||||
|  |         Self { | ||||||
|  |             relevant_for: format!("{}user-{};", self.relevant_for, user.id), | ||||||
|  |             ..self | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[must_use] | ||||||
|  |     pub fn role(self, role: &Role) -> Self { | ||||||
|  |         Self { | ||||||
|  |             relevant_for: format!("{}role-{};", self.relevant_for, role.id), | ||||||
|  |             ..self | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[must_use] | ||||||
|  |     pub fn logbook(self, logbook: &Logbook) -> Self { | ||||||
|  |         Self { | ||||||
|  |             relevant_for: format!("{}logbook-{};", self.relevant_for, logbook.id), | ||||||
|  |             ..self | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[must_use] | ||||||
|  |     pub fn keep_until_days(self, days: i64) -> Self { | ||||||
|  |         let now = Utc::now().naive_utc(); | ||||||
|  |         Self { | ||||||
|  |             keep_until: Some(now + Duration::days(days)), | ||||||
|  |             ..self | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn save(self, db: &SqlitePool) { | ||||||
|  |         Activity::create(db, &self.text, &self.relevant_for, self.keep_until).await; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn save_tx(self, db: &mut Transaction<'_, Sqlite>) { | ||||||
|  |         Activity::create_with_tx(db, &self.text, &self.relevant_for, self.keep_until).await; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Activity { | ||||||
|  |     pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             "SELECT id, created_at, text, relevant_for, keep_until FROM activity WHERE id like ?", | ||||||
|  |             id | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db) | ||||||
|  |         .await | ||||||
|  |         .ok() | ||||||
|  |     } | ||||||
|  |     pub(super) async fn create_with_tx( | ||||||
|  |         db: &mut Transaction<'_, Sqlite>, | ||||||
|  |         text: &str, | ||||||
|  |         relevant_for: &str, | ||||||
|  |         keep_until: Option<NaiveDateTime>, | ||||||
|  |     ) { | ||||||
|  |         sqlx::query!( | ||||||
|  |             "INSERT INTO activity(text, relevant_for, keep_until) VALUES (?, ?, ?)", | ||||||
|  |             text, | ||||||
|  |             relevant_for, | ||||||
|  |             keep_until | ||||||
|  |         ) | ||||||
|  |         .execute(db.deref_mut()) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(super) async fn create( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         text: &str, | ||||||
|  |         relevant_for: &str, | ||||||
|  |         keep_until: Option<NaiveDateTime>, | ||||||
|  |     ) { | ||||||
|  |         let mut tx = db.begin().await.unwrap(); | ||||||
|  |         Self::create_with_tx(&mut tx, text, relevant_for, keep_until).await; | ||||||
|  |         tx.commit().await.unwrap(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn for_user(db: &SqlitePool, user: &User) -> Vec<Activity> { | ||||||
|  |         let user_str = format!("user-{};", user.id); | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  | SELECT id, created_at, text, relevant_for, keep_until FROM activity | ||||||
|  | WHERE  | ||||||
|  |   relevant_for like CONCAT('%', ?, '%') | ||||||
|  | ORDER BY created_at DESC; | ||||||
|  |             ", | ||||||
|  |             user_str | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async fn last(db: &SqlitePool) -> Vec<Self> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  | SELECT id, created_at, text, relevant_for, keep_until FROM activity  | ||||||
|  | ORDER BY id DESC | ||||||
|  | LIMIT 1000 | ||||||
|  |     " | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn show(db: &SqlitePool) -> String { | ||||||
|  |         let mut ret = String::new(); | ||||||
|  |  | ||||||
|  |         for log in Self::last(db).await { | ||||||
|  |             let utc_time: DateTime<Utc> = Utc::from_utc_datetime(&Utc, &log.created_at); | ||||||
|  |             let local_time = utc_time.with_timezone(&Local); | ||||||
|  |             ret.push_str(&format!("- {local_time}: {}\n", log.text)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,13 +1,17 @@ | |||||||
| use std::ops::DerefMut; | use std::ops::DerefMut; | ||||||
|  |  | ||||||
|  | use chrono::NaiveDateTime; | ||||||
| use rocket::serde::{Deserialize, Serialize}; | use rocket::serde::{Deserialize, Serialize}; | ||||||
| use rocket::FromForm; | use rocket::FromForm; | ||||||
| use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; | use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; | ||||||
|  |  | ||||||
|  | use crate::model::boathouse::Boathouse; | ||||||
|  |  | ||||||
| use super::location::Location; | use super::location::Location; | ||||||
| use super::user::User; | use super::user::User; | ||||||
|  | use std::fmt::Display; | ||||||
|  |  | ||||||
| #[derive(FromRow, Debug, Serialize, Deserialize)] | #[derive(FromRow, Debug, Serialize, Deserialize, Eq, Hash, PartialEq, Clone)] | ||||||
| pub struct Boat { | pub struct Boat { | ||||||
|     pub id: i64, |     pub id: i64, | ||||||
|     pub name: String, |     pub name: String, | ||||||
| @@ -18,11 +22,25 @@ pub struct Boat { | |||||||
|     pub boatbuilder: Option<String>, |     pub boatbuilder: Option<String>, | ||||||
|     pub default_destination: Option<String>, |     pub default_destination: Option<String>, | ||||||
|     #[serde(default = "bool::default")] |     #[serde(default = "bool::default")] | ||||||
|     default_shipmaster_only_steering: bool, |     pub convert_handoperated_possible: bool, | ||||||
|  |     #[serde(default = "bool::default")] | ||||||
|  |     pub default_shipmaster_only_steering: bool, | ||||||
|     #[serde(default = "bool::default")] |     #[serde(default = "bool::default")] | ||||||
|     skull: bool, |     skull: bool, | ||||||
|     #[serde(default = "bool::default")] |     #[serde(default = "bool::default")] | ||||||
|     external: bool, |     pub external: bool, | ||||||
|  |     pub deleted: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Display for Boat { | ||||||
|  |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  |         let private_or_club_boat = if self.owner.is_some() { | ||||||
|  |             "privat" | ||||||
|  |         } else { | ||||||
|  |             "Vereinsboot" | ||||||
|  |         }; | ||||||
|  |         write!(f, "{} ({}, {private_or_club_boat})", self.name, self.cat()) | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Serialize, Deserialize, Debug)] | #[derive(Serialize, Deserialize, Debug)] | ||||||
| @@ -36,9 +54,11 @@ pub enum BoatDamage { | |||||||
| #[derive(Serialize, Deserialize, Debug)] | #[derive(Serialize, Deserialize, Debug)] | ||||||
| pub struct BoatWithDetails { | pub struct BoatWithDetails { | ||||||
|     #[serde(flatten)] |     #[serde(flatten)] | ||||||
|     boat: Boat, |     pub(crate) boat: Boat, | ||||||
|     damage: BoatDamage, |     damage: BoatDamage, | ||||||
|     on_water: bool, |     on_water: bool, | ||||||
|  |     reserved_today: bool, | ||||||
|  |     cat: String, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(FromForm)] | #[derive(FromForm)] | ||||||
| @@ -48,6 +68,7 @@ pub struct BoatToAdd<'r> { | |||||||
|     pub year_built: Option<i64>, |     pub year_built: Option<i64>, | ||||||
|     pub boatbuilder: Option<&'r str>, |     pub boatbuilder: Option<&'r str>, | ||||||
|     pub default_shipmaster_only_steering: bool, |     pub default_shipmaster_only_steering: bool, | ||||||
|  |     pub convert_handoperated_possible: bool, | ||||||
|     pub default_destination: Option<&'r str>, |     pub default_destination: Option<&'r str>, | ||||||
|     pub skull: bool, |     pub skull: bool, | ||||||
|     pub external: bool, |     pub external: bool, | ||||||
| @@ -64,6 +85,7 @@ pub struct BoatToUpdate<'r> { | |||||||
|     pub default_shipmaster_only_steering: bool, |     pub default_shipmaster_only_steering: bool, | ||||||
|     pub default_destination: Option<&'r str>, |     pub default_destination: Option<&'r str>, | ||||||
|     pub skull: bool, |     pub skull: bool, | ||||||
|  |     pub convert_handoperated_possible: bool, | ||||||
|     pub external: bool, |     pub external: bool, | ||||||
|     pub location_id: i64, |     pub location_id: i64, | ||||||
|     pub owner: Option<i64>, |     pub owner: Option<i64>, | ||||||
| @@ -71,44 +93,30 @@ pub struct BoatToUpdate<'r> { | |||||||
|  |  | ||||||
| impl Boat { | impl Boat { | ||||||
|     pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> { |     pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> { | ||||||
|         sqlx::query_as!(Self, "SELECT * FROM boat WHERE id like ?", id) |         sqlx::query_as!(Self, "SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, convert_handoperated_possible, default_destination, skull, external, deleted FROM boat WHERE id like ?", id) | ||||||
|             .fetch_one(db) |             .fetch_one(db) | ||||||
|             .await |             .await | ||||||
|             .ok() |             .ok() | ||||||
|     } |     } | ||||||
|     pub async fn find_by_id_tx(db: &mut Transaction<'_, Sqlite>, id: i32) -> Option<Self> { |     pub async fn find_by_id_tx(db: &mut Transaction<'_, Sqlite>, id: i32) -> Option<Self> { | ||||||
|         sqlx::query_as!(Self, "SELECT * FROM boat WHERE id like ?", id) |         sqlx::query_as!(Self, "SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, convert_handoperated_possible, default_destination, skull, external, deleted FROM boat WHERE id like ?", id) | ||||||
|             .fetch_one(db.deref_mut()) |             .fetch_one(db.deref_mut()) | ||||||
|             .await |             .await | ||||||
|             .ok() |             .ok() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn find_by_name(db: &SqlitePool, name: String) -> Option<Self> { |     pub async fn find_by_name(db: &SqlitePool, name: String) -> Option<Self> { | ||||||
|         sqlx::query_as!(Self, "SELECT * FROM boat WHERE name like ?", name) |         sqlx::query_as!(Self, "SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, convert_handoperated_possible, default_destination, skull, external, deleted FROM boat WHERE name like ?", name) | ||||||
|             .fetch_one(db) |             .fetch_one(db) | ||||||
|             .await |             .await | ||||||
|             .ok() |             .ok() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn shipmaster_allowed(&self, db: &SqlitePool, user: &User) -> bool { |     pub async fn shipmaster_allowed(&self, db: &SqlitePool, user: &User) -> bool { | ||||||
|         if let Some(owner_id) = self.owner { |         let mut tx = db.begin().await.unwrap(); | ||||||
|             return owner_id == user.id; |         let ret = self.shipmaster_allowed_tx(&mut tx, user).await; | ||||||
|         } |         tx.commit().await.unwrap(); | ||||||
|  |         ret | ||||||
|         if user.has_role(db, "Rennrudern").await { |  | ||||||
|             let ottensheim = Location::find_by_name(db, "Ottensheim".into()) |  | ||||||
|                 .await |  | ||||||
|                 .unwrap(); |  | ||||||
|             if self.location_id == ottensheim.id { |  | ||||||
|                 return true; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if self.amount_seats == 1 { |  | ||||||
|             return true; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         user.has_role(db, "cox").await |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn shipmaster_allowed_tx( |     pub async fn shipmaster_allowed_tx( | ||||||
| @@ -116,15 +124,32 @@ impl Boat { | |||||||
|         db: &mut Transaction<'_, Sqlite>, |         db: &mut Transaction<'_, Sqlite>, | ||||||
|         user: &User, |         user: &User, | ||||||
|     ) -> bool { |     ) -> bool { | ||||||
|  |         if user.has_role_tx(db, "admin").await { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         if let Some(owner_id) = self.owner { |         if let Some(owner_id) = self.owner { | ||||||
|             return owner_id == user.id; |             return owner_id == user.id; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         if user.has_role_tx(db, "Rennrudern").await { | ||||||
|  |             let ottensheim = Location::find_by_name_tx(db, "Ottensheim".into()) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|  |             if self.location_id == ottensheim.id { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if self.name == "Externes Boot" { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         if self.amount_seats == 1 { |         if self.amount_seats == 1 { | ||||||
|             return true; |             return true; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         user.has_role_tx(db, "cox").await |         user.allowed_to_steer_tx(db).await | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn is_locked(&self, db: &SqlitePool) -> bool { |     pub async fn is_locked(&self, db: &SqlitePool) -> bool { | ||||||
| @@ -135,6 +160,20 @@ impl Boat { | |||||||
|         sqlx::query!("SELECT * FROM boat_damage WHERE boat_id=? AND lock_boat=false AND user_id_verified is null", self.id).fetch_optional(db).await.unwrap().is_some() |         sqlx::query!("SELECT * FROM boat_damage WHERE boat_id=? AND lock_boat=false AND user_id_verified is null", self.id).fetch_optional(db).await.unwrap().is_some() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     pub async fn reserved_today(&self, db: &SqlitePool) -> bool { | ||||||
|  |         sqlx::query!( | ||||||
|  |             "SELECT *  | ||||||
|  | FROM boat_reservation | ||||||
|  | WHERE boat_id =?  | ||||||
|  | AND date('now') BETWEEN start_date AND end_date;", | ||||||
|  |             self.id | ||||||
|  |         ) | ||||||
|  |         .fetch_optional(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap() | ||||||
|  |         .is_some() | ||||||
|  |     } | ||||||
|  |  | ||||||
|     pub async fn on_water(&self, db: &SqlitePool) -> bool { |     pub async fn on_water(&self, db: &SqlitePool) -> bool { | ||||||
|         sqlx::query!( |         sqlx::query!( | ||||||
|             "SELECT * FROM logbook WHERE boat_id=? AND arrival is null", |             "SELECT * FROM logbook WHERE boat_id=? AND arrival is null", | ||||||
| @@ -146,6 +185,18 @@ impl Boat { | |||||||
|         .is_some() |         .is_some() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     pub(crate) fn cat(&self) -> String { | ||||||
|  |         if self.external { | ||||||
|  |             "Vereinsfremde Boote".to_string() | ||||||
|  |         } else if self.default_shipmaster_only_steering { | ||||||
|  |             format!("{}+", self.amount_seats - 1) | ||||||
|  |         } else if self.skull { | ||||||
|  |             format!("{}x", self.amount_seats) | ||||||
|  |         } else { | ||||||
|  |             format!("{}-", self.amount_seats) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     async fn boats_to_details(db: &SqlitePool, boats: Vec<Boat>) -> Vec<BoatWithDetails> { |     async fn boats_to_details(db: &SqlitePool, boats: Vec<Boat>) -> Vec<BoatWithDetails> { | ||||||
|         let mut res = Vec::new(); |         let mut res = Vec::new(); | ||||||
|         for boat in boats { |         for boat in boats { | ||||||
| @@ -156,10 +207,14 @@ impl Boat { | |||||||
|             if boat.is_locked(db).await { |             if boat.is_locked(db).await { | ||||||
|                 damage = BoatDamage::Locked; |                 damage = BoatDamage::Locked; | ||||||
|             } |             } | ||||||
|  |             let cat = boat.cat(); | ||||||
|  |  | ||||||
|             res.push(BoatWithDetails { |             res.push(BoatWithDetails { | ||||||
|                 damage, |                 damage, | ||||||
|                 on_water: boat.on_water(db).await, |                 on_water: boat.on_water(db).await, | ||||||
|  |                 reserved_today: boat.reserved_today(db).await, | ||||||
|                 boat, |                 boat, | ||||||
|  |                 cat, | ||||||
|             }); |             }); | ||||||
|         } |         } | ||||||
|         res |         res | ||||||
| @@ -169,8 +224,9 @@ impl Boat { | |||||||
|         let boats = sqlx::query_as!( |         let boats = sqlx::query_as!( | ||||||
|             Boat, |             Boat, | ||||||
|             " |             " | ||||||
| SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external  | SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external, deleted, convert_handoperated_possible  | ||||||
| FROM boat  | FROM boat  | ||||||
|  | WHERE deleted=false | ||||||
| ORDER BY amount_seats DESC | ORDER BY amount_seats DESC | ||||||
|         " |         " | ||||||
|         ) |         ) | ||||||
| @@ -181,51 +237,62 @@ ORDER BY amount_seats DESC | |||||||
|         Self::boats_to_details(db, boats).await |         Self::boats_to_details(db, boats).await | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn for_user(db: &SqlitePool, user: &User) -> Vec<BoatWithDetails> { |     pub async fn all_for_boatshouse(db: &SqlitePool) -> Vec<BoatWithDetails> { | ||||||
|         if user.has_role(db, "admin").await { |         let boats = sqlx::query_as!( | ||||||
|             return Self::all(db).await; |  | ||||||
|         } |  | ||||||
|         let boats = if user.has_role(db, "cox").await { |  | ||||||
|             sqlx::query_as!( |  | ||||||
|             Boat, |             Boat, | ||||||
|             " |             " | ||||||
| SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external  | SELECT  | ||||||
| FROM boat  |     b.id,  | ||||||
| WHERE owner is null or owner = ? |     b.name,  | ||||||
| ORDER BY amount_seats DESC |     b.amount_seats,  | ||||||
|         ", |     b.location_id,  | ||||||
|         user.id |     b.owner,  | ||||||
|  |     b.year_built,  | ||||||
|  |     b.boatbuilder,  | ||||||
|  |     b.default_shipmaster_only_steering,  | ||||||
|  |     b.default_destination,  | ||||||
|  |     b.skull,  | ||||||
|  |     b.external, | ||||||
|  |     b.deleted, | ||||||
|  |     b.convert_handoperated_possible | ||||||
|  | FROM  | ||||||
|  |     boat AS b | ||||||
|  | WHERE  | ||||||
|  |     b.external = false  | ||||||
|  |     AND b.location_id = (SELECT id FROM location WHERE name = 'Linz') | ||||||
|  |     AND b.deleted = false | ||||||
|  | ORDER BY  | ||||||
|  |     b.name DESC; | ||||||
|  |         " | ||||||
|         ) |         ) | ||||||
|         .fetch_all(db) |         .fetch_all(db) | ||||||
|         .await |         .await | ||||||
|         .unwrap() //TODO: fixme |         .unwrap(); //TODO: fixme | ||||||
|         } else { |  | ||||||
|             sqlx::query_as!( |  | ||||||
|             Boat, |  | ||||||
|             " |  | ||||||
| SELECT id, name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external  |  | ||||||
| FROM boat  |  | ||||||
| WHERE owner = ? OR (owner is null and amount_seats = 1) |  | ||||||
| ORDER BY amount_seats DESC |  | ||||||
|         ", |  | ||||||
|         user.id |  | ||||||
|         ) |  | ||||||
|         .fetch_all(db) |  | ||||||
|         .await |  | ||||||
|         .unwrap() //TODO: fixme |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         Self::boats_to_details(db, boats).await |         Self::boats_to_details(db, boats).await | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     pub async fn for_user(db: &SqlitePool, user: &User) -> Vec<BoatWithDetails> { | ||||||
|  |         let all_boats = Self::all(db).await; | ||||||
|  |         let mut filtered_boats = Vec::new(); | ||||||
|  |  | ||||||
|  |         for boat in all_boats { | ||||||
|  |             if boat.boat.shipmaster_allowed(db, user).await { | ||||||
|  |                 filtered_boats.push(boat); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         filtered_boats | ||||||
|  |     } | ||||||
|  |  | ||||||
|     pub async fn all_at_location(db: &SqlitePool, location: String) -> Vec<BoatWithDetails> { |     pub async fn all_at_location(db: &SqlitePool, location: String) -> Vec<BoatWithDetails> { | ||||||
|         let boats = sqlx::query_as!( |         let boats = sqlx::query_as!( | ||||||
|             Boat, |             Boat, | ||||||
|             " |             " | ||||||
| SELECT boat.id, boat.name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external  | SELECT boat.id, boat.name, amount_seats, location_id, owner, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external, deleted, convert_handoperated_possible | ||||||
| FROM boat  | FROM boat  | ||||||
| INNER JOIN location ON boat.location_id = location.id | INNER JOIN location ON boat.location_id = location.id | ||||||
| WHERE location.name=? | WHERE location.name=? AND deleted = 0 | ||||||
| ORDER BY amount_seats DESC | ORDER BY amount_seats DESC | ||||||
|         ", |         ", | ||||||
|         location |         location | ||||||
| @@ -239,7 +306,7 @@ ORDER BY amount_seats DESC | |||||||
|  |  | ||||||
|     pub async fn create(db: &SqlitePool, boat: BoatToAdd<'_>) -> Result<(), String> { |     pub async fn create(db: &SqlitePool, boat: BoatToAdd<'_>) -> Result<(), String> { | ||||||
|         sqlx::query!( |         sqlx::query!( | ||||||
|             "INSERT INTO boat(name, amount_seats, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external, location_id, owner) VALUES (?,?,?,?,?,?,?,?,?,?)", |             "INSERT INTO boat(name, amount_seats, year_built, boatbuilder, default_shipmaster_only_steering, default_destination, skull, external, location_id, owner, convert_handoperated_possible) VALUES (?,?,?,?,?,?,?,?,?,?,?)", | ||||||
|             boat.name, |             boat.name, | ||||||
|             boat.amount_seats, |             boat.amount_seats, | ||||||
|             boat.year_built, |             boat.year_built, | ||||||
| @@ -249,7 +316,8 @@ ORDER BY amount_seats DESC | |||||||
|             boat.skull, |             boat.skull, | ||||||
|             boat.external, |             boat.external, | ||||||
|             boat.location_id, |             boat.location_id, | ||||||
|             boat.owner |             boat.owner, | ||||||
|  |             boat.convert_handoperated_possible | ||||||
|         ) |         ) | ||||||
|         .execute(db) |         .execute(db) | ||||||
|         .await.map_err(|e| e.to_string())?; |         .await.map_err(|e| e.to_string())?; | ||||||
| @@ -258,7 +326,7 @@ ORDER BY amount_seats DESC | |||||||
|  |  | ||||||
|     pub async fn update(&self, db: &SqlitePool, boat: BoatToUpdate<'_>) -> Result<(), String> { |     pub async fn update(&self, db: &SqlitePool, boat: BoatToUpdate<'_>) -> Result<(), String> { | ||||||
|         sqlx::query!( |         sqlx::query!( | ||||||
|             "UPDATE boat SET name=?, amount_seats=?, year_built=?, boatbuilder=?, default_shipmaster_only_steering=?, default_destination=?, skull=?, external=?, location_id=?, owner=? WHERE id=?", |             "UPDATE boat SET name=?, amount_seats=?, year_built=?, boatbuilder=?, default_shipmaster_only_steering=?, default_destination=?, skull=?, external=?, location_id=?, owner=?, convert_handoperated_possible=? WHERE id=?", | ||||||
|         boat.name, |         boat.name, | ||||||
|         boat.amount_seats, |         boat.amount_seats, | ||||||
|         boat.year_built, |         boat.year_built, | ||||||
| @@ -269,6 +337,7 @@ ORDER BY amount_seats DESC | |||||||
|         boat.external, |         boat.external, | ||||||
|         boat.location_id, |         boat.location_id, | ||||||
|         boat.owner, |         boat.owner, | ||||||
|  |         boat.convert_handoperated_possible, | ||||||
|             self.id |             self.id | ||||||
|         ) |         ) | ||||||
|         .execute(db) |         .execute(db) | ||||||
| @@ -276,12 +345,64 @@ ORDER BY amount_seats DESC | |||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     pub async fn owner(&self, db: &SqlitePool) -> Option<User> { | ||||||
|  |         if let Some(owner_id) = self.owner { | ||||||
|  |             Some(User::find_by_id(db, owner_id as i32).await.unwrap()) | ||||||
|  |         } else { | ||||||
|  |             None | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     pub async fn delete(&self, db: &SqlitePool) { |     pub async fn delete(&self, db: &SqlitePool) { | ||||||
|         sqlx::query!("DELETE FROM boat WHERE id=?", self.id) |         sqlx::query!("UPDATE boat SET deleted=1 WHERE id=?", self.id) | ||||||
|             .execute(db) |             .execute(db) | ||||||
|             .await |             .await | ||||||
|             .unwrap(); //Okay, because we can only create a Boat of a valid id |             .unwrap(); //Okay, because we can only create a Boat of a valid id | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     pub async fn boathouse(&self, db: &SqlitePool) -> Option<Boathouse> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Boathouse, | ||||||
|  |             "SELECT * FROM boathouse WHERE boat_id like ?", | ||||||
|  |             self.id | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db) | ||||||
|  |         .await | ||||||
|  |         .ok() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn on_water_between( | ||||||
|  |         &self, | ||||||
|  |         db: &mut Transaction<'_, Sqlite>, | ||||||
|  |         dep: NaiveDateTime, | ||||||
|  |         arr: NaiveDateTime, | ||||||
|  |     ) -> bool { | ||||||
|  |         let dep = dep.format("%Y-%m-%dT%H:%M").to_string(); | ||||||
|  |         let arr = arr.format("%Y-%m-%dT%H:%M").to_string(); | ||||||
|  |  | ||||||
|  |         sqlx::query!( | ||||||
|  |             "SELECT COUNT(*) AS overlap_count | ||||||
|  | FROM logbook | ||||||
|  | WHERE boat_id = ? | ||||||
|  |   AND ( | ||||||
|  |     (departure <= ? AND arrival >= ?)  -- Existing entry covers the entire new period | ||||||
|  |     OR (departure >= ? AND departure < ?)  -- Existing entry starts during the new period | ||||||
|  |     OR (arrival > ? AND arrival <= ?)  -- Existing entry ends during the new period | ||||||
|  |   );", | ||||||
|  |             self.id, | ||||||
|  |             arr, | ||||||
|  |             arr, | ||||||
|  |             dep, | ||||||
|  |             dep, | ||||||
|  |             dep, | ||||||
|  |             arr | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db.deref_mut()) | ||||||
|  |         .await | ||||||
|  |         .unwrap() | ||||||
|  |         .overlap_count | ||||||
|  |             > 0 | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| #[cfg(test)] | #[cfg(test)] | ||||||
| @@ -329,6 +450,7 @@ mod test { | |||||||
|                     year_built: None, |                     year_built: None, | ||||||
|                     boatbuilder: "Best Boatbuilder".into(), |                     boatbuilder: "Best Boatbuilder".into(), | ||||||
|                     default_shipmaster_only_steering: true, |                     default_shipmaster_only_steering: true, | ||||||
|  |                     convert_handoperated_possible: false, | ||||||
|                     skull: true, |                     skull: true, | ||||||
|                     external: false, |                     external: false, | ||||||
|                     location_id: Some(1), |                     location_id: Some(1), | ||||||
| @@ -354,6 +476,7 @@ mod test { | |||||||
|                     year_built: None, |                     year_built: None, | ||||||
|                     boatbuilder: "Best Boatbuilder".into(), |                     boatbuilder: "Best Boatbuilder".into(), | ||||||
|                     default_shipmaster_only_steering: true, |                     default_shipmaster_only_steering: true, | ||||||
|  |                     convert_handoperated_possible: false, | ||||||
|                     skull: true, |                     skull: true, | ||||||
|                     external: false, |                     external: false, | ||||||
|                     location_id: Some(1), |                     location_id: Some(1), | ||||||
| @@ -456,6 +579,7 @@ mod test { | |||||||
|             year_built: None, |             year_built: None, | ||||||
|             boatbuilder: None, |             boatbuilder: None, | ||||||
|             default_shipmaster_only_steering: false, |             default_shipmaster_only_steering: false, | ||||||
|  |             convert_handoperated_possible: false, | ||||||
|             skull: true, |             skull: true, | ||||||
|             external: false, |             external: false, | ||||||
|             location_id: 1, |             location_id: 1, | ||||||
| @@ -479,6 +603,7 @@ mod test { | |||||||
|             year_built: None, |             year_built: None, | ||||||
|             boatbuilder: None, |             boatbuilder: None, | ||||||
|             default_shipmaster_only_steering: false, |             default_shipmaster_only_steering: false, | ||||||
|  |             convert_handoperated_possible: false, | ||||||
|             skull: true, |             skull: true, | ||||||
|             external: false, |             external: false, | ||||||
|             location_id: 999, |             location_id: 999, | ||||||
|   | |||||||
| @@ -1,10 +1,12 @@ | |||||||
| use crate::model::{boat::Boat, user::User}; | use crate::model::{boat::Boat, user::User}; | ||||||
| use chrono::NaiveDateTime; | use chrono::NaiveDateTime; | ||||||
| use rocket::serde::{Deserialize, Serialize}; |  | ||||||
| use rocket::FromForm; | use rocket::FromForm; | ||||||
|  | use rocket::serde::{Deserialize, Serialize}; | ||||||
| use sqlx::{FromRow, SqlitePool}; | use sqlx::{FromRow, SqlitePool}; | ||||||
|  |  | ||||||
| use super::log::Log; | use super::log::Log; | ||||||
|  | use super::notification::Notification; | ||||||
|  | use super::role::Role; | ||||||
|  |  | ||||||
| #[derive(FromRow, Debug, Serialize, Deserialize)] | #[derive(FromRow, Debug, Serialize, Deserialize)] | ||||||
| pub struct BoatDamage { | pub struct BoatDamage { | ||||||
| @@ -71,6 +73,10 @@ impl BoatDamage { | |||||||
|             " |             " | ||||||
| SELECT id, boat_id, desc, user_id_created, created_at, user_id_fixed, fixed_at, user_id_verified, verified_at, lock_boat  | SELECT id, boat_id, desc, user_id_created, created_at, user_id_fixed, fixed_at, user_id_verified, verified_at, lock_boat  | ||||||
| FROM boat_damage | FROM boat_damage | ||||||
|  | WHERE ( | ||||||
|  |     verified_at IS NULL  | ||||||
|  |     OR verified_at >= datetime('now', '-30 days') | ||||||
|  |   )  | ||||||
| ORDER BY created_at DESC | ORDER BY created_at DESC | ||||||
|         " |         " | ||||||
|         ) |         ) | ||||||
| @@ -113,6 +119,10 @@ ORDER BY created_at DESC | |||||||
|  |  | ||||||
|     pub async fn create(db: &SqlitePool, boatdamage: BoatDamageToAdd<'_>) -> Result<(), String> { |     pub async fn create(db: &SqlitePool, boatdamage: BoatDamageToAdd<'_>) -> Result<(), String> { | ||||||
|         Log::create(db, format!("New boat damage: {boatdamage:?}")).await; |         Log::create(db, format!("New boat damage: {boatdamage:?}")).await; | ||||||
|  |         let Some(boat) = Boat::find_by_id(db, boatdamage.boat_id as i32).await else { | ||||||
|  |             return Err("Boot gibt's ned".into()); | ||||||
|  |         }; | ||||||
|  |         let was_unusable_before = boat.is_locked(db).await; | ||||||
|  |  | ||||||
|         sqlx::query!( |         sqlx::query!( | ||||||
|             "INSERT INTO boat_damage(boat_id, desc, user_id_created, lock_boat) VALUES (?,?,?, ?)", |             "INSERT INTO boat_damage(boat_id, desc, user_id_created, lock_boat) VALUES (?,?,?, ?)", | ||||||
| @@ -124,63 +134,217 @@ ORDER BY created_at DESC | |||||||
|         .execute(db) |         .execute(db) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| e.to_string())?; |         .map_err(|e| e.to_string())?; | ||||||
|  |  | ||||||
|  |         if !was_unusable_before && boat.is_locked(db).await { | ||||||
|  |             Notification::create_for_steering_people(db,  &format!("Liebe Steuerberechtigte, bitte beachten, dass {} bis auf weiteres aufgrund von Reparaturarbeiten gesperrt ist.", boat.name), "Boot gesperrt", None, None).await; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let technicals = | ||||||
|  |             User::all_with_role(db, &Role::find_by_name(db, "tech").await.unwrap()).await; | ||||||
|  |         for technical in technicals { | ||||||
|  |             if technical.id as i32 != boatdamage.user_id_created { | ||||||
|  |                 Notification::create( | ||||||
|  |                     db, | ||||||
|  |                     &technical, | ||||||
|  |                     &format!( | ||||||
|  |                         "{} hat einen neuen Bootschaden für Boot '{}' angelegt: {}", | ||||||
|  |                         User::find_by_id(db, boatdamage.user_id_created) | ||||||
|  |                             .await | ||||||
|  |                             .unwrap() | ||||||
|  |                             .name, | ||||||
|  |                         boat.name, | ||||||
|  |                         boatdamage.desc | ||||||
|  |                     ), | ||||||
|  |                     "Neuer Bootsschaden angelegt", | ||||||
|  |                     None, | ||||||
|  |                     None, | ||||||
|  |                 ) | ||||||
|  |                 .await; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Notification::create( | ||||||
|  |             db, | ||||||
|  |             &User::find_by_id(db, boatdamage.user_id_created) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(), | ||||||
|  |             &format!( | ||||||
|  |                 "Du hat einen neuen Bootschaden für Boot '{}' angelegt: {}", | ||||||
|  |                 Boat::find_by_id(db, boatdamage.boat_id as i32) | ||||||
|  |                     .await | ||||||
|  |                     .unwrap() | ||||||
|  |                     .name, | ||||||
|  |                 boatdamage.desc | ||||||
|  |             ), | ||||||
|  |             "Neuer Bootsschaden angelegt", | ||||||
|  |             None, | ||||||
|  |             None, | ||||||
|  |         ) | ||||||
|  |         .await; | ||||||
|  |  | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn fixed(&self, db: &SqlitePool, boat: BoatDamageFixed<'_>) -> Result<(), String> { |     pub async fn fixed( | ||||||
|         Log::create(db, format!("Fixed boat damage: {boat:?}")).await; |         &self, | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         boat_damage: BoatDamageFixed<'_>, | ||||||
|  |     ) -> Result<(), String> { | ||||||
|  |         Log::create(db, format!("Fixed boat damage: {boat_damage:?}")).await; | ||||||
|  |  | ||||||
|  |         let boat = Boat::find_by_id(db, self.boat_id as i32).await.unwrap(); | ||||||
|  |  | ||||||
|         sqlx::query!( |         sqlx::query!( | ||||||
|             "UPDATE boat_damage SET desc=?, user_id_fixed=?, fixed_at=CURRENT_TIMESTAMP WHERE id=?", |             "UPDATE boat_damage SET desc=?, user_id_fixed=?, fixed_at=CURRENT_TIMESTAMP WHERE id=?", | ||||||
|             boat.desc, |             boat_damage.desc, | ||||||
|             boat.user_id_fixed, |             boat_damage.user_id_fixed, | ||||||
|             self.id |             self.id | ||||||
|         ) |         ) | ||||||
|         .execute(db) |         .execute(db) | ||||||
|         .await |         .await | ||||||
|         .map_err(|e| e.to_string())?; |         .map_err(|e| e.to_string())?; | ||||||
|  |  | ||||||
|         let user = User::find_by_id(db, boat.user_id_fixed).await.unwrap(); |         let user = User::find_by_id(db, boat_damage.user_id_fixed) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|         if user.has_role(db, "tech").await { |         if user.has_role(db, "tech").await { | ||||||
|             return self |             return self | ||||||
|                 .verified( |                 .verified( | ||||||
|                     db, |                     db, | ||||||
|                     BoatDamageVerified { |                     BoatDamageVerified { | ||||||
|                         desc: boat.desc, |                         desc: boat_damage.desc, | ||||||
|                         user_id_verified: user.id as i32, |                         user_id_verified: user.id as i32, | ||||||
|                     }, |                     }, | ||||||
|                 ) |                 ) | ||||||
|                 .await; |                 .await; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         let technicals = | ||||||
|  |             User::all_with_role(db, &Role::find_by_name(db, "tech").await.unwrap()).await; | ||||||
|  |         for technical in technicals { | ||||||
|  |             if technical.id as i32 != boat_damage.user_id_fixed { | ||||||
|  |                 Notification::create( | ||||||
|  |                     db, | ||||||
|  |                     &technical, | ||||||
|  |                     &format!( | ||||||
|  |                         "{} hat den Bootschaden '{}' beim Boot '{}' repariert. Könntest du das bei Gelegenheit verifizieren?", | ||||||
|  |                         User::find_by_id(db, boat_damage.user_id_fixed) | ||||||
|  |                             .await | ||||||
|  |                             .unwrap() | ||||||
|  |                             .name, | ||||||
|  |                         boat_damage.desc, | ||||||
|  |                        boat.name, | ||||||
|  |                     ), | ||||||
|  |                     "Bootsschaden repariert", | ||||||
|  |                     None,None | ||||||
|  |                 ) | ||||||
|  |                 .await; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if boat_damage.user_id_fixed != self.user_id_created as i32 { | ||||||
|  |             let user_fixed = User::find_by_id(db, boat_damage.user_id_fixed) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|  |             let user_created = User::find_by_id(db, self.user_id_created as i32) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|  |  | ||||||
|  |             // Boatdamage is also directly verified, if a tech has repaired it. We don't want to | ||||||
|  |             // send 2 notifications. | ||||||
|  |             if !user_fixed.has_role(db, "tech").await { | ||||||
|  |                 Notification::create( | ||||||
|  |                 db, | ||||||
|  |                 &user_created, | ||||||
|  |                 &format!( | ||||||
|  |                     "{} hat den von dir eingetragenen Bootschaden '{}' beim Boot '{}' repariert. Dieser muss nun noch von unseren Bootswarten bestätigt werden.", | ||||||
|  |                     user_fixed.name, | ||||||
|  |                     boat_damage.desc, boat.name, | ||||||
|  |                 ), | ||||||
|  |                 "Bootsschaden repariert", | ||||||
|  |                 None,None | ||||||
|  |             ) | ||||||
|  |             .await; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn verified( |     pub async fn verified( | ||||||
|         &self, |         &self, | ||||||
|         db: &SqlitePool, |         db: &SqlitePool, | ||||||
|         boat: BoatDamageVerified<'_>, |         boat_form: BoatDamageVerified<'_>, | ||||||
|     ) -> Result<(), String> { |     ) -> Result<(), String> { | ||||||
|         if let Some(verifier) = User::find_by_id(db, boat.user_id_verified).await { |         if let Some(verifier) = User::find_by_id(db, boat_form.user_id_verified).await { | ||||||
|             if !verifier.has_role(db, "tech").await { |             if !verifier.has_role(db, "tech").await { | ||||||
|                 Log::create(db, format!("User {verifier:?} tried to verify boat {boat:?}. The user is no tech. Manually craftted request?")).await; |                 Log::create(db, format!("User {verifier:?} tried to verify boat {boat_form:?}. The user is no tech. Manually craftted request?")).await; | ||||||
|                 return Err("You are not allowed to verify the boat!".into()); |                 return Err("You are not allowed to verify the boat!".into()); | ||||||
|             } |             } | ||||||
|         } else { |         } else { | ||||||
|             Log::create(db, format!("Someone tried to verify the boat {boat:?} with user_id={} which does not exist. Manually craftted request?", boat.user_id_verified)).await; |             Log::create(db, format!("Someone tried to verify the boat {boat_form:?} with user_id={} which does not exist. Manually craftted request?", boat_form.user_id_verified)).await; | ||||||
|             return Err("Could not find user".into()); |             return Err("Could not find user".into()); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         Log::create(db, format!("Verified boat damage: {boat:?}")).await; |         let Some(boat) = Boat::find_by_id(db, self.boat_id as i32).await else { | ||||||
|  |             return Err("Boot gibt's ned".into()); | ||||||
|  |         }; | ||||||
|  |         let was_unusable_before = boat.is_locked(db).await; | ||||||
|  |  | ||||||
|  |         Log::create(db, format!("Verified boat damage: {boat_form:?}")).await; | ||||||
|  |  | ||||||
|         sqlx::query!( |         sqlx::query!( | ||||||
|             "UPDATE boat_damage SET desc=?, user_id_verified=?, verified_at=CURRENT_TIMESTAMP WHERE id=?", |             "UPDATE boat_damage SET desc=?, user_id_verified=?, verified_at=CURRENT_TIMESTAMP WHERE id=?", | ||||||
|             boat.desc, |             boat_form.desc, | ||||||
|             boat.user_id_verified, |             boat_form.user_id_verified, | ||||||
|             self.id |             self.id | ||||||
|         ) |         ) | ||||||
|         .execute(db) |         .execute(db) | ||||||
|         .await.map_err(|e| e.to_string())?; |         .await.map_err(|e| e.to_string())?; | ||||||
|  |  | ||||||
|  |         if boat_form.user_id_verified != self.user_id_created as i32 { | ||||||
|  |             let user_verified = User::find_by_id(db, boat_form.user_id_verified) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|  |             let user_created = User::find_by_id(db, self.user_id_created as i32) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|  |  | ||||||
|  |             if user_verified.id == self.user_id_fixed.unwrap() { | ||||||
|  |                 Notification::create( | ||||||
|  |                  db, | ||||||
|  |                  &user_created, | ||||||
|  |                  &format!( | ||||||
|  |                      "{} hat den von dir eingetragenen Bootschaden '{}' beim Boot '{}' repariert und verifiziert.", | ||||||
|  |                      user_verified.name, | ||||||
|  |                      self.desc, boat.name, | ||||||
|  |                  ), | ||||||
|  |                  "Bootsschaden repariert & verifiziert", | ||||||
|  |                  None, | ||||||
|  |                  None | ||||||
|  |              ) | ||||||
|  |              .await; | ||||||
|  |             } else { | ||||||
|  |                 Notification::create( | ||||||
|  |                 db, | ||||||
|  |                 &user_created, | ||||||
|  |                 &format!( | ||||||
|  |                     "{} hat verifiziert, dass der von dir eingetragenen Bootschaden '{}' beim Boot '{}' korrekt repariert wurde.", | ||||||
|  |                     user_verified.name, | ||||||
|  |                     self.desc, boat.name, | ||||||
|  |                 ), | ||||||
|  |                 "Bootsschaden verifiziert", | ||||||
|  |                 None, | ||||||
|  |                 None | ||||||
|  |             ).await; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if was_unusable_before && !boat.is_locked(db).await { | ||||||
|  |             let cox = Role::find_by_name(db, "cox").await.unwrap(); | ||||||
|  |             Notification::create_for_role(db, &cox, &format!("Liebe Steuerberechtigte, {} wurde repariert und freut sich ab sofort wieder gerudert zu werden :-)", boat.name), "Boot repariert", None, None).await; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										172
									
								
								src/model/boathouse.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,172 @@ | |||||||
|  | use rocket::serde::{Deserialize, Serialize}; | ||||||
|  | use sqlx::{FromRow, SqlitePool}; | ||||||
|  |  | ||||||
|  | use crate::{ | ||||||
|  |     model::{log::Log, user::AllowedToUpdateBoathouse}, | ||||||
|  |     tera::board::boathouse::FormBoathouseToAdd, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | use super::boat::Boat; | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct BoathousePlace { | ||||||
|  |     boat: Boat, | ||||||
|  |     boathouse_id: i64, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct BoathouseRack { | ||||||
|  |     boats: [Option<BoathousePlace>; 12], | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl BoathouseRack { | ||||||
|  |     fn new() -> Self { | ||||||
|  |         let boats = [ | ||||||
|  |             None, None, None, None, None, None, None, None, None, None, None, None, | ||||||
|  |         ]; | ||||||
|  |         Self { boats } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async fn add(&mut self, db: &SqlitePool, boathouse: Boathouse) { | ||||||
|  |         self.boats[boathouse.level as usize] = Some(BoathousePlace { | ||||||
|  |             boat: Boat::find_by_id(db, boathouse.boat_id as i32) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(), | ||||||
|  |             boathouse_id: boathouse.id, | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct BoathouseSide { | ||||||
|  |     mountain: BoathouseRack, | ||||||
|  |     water: BoathouseRack, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl BoathouseSide { | ||||||
|  |     fn new() -> Self { | ||||||
|  |         Self { | ||||||
|  |             mountain: BoathouseRack::new(), | ||||||
|  |             water: BoathouseRack::new(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async fn add(&mut self, db: &SqlitePool, boathouse: Boathouse) { | ||||||
|  |         match boathouse.side.as_str() { | ||||||
|  |             "mountain" => self.mountain.add(db, boathouse).await, | ||||||
|  |             "water" => self.water.add(db, boathouse).await, | ||||||
|  |             _ => panic!("db constraint failed"), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, Serialize, Deserialize)] | ||||||
|  | pub struct BoathouseAisles { | ||||||
|  |     mountain: BoathouseSide, | ||||||
|  |     middle: BoathouseSide, | ||||||
|  |     water: BoathouseSide, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl BoathouseAisles { | ||||||
|  |     fn new() -> Self { | ||||||
|  |         Self { | ||||||
|  |             mountain: BoathouseSide::new(), | ||||||
|  |             middle: BoathouseSide::new(), | ||||||
|  |             water: BoathouseSide::new(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async fn add(&mut self, db: &SqlitePool, boathouse: Boathouse) { | ||||||
|  |         match boathouse.aisle.as_str() { | ||||||
|  |             "water" => self.water.add(db, boathouse).await, | ||||||
|  |             "middle" => self.middle.add(db, boathouse).await, | ||||||
|  |             "mountain" => self.mountain.add(db, boathouse).await, | ||||||
|  |             _ => panic!("db constraint failed"), | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn from(db: &SqlitePool, boathouses: Vec<Boathouse>) -> Self { | ||||||
|  |         let mut ret = BoathouseAisles::new(); | ||||||
|  |  | ||||||
|  |         for boathouse in boathouses { | ||||||
|  |             ret.add(db, boathouse).await; | ||||||
|  |         } | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(FromRow, Debug, Serialize, Deserialize)] | ||||||
|  | pub struct Boathouse { | ||||||
|  |     pub id: i64, | ||||||
|  |     pub boat_id: i64, | ||||||
|  |     pub aisle: String, | ||||||
|  |     pub side: String, | ||||||
|  |     pub level: i64, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Boathouse { | ||||||
|  |     pub async fn get(db: &SqlitePool) -> BoathouseAisles { | ||||||
|  |         let boathouses = sqlx::query_as!( | ||||||
|  |             Boathouse, | ||||||
|  |             "SELECT id, boat_id, aisle, side, level FROM boathouse" | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); //TODO: fixme | ||||||
|  |  | ||||||
|  |         BoathouseAisles::from(db, boathouses).await | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn create( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         changed_by: &AllowedToUpdateBoathouse, | ||||||
|  |         data: FormBoathouseToAdd, | ||||||
|  |     ) -> Result<(), String> { | ||||||
|  |         sqlx::query!( | ||||||
|  |             "INSERT INTO boathouse(boat_id, aisle, side, level) VALUES (?,?,?,?)", | ||||||
|  |             data.boat_id, | ||||||
|  |             data.aisle, | ||||||
|  |             data.side, | ||||||
|  |             data.level | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| e.to_string())?; | ||||||
|  |  | ||||||
|  |         let boat = Boat::find_by_id(db, data.boat_id).await.unwrap(); | ||||||
|  |         Log::create( | ||||||
|  |             db, | ||||||
|  |             format!( | ||||||
|  |                 "{changed_by} hat das Boot {boat} auf den Gang {}, Seite {}, und Höhe {} 'gelegt'.", | ||||||
|  |                 data.aisle, data.side, data.level | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |         .await; | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> { | ||||||
|  |         sqlx::query_as!(Self, "SELECT * FROM boathouse WHERE id like ?", id) | ||||||
|  |             .fetch_one(db) | ||||||
|  |             .await | ||||||
|  |             .ok() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn delete(&self, db: &SqlitePool, changed_by: &AllowedToUpdateBoathouse) { | ||||||
|  |         sqlx::query!("DELETE FROM boathouse WHERE id=?", self.id) | ||||||
|  |             .execute(db) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); //Okay, because we can only create a Boat of a valid id | ||||||
|  |  | ||||||
|  |         let boat = Boat::find_by_id(db, self.boat_id as i32).await.unwrap(); | ||||||
|  |         Log::create( | ||||||
|  |             db, | ||||||
|  |             format!( | ||||||
|  |                 "{changed_by} hat das Boot {boat} von Gang {}, Seite {}, und Höhe {} gelöscht.", | ||||||
|  |                 self.aisle, self.side, self.level | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |         .await; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										272
									
								
								src/model/boatreservation.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,272 @@ | |||||||
|  | use std::collections::HashMap; | ||||||
|  |  | ||||||
|  | use crate::model::{boat::Boat, user::User}; | ||||||
|  | use crate::tera::boatreservation::ReservationEditForm; | ||||||
|  | use chrono::NaiveDate; | ||||||
|  | use chrono::NaiveDateTime; | ||||||
|  | use rocket::serde::{Deserialize, Serialize}; | ||||||
|  | use sqlx::{FromRow, SqlitePool}; | ||||||
|  |  | ||||||
|  | use super::log::Log; | ||||||
|  | use super::notification::Notification; | ||||||
|  | use super::role::Role; | ||||||
|  |  | ||||||
|  | #[derive(FromRow, Debug, Serialize, Deserialize)] | ||||||
|  | pub struct BoatReservation { | ||||||
|  |     pub id: i64, | ||||||
|  |     pub boat_id: i64, | ||||||
|  |     pub start_date: NaiveDate, | ||||||
|  |     pub end_date: NaiveDate, | ||||||
|  |     pub time_desc: String, | ||||||
|  |     pub usage: String, | ||||||
|  |     pub user_id_applicant: i64, | ||||||
|  |     pub user_id_confirmation: Option<i64>, | ||||||
|  |     pub created_at: NaiveDateTime, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(FromRow, Debug, Serialize, Deserialize)] | ||||||
|  | pub struct BoatReservationWithDetails { | ||||||
|  |     #[serde(flatten)] | ||||||
|  |     reservation: BoatReservation, | ||||||
|  |     boat: Boat, | ||||||
|  |     user_applicant: User, | ||||||
|  |     user_confirmation: Option<User>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug)] | ||||||
|  | pub struct BoatReservationToAdd<'r> { | ||||||
|  |     pub boat: &'r Boat, | ||||||
|  |     pub start_date: NaiveDate, | ||||||
|  |     pub end_date: NaiveDate, | ||||||
|  |     pub time_desc: &'r str, | ||||||
|  |     pub usage: &'r str, | ||||||
|  |     pub user_applicant: &'r User, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl BoatReservation { | ||||||
|  |     pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             "SELECT id, boat_id, start_date, end_date, time_desc, usage, user_id_applicant, user_id_confirmation, created_at | ||||||
|  |              FROM boat_reservation | ||||||
|  |              WHERE id like ?", | ||||||
|  |             id | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db) | ||||||
|  |         .await | ||||||
|  |         .ok() | ||||||
|  |     } | ||||||
|  |     pub async fn for_day(db: &SqlitePool, day: NaiveDate) -> Vec<BoatReservationWithDetails> { | ||||||
|  |         let boatreservations = sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  | SELECT id, boat_id, start_date, end_date, time_desc, usage, user_id_applicant, user_id_confirmation, created_at | ||||||
|  | FROM boat_reservation | ||||||
|  | WHERE end_date >= ? AND start_date <= ?  | ||||||
|  |         ", day, day | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); //TODO: fixme | ||||||
|  |  | ||||||
|  |         let mut res = Vec::new(); | ||||||
|  |         for reservation in boatreservations { | ||||||
|  |             let user_confirmation = match reservation.user_id_confirmation { | ||||||
|  |                 Some(id) => { | ||||||
|  |                     let user = User::find_by_id(db, id as i32).await; | ||||||
|  |                     Some(user.unwrap()) | ||||||
|  |                 } | ||||||
|  |                 None => None, | ||||||
|  |             }; | ||||||
|  |             let user_applicant = User::find_by_id(db, reservation.user_id_applicant as i32) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|  |             let boat = Boat::find_by_id(db, reservation.boat_id as i32) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|  |  | ||||||
|  |             res.push(BoatReservationWithDetails { | ||||||
|  |                 reservation, | ||||||
|  |                 boat, | ||||||
|  |                 user_applicant, | ||||||
|  |                 user_confirmation, | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |         res | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn all_future(db: &SqlitePool) -> Vec<BoatReservationWithDetails> { | ||||||
|  |         let boatreservations = sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  | SELECT id, boat_id, start_date, end_date, time_desc, usage, user_id_applicant, user_id_confirmation, created_at | ||||||
|  | FROM boat_reservation | ||||||
|  | WHERE end_date >= CURRENT_DATE ORDER BY end_date  | ||||||
|  |         " | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); //TODO: fixme | ||||||
|  |  | ||||||
|  |         let mut res = Vec::new(); | ||||||
|  |         for reservation in boatreservations { | ||||||
|  |             let user_confirmation = match reservation.user_id_confirmation { | ||||||
|  |                 Some(id) => { | ||||||
|  |                     let user = User::find_by_id(db, id as i32).await; | ||||||
|  |                     Some(user.unwrap()) | ||||||
|  |                 } | ||||||
|  |                 None => None, | ||||||
|  |             }; | ||||||
|  |             let user_applicant = User::find_by_id(db, reservation.user_id_applicant as i32) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|  |             let boat = Boat::find_by_id(db, reservation.boat_id as i32) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|  |  | ||||||
|  |             res.push(BoatReservationWithDetails { | ||||||
|  |                 reservation, | ||||||
|  |                 boat, | ||||||
|  |                 user_applicant, | ||||||
|  |                 user_confirmation, | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |         res | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub fn with_groups( | ||||||
|  |         reservations: Vec<BoatReservationWithDetails>, | ||||||
|  |     ) -> HashMap<String, Vec<BoatReservationWithDetails>> { | ||||||
|  |         let mut grouped_reservations: HashMap<String, Vec<BoatReservationWithDetails>> = | ||||||
|  |             HashMap::new(); | ||||||
|  |  | ||||||
|  |         for reservation in reservations { | ||||||
|  |             let key = format!( | ||||||
|  |                 "{}-{}-{}-{}-{}", | ||||||
|  |                 reservation.reservation.start_date, | ||||||
|  |                 reservation.reservation.end_date, | ||||||
|  |                 reservation.reservation.time_desc, | ||||||
|  |                 reservation.reservation.usage, | ||||||
|  |                 reservation.user_applicant.name | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             grouped_reservations | ||||||
|  |                 .entry(key) | ||||||
|  |                 .or_default() | ||||||
|  |                 .push(reservation); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         grouped_reservations | ||||||
|  |     } | ||||||
|  |     pub async fn all_future_with_groups( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |     ) -> HashMap<String, Vec<BoatReservationWithDetails>> { | ||||||
|  |         let reservations = Self::all_future(db).await; | ||||||
|  |         Self::with_groups(reservations) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn create( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         boatreservation: BoatReservationToAdd<'_>, | ||||||
|  |     ) -> Result<(), String> { | ||||||
|  |         if Self::boat_reserved_between_dates( | ||||||
|  |             db, | ||||||
|  |             boatreservation.boat, | ||||||
|  |             &boatreservation.start_date, | ||||||
|  |             &boatreservation.end_date, | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         { | ||||||
|  |             return Err("Boot in diesem Zeitraum bereits reserviert.".into()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Log::create(db, format!("New boat reservation: {boatreservation:?}")).await; | ||||||
|  |  | ||||||
|  |         sqlx::query!( | ||||||
|  |             "INSERT INTO boat_reservation(boat_id, start_date, end_date, time_desc, usage, user_id_applicant) VALUES (?,?,?,?,?,?)", | ||||||
|  |             boatreservation.boat.id, | ||||||
|  |             boatreservation.start_date, | ||||||
|  |             boatreservation.end_date, | ||||||
|  |             boatreservation.time_desc, | ||||||
|  |             boatreservation.usage, | ||||||
|  |             boatreservation.user_applicant.id, | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| e.to_string())?; | ||||||
|  |  | ||||||
|  |         let board = | ||||||
|  |             User::all_with_role(db, &Role::find_by_name(db, "Vorstand").await.unwrap()).await; | ||||||
|  |         for user in board { | ||||||
|  |             let date = if boatreservation.start_date == boatreservation.end_date { | ||||||
|  |                 format!("am {}", boatreservation.start_date) | ||||||
|  |             } else { | ||||||
|  |                 format!( | ||||||
|  |                     "von {} bis {}", | ||||||
|  |                     boatreservation.start_date, boatreservation.end_date | ||||||
|  |                 ) | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             Notification::create( | ||||||
|  |                     db, | ||||||
|  |                     &user, | ||||||
|  |                     &format!( | ||||||
|  |                         "{} hat eine neue Bootsreservierung für Boot '{}' {} angelegt. Zeit: {}; Zweck: {}", | ||||||
|  |                         boatreservation.user_applicant.name, | ||||||
|  |                        boatreservation.boat.name, | ||||||
|  |                        date, | ||||||
|  |                         boatreservation.time_desc, | ||||||
|  |                         boatreservation.usage | ||||||
|  |                     ), | ||||||
|  |                     "Neue Bootsreservierung", | ||||||
|  |                     None,None | ||||||
|  |                 ) | ||||||
|  |                 .await; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn boat_reserved_between_dates( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         boat: &Boat, | ||||||
|  |         start_date: &NaiveDate, | ||||||
|  |         end_date: &NaiveDate, | ||||||
|  |     ) -> bool { | ||||||
|  |         sqlx::query!( | ||||||
|  |             "SELECT COUNT(*) AS reservation_count | ||||||
|  | FROM boat_reservation | ||||||
|  | WHERE boat_id = ?  | ||||||
|  | AND start_date <= ? AND end_date >= ?;", | ||||||
|  |             boat.id, | ||||||
|  |             end_date, | ||||||
|  |             start_date | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap() | ||||||
|  |         .reservation_count | ||||||
|  |             > 0 | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn update(&self, db: &SqlitePool, data: ReservationEditForm) { | ||||||
|  |         let time_desc = data.time_desc.trim(); | ||||||
|  |         let usage = data.usage.trim(); | ||||||
|  |         sqlx::query!( | ||||||
|  |             "UPDATE boat_reservation SET time_desc = ?, usage = ? where id = ?", | ||||||
|  |             time_desc, | ||||||
|  |             usage, | ||||||
|  |             self.id | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); //Okay, because we can only create a User of a valid id | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn delete(&self, db: &SqlitePool) { | ||||||
|  |         sqlx::query!("DELETE FROM boat_reservation WHERE id=?", self.id) | ||||||
|  |             .execute(db) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); //Okay, because we can only create a Boat of a valid id | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										33
									
								
								src/model/distance.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,33 @@ | |||||||
|  | use serde::Serialize; | ||||||
|  | use sqlx::{FromRow, SqlitePool}; | ||||||
|  |  | ||||||
|  | #[derive(FromRow, Serialize, Clone, Debug)] | ||||||
|  | pub struct Distance { | ||||||
|  |     pub id: i64, | ||||||
|  |     pub destination: String, | ||||||
|  |     pub distance_in_km: i64, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Distance { | ||||||
|  |     /// Return all default `distance`s, ordered by usage in logbook entries | ||||||
|  |     pub async fn all(db: &SqlitePool) -> Vec<Self> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             "SELECT  | ||||||
|  |     d.id, | ||||||
|  |     d.destination, | ||||||
|  |     d.distance_in_km | ||||||
|  | FROM  | ||||||
|  |     distance d | ||||||
|  | LEFT JOIN  | ||||||
|  |     logbook l ON d.destination = l.destination AND d.distance_in_km = l.distance_in_km | ||||||
|  | GROUP BY  | ||||||
|  |     d.id, d.destination, d.distance_in_km | ||||||
|  | ORDER BY  | ||||||
|  |     COUNT(l.id) DESC, d.destination ASC;" | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap() | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,11 +1,13 @@ | |||||||
|  | use std::ops::DerefMut; | ||||||
|  |  | ||||||
| use serde::Serialize; | use serde::Serialize; | ||||||
| use sqlx::{sqlite::SqliteQueryResult, FromRow, SqlitePool}; | use sqlx::{FromRow, Sqlite, SqlitePool, Transaction, sqlite::SqliteQueryResult}; | ||||||
|  |  | ||||||
| use super::user::User; | use super::user::User; | ||||||
|  |  | ||||||
| #[derive(FromRow, Serialize, Clone)] | #[derive(FromRow, Serialize, Clone)] | ||||||
| pub struct Family { | pub struct Family { | ||||||
|     id: i64, |     pub(crate) id: i64, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Serialize, Clone)] | #[derive(Serialize, Clone)] | ||||||
| @@ -22,7 +24,16 @@ impl Family { | |||||||
|             .unwrap() |             .unwrap() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn new(db: &SqlitePool) -> i64 { |     pub async fn insert_tx(db: &mut Transaction<'_, Sqlite>) -> i64 { | ||||||
|  |         let result: SqliteQueryResult = sqlx::query("INSERT INTO family DEFAULT VALUES") | ||||||
|  |             .execute(db.deref_mut()) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|  |  | ||||||
|  |         result.last_insert_rowid() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn insert(db: &SqlitePool) -> i64 { | ||||||
|         let result: SqliteQueryResult = sqlx::query("INSERT INTO family DEFAULT VALUES") |         let result: SqliteQueryResult = sqlx::query("INSERT INTO family DEFAULT VALUES") | ||||||
|             .execute(db) |             .execute(db) | ||||||
|             .await |             .await | ||||||
| @@ -63,7 +74,7 @@ GROUP BY family.id;" | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn amount_family_members(&self, db: &SqlitePool) -> i32 { |     pub async fn amount_family_members(&self, db: &SqlitePool) -> i64 { | ||||||
|         sqlx::query!( |         sqlx::query!( | ||||||
|             "SELECT COUNT(*) as count FROM user WHERE family_id = ?", |             "SELECT COUNT(*) as count FROM user WHERE family_id = ?", | ||||||
|             self.id |             self.id | ||||||
| @@ -75,9 +86,23 @@ GROUP BY family.id;" | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn members(&self, db: &SqlitePool) -> Vec<User> { |     pub async fn members(&self, db: &SqlitePool) -> Vec<User> { | ||||||
|         sqlx::query_as!(User, "SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id FROM user WHERE family_id = ?", self.id) |         sqlx::query_as!(User, "SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, phone, address, family_id, user_token FROM user WHERE family_id = ?", self.id) | ||||||
|             .fetch_all(db) |             .fetch_all(db) | ||||||
|             .await |             .await | ||||||
|             .unwrap() |             .unwrap() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     pub async fn clean_families_without_members(db: &SqlitePool) { | ||||||
|  |         sqlx::query( | ||||||
|  |             "DELETE FROM family | ||||||
|  | WHERE id NOT IN ( | ||||||
|  |     SELECT DISTINCT family_id  | ||||||
|  |     FROM user  | ||||||
|  |     WHERE family_id IS NOT NULL | ||||||
|  | );", | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use sqlx::{FromRow, SqlitePool}; | use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; | ||||||
|  | use std::ops::DerefMut; | ||||||
|  |  | ||||||
| #[derive(FromRow, Debug, Serialize, Deserialize)] | #[derive(FromRow, Debug, Serialize, Deserialize)] | ||||||
| pub struct Location { | pub struct Location { | ||||||
| @@ -37,6 +38,20 @@ impl Location { | |||||||
|         .await |         .await | ||||||
|         .ok() |         .ok() | ||||||
|     } |     } | ||||||
|  |     pub async fn find_by_name_tx(db: &mut Transaction<'_, Sqlite>, name: String) -> Option<Self> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  |     SELECT id, name | ||||||
|  |     FROM location | ||||||
|  |     WHERE name=? | ||||||
|  |             ", | ||||||
|  |             name | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db.deref_mut()) | ||||||
|  |         .await | ||||||
|  |         .ok() | ||||||
|  |     } | ||||||
|  |  | ||||||
|     pub async fn all(db: &SqlitePool) -> Vec<Self> { |     pub async fn all(db: &SqlitePool) -> Vec<Self> { | ||||||
|         sqlx::query_as!(Self, "SELECT id, name FROM location") |         sqlx::query_as!(Self, "SELECT id, name FROM location") | ||||||
|   | |||||||
| @@ -1,74 +1,16 @@ | |||||||
| use std::ops::DerefMut; | use super::activity::ActivityBuilder; | ||||||
|  | use sqlx::{Sqlite, SqlitePool, Transaction}; | ||||||
|  |  | ||||||
| use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc}; | pub struct Log {} | ||||||
| use serde::{Deserialize, Serialize}; |  | ||||||
| use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; |  | ||||||
|  |  | ||||||
| #[derive(FromRow, Debug, Serialize, Deserialize)] |  | ||||||
| pub struct Log { |  | ||||||
|     pub msg: String, |  | ||||||
|     pub created_at: NaiveDateTime, |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | // TODO: remove and convert to proper acitvities | ||||||
| impl Log { | impl Log { | ||||||
|     pub async fn create(db: &SqlitePool, msg: String) -> bool { |     pub async fn create(db: &SqlitePool, msg: String) -> bool { | ||||||
|         sqlx::query!("INSERT INTO log(msg) VALUES (?)", msg,) |         ActivityBuilder::new(&msg).save(db).await; | ||||||
|             .execute(db) |         true | ||||||
|             .await |  | ||||||
|             .is_ok() |  | ||||||
|     } |     } | ||||||
|     pub async fn create_with_tx(db: &mut Transaction<'_, Sqlite>, msg: String) -> bool { |     pub async fn create_with_tx(db: &mut Transaction<'_, Sqlite>, msg: String) -> bool { | ||||||
|         sqlx::query!("INSERT INTO log(msg) VALUES (?)", msg,) |         ActivityBuilder::new(&msg).save_tx(db).await; | ||||||
|             .execute(db.deref_mut()) |         true | ||||||
|             .await |  | ||||||
|             .is_ok() |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     async fn last(db: &SqlitePool) -> Vec<Log> { |  | ||||||
|         sqlx::query_as!( |  | ||||||
|             Log, |  | ||||||
|             " |  | ||||||
| SELECT msg, created_at |  | ||||||
| FROM log  |  | ||||||
| ORDER BY id DESC |  | ||||||
| LIMIT 1000 |  | ||||||
|     " |  | ||||||
|         ) |  | ||||||
|         .fetch_all(db) |  | ||||||
|         .await |  | ||||||
|         .unwrap() |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub async fn generate_feed(db: &SqlitePool) -> String { |  | ||||||
|         let mut ret = String::from( |  | ||||||
|             r#"<?xml version="1.0" encoding="utf-8"?> |  | ||||||
| <rss version="2.0"> |  | ||||||
| <channel> |  | ||||||
| <title>Ruder App Admin Feed</title> |  | ||||||
| <link>app.rudernlinz.at</link> |  | ||||||
| <description>An RSS feed with activities from app.rudernlinz.at</description>"#, |  | ||||||
|         ); |  | ||||||
|         for log in Self::last(db).await { |  | ||||||
|             let utc_time: DateTime<Utc> = Utc::from_utc_datetime(&Utc, &log.created_at); |  | ||||||
|             let local_time = utc_time.with_timezone(&Local); |  | ||||||
|             ret.push_str("<item><title>"); |  | ||||||
|             ret.push_str(&format!("({}) {}", local_time, log.msg)); |  | ||||||
|             ret.push_str("</title></item>"); |  | ||||||
|         } |  | ||||||
|         ret.push_str("</channel>"); |  | ||||||
|         ret.push_str("</rss>"); |  | ||||||
|         ret.replace('\n', "") |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub async fn show(db: &SqlitePool) -> String { |  | ||||||
|         let mut ret = String::new(); |  | ||||||
|  |  | ||||||
|         for log in Self::last(db).await { |  | ||||||
|             let utc_time: DateTime<Utc> = Utc::from_utc_datetime(&Utc, &log.created_at); |  | ||||||
|             let local_time = utc_time.with_timezone(&Local); |  | ||||||
|             ret.push_str(&format!("- {} - {}\n", local_time, log.msg)); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         ret |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,13 +1,22 @@ | |||||||
| use std::ops::DerefMut; | use std::{fmt::Display, ops::DerefMut}; | ||||||
|  |  | ||||||
| use chrono::{Datelike, NaiveDateTime, Utc}; | use chrono::{Datelike, Duration, Local, NaiveDateTime}; | ||||||
| use rocket::FromForm; | use rocket::FromForm; | ||||||
| use serde::Serialize; | use serde::{Deserialize, Serialize}; | ||||||
| use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; | use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; | ||||||
|  |  | ||||||
| use super::{boat::Boat, log::Log, rower::Rower, user::User}; | use super::{ | ||||||
|  |     activity::{ActivityBuilder, ReasonLogbook}, | ||||||
|  |     boat::Boat, | ||||||
|  |     log::Log, | ||||||
|  |     notification::Notification, | ||||||
|  |     role::Role, | ||||||
|  |     rower::Rower, | ||||||
|  |     user::{User, VorstandUser}, | ||||||
|  | }; | ||||||
|  | use crate::model::user::VecUser; | ||||||
|  |  | ||||||
| #[derive(FromRow, Serialize, Clone, Debug)] | #[derive(FromRow, Serialize, Deserialize, Clone, Debug)] | ||||||
| pub struct Logbook { | pub struct Logbook { | ||||||
|     pub id: i64, |     pub id: i64, | ||||||
|     pub boat_id: i64, |     pub boat_id: i64, | ||||||
| @@ -29,6 +38,11 @@ impl PartialEq for Logbook { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | pub(crate) enum Filter { | ||||||
|  |     SingleDayOnly, | ||||||
|  |     MultiDayOnly, | ||||||
|  | } | ||||||
|  |  | ||||||
| #[derive(FromForm, Debug, Clone)] | #[derive(FromForm, Debug, Clone)] | ||||||
| pub struct LogToAdd { | pub struct LogToAdd { | ||||||
|     pub boat_id: i32, |     pub boat_id: i32, | ||||||
| @@ -58,6 +72,22 @@ pub struct LogToFinalize { | |||||||
|     pub rowers: Vec<i64>, |     pub rowers: Vec<i64>, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #[derive(FromForm, Debug, Clone)] | ||||||
|  | pub struct LogToUpdate { | ||||||
|  |     pub id: i64, | ||||||
|  |     pub boat_id: i64, | ||||||
|  |     pub shipmaster: i64, | ||||||
|  |     pub steering_person: i64, | ||||||
|  |     pub shipmaster_only_steering: bool, | ||||||
|  |     pub departure: String, | ||||||
|  |     pub arrival: Option<String>, | ||||||
|  |     pub destination: Option<String>, | ||||||
|  |     pub distance_in_km: Option<i64>, | ||||||
|  |     pub comments: Option<String>, | ||||||
|  |     pub logtype: Option<i64>, | ||||||
|  |     pub rowers: Vec<i64>, | ||||||
|  | } | ||||||
|  |  | ||||||
| impl TryFrom<LogToAdd> for LogToFinalize { | impl TryFrom<LogToAdd> for LogToFinalize { | ||||||
|     type Error = String; |     type Error = String; | ||||||
|  |  | ||||||
| @@ -82,7 +112,7 @@ impl TryFrom<LogToAdd> for LogToFinalize { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Serialize, Debug)] | #[derive(Serialize, Deserialize, Debug)] | ||||||
| pub struct LogbookWithBoatAndRowers { | pub struct LogbookWithBoatAndRowers { | ||||||
|     #[serde(flatten)] |     #[serde(flatten)] | ||||||
|     pub logbook: Logbook, |     pub logbook: Logbook, | ||||||
| @@ -92,6 +122,77 @@ pub struct LogbookWithBoatAndRowers { | |||||||
|     pub rowers: Vec<User>, |     pub rowers: Vec<User>, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | impl Display for LogbookWithBoatAndRowers { | ||||||
|  |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  |         if let Some(arrival) = self.logbook.arrival { | ||||||
|  |             let departure_date = format!("{}", self.logbook.departure.format("%Y-%m-%d")); | ||||||
|  |             let arrival_date = format!("{}", arrival.format("%Y-%m-%d")); | ||||||
|  |             if departure_date == arrival_date { | ||||||
|  |                 write!( | ||||||
|  |                     f, | ||||||
|  |                     "Datum: {}: Start: {}, Ende: {}; ", | ||||||
|  |                     &self.logbook.departure.format("%d. %m. %Y"), | ||||||
|  |                     &self.logbook.departure.format("%H:%M"), | ||||||
|  |                     &arrival.format("%H:%M") | ||||||
|  |                 )?; | ||||||
|  |             } else { | ||||||
|  |                 write!( | ||||||
|  |                     f, | ||||||
|  |                     "{} - {}; ", | ||||||
|  |                     &self.logbook.departure.format("%d. %m. %Y"), | ||||||
|  |                     &arrival.format("%d. %m. %Y"), | ||||||
|  |                 )?; | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             write!( | ||||||
|  |                 f, | ||||||
|  |                 "Start: {}", | ||||||
|  |                 &self.logbook.departure.format("%d. %m. %Y %H:%M") | ||||||
|  |             )?; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if let Some(destination) = &self.logbook.destination { | ||||||
|  |             write!(f, "Ziel: {destination}; ")?; | ||||||
|  |         } | ||||||
|  |         write!(f, "Boot: {}; ", self.boat)?; | ||||||
|  |         if let Some(distance) = self.logbook.distance_in_km { | ||||||
|  |             write!(f, "Distanz: {distance} km; ")?; | ||||||
|  |         } | ||||||
|  |         write!(f, "Schiffsführer: {}; ", self.shipmaster_user)?; | ||||||
|  |         write!(f, "Steuerperson: {}; ", self.steering_user)?; | ||||||
|  |         write!(f, "Rudernde: {}; ", VecUser(&self.rowers))?; | ||||||
|  |         if let Some(comments) = &self.logbook.comments { | ||||||
|  |             if !comments.trim().is_empty() { | ||||||
|  |                 write!(f, "Kommentar: {comments}; ")?; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl LogbookWithBoatAndRowers { | ||||||
|  |     pub(crate) async fn from(db: &SqlitePool, log: Logbook) -> Self { | ||||||
|  |         let mut tx = db.begin().await.unwrap(); | ||||||
|  |         let ret = Self::from_tx(&mut tx, log).await; | ||||||
|  |         tx.commit().await.unwrap(); | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) async fn from_tx(db: &mut Transaction<'_, Sqlite>, log: Logbook) -> Self { | ||||||
|  |         Self { | ||||||
|  |             rowers: Rower::for_log_tx(db, &log).await, | ||||||
|  |             boat: Boat::find_by_id_tx(db, log.boat_id as i32).await.unwrap(), | ||||||
|  |             shipmaster_user: User::find_by_id_tx(db, log.shipmaster as i32) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(), | ||||||
|  |             steering_user: User::find_by_id_tx(db, log.steering_person as i32) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(), | ||||||
|  |             logbook: log, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| #[derive(Debug, PartialEq)] | #[derive(Debug, PartialEq)] | ||||||
| pub enum LogbookUpdateError { | pub enum LogbookUpdateError { | ||||||
|     NotYourEntry, |     NotYourEntry, | ||||||
| @@ -102,6 +203,10 @@ pub enum LogbookUpdateError { | |||||||
|     SteeringPersonNotInRowers, |     SteeringPersonNotInRowers, | ||||||
|     UserNotAllowedToUseBoat, |     UserNotAllowedToUseBoat, | ||||||
|     OnlyAllowedToEndTripsEndingToday, |     OnlyAllowedToEndTripsEndingToday, | ||||||
|  |     TooFast(i64, i64), | ||||||
|  |     AlreadyFinalized, | ||||||
|  |     ExternalSteeringPersonMustSteerOrShipmaster, | ||||||
|  |     BoatAlreadyOnWater, | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Debug, PartialEq)] | #[derive(Debug, PartialEq)] | ||||||
| @@ -116,7 +221,7 @@ pub enum LogbookCreateError { | |||||||
|     BoatLocked, |     BoatLocked, | ||||||
|     BoatNotFound, |     BoatNotFound, | ||||||
|     TooManyRowers(usize, usize), |     TooManyRowers(usize, usize), | ||||||
|     RowerAlreadyOnWater(User), |     RowerAlreadyOnWater(Box<User>), | ||||||
|     RowerCreateError(i64, String), |     RowerCreateError(i64, String), | ||||||
|     ArrivalNotAfterDeparture, |     ArrivalNotAfterDeparture, | ||||||
|     SteeringPersonNotInRowers, |     SteeringPersonNotInRowers, | ||||||
| @@ -124,6 +229,10 @@ pub enum LogbookCreateError { | |||||||
|     NotYourEntry, |     NotYourEntry, | ||||||
|     ArrivalSetButNotRemainingTwo, |     ArrivalSetButNotRemainingTwo, | ||||||
|     OnlyAllowedToEndTripsEndingToday, |     OnlyAllowedToEndTripsEndingToday, | ||||||
|  |     CantChangeHandoperatableStatusForThisBoat, | ||||||
|  |     TooFast(i64, i64), | ||||||
|  |     AlreadyFinalized, | ||||||
|  |     ExternalSteeringPersonMustSteerOrShipmaster, | ||||||
| } | } | ||||||
|  |  | ||||||
| impl From<LogbookUpdateError> for LogbookCreateError { | impl From<LogbookUpdateError> for LogbookCreateError { | ||||||
| @@ -147,6 +256,12 @@ impl From<LogbookUpdateError> for LogbookCreateError { | |||||||
|             LogbookUpdateError::OnlyAllowedToEndTripsEndingToday => { |             LogbookUpdateError::OnlyAllowedToEndTripsEndingToday => { | ||||||
|                 LogbookCreateError::OnlyAllowedToEndTripsEndingToday |                 LogbookCreateError::OnlyAllowedToEndTripsEndingToday | ||||||
|             } |             } | ||||||
|  |             LogbookUpdateError::TooFast(km, min) => LogbookCreateError::TooFast(km, min), | ||||||
|  |             LogbookUpdateError::AlreadyFinalized => LogbookCreateError::AlreadyFinalized, | ||||||
|  |             LogbookUpdateError::ExternalSteeringPersonMustSteerOrShipmaster => { | ||||||
|  |                 LogbookCreateError::ExternalSteeringPersonMustSteerOrShipmaster | ||||||
|  |             } | ||||||
|  |             LogbookUpdateError::BoatAlreadyOnWater => LogbookCreateError::BoatAlreadyOnWater, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -166,7 +281,7 @@ impl Logbook { | |||||||
|         .await |         .await | ||||||
|         .ok() |         .ok() | ||||||
|     } |     } | ||||||
|     pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> { |     pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> { | ||||||
|         sqlx::query_as!( |         sqlx::query_as!( | ||||||
|             Self, |             Self, | ||||||
|             " |             " | ||||||
| @@ -213,27 +328,142 @@ ORDER BY departure DESC | |||||||
|  |  | ||||||
|         let mut ret = Vec::new(); |         let mut ret = Vec::new(); | ||||||
|         for log in logs { |         for log in logs { | ||||||
|             ret.push(LogbookWithBoatAndRowers { |             ret.push(LogbookWithBoatAndRowers::from(db, log).await); | ||||||
|                 rowers: Rower::for_log(db, &log).await, |  | ||||||
|                 boat: Boat::find_by_id(db, log.boat_id as i32).await.unwrap(), |  | ||||||
|                 shipmaster_user: User::find_by_id(db, log.shipmaster as i32).await.unwrap(), |  | ||||||
|                 steering_user: User::find_by_id(db, log.steering_person as i32) |  | ||||||
|                     .await |  | ||||||
|                     .unwrap(), |  | ||||||
|                 logbook: log, |  | ||||||
|             }); |  | ||||||
|         } |         } | ||||||
|         ret |         ret | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     pub async fn completed_with_user( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         user: &User, | ||||||
|  |     ) -> Vec<LogbookWithBoatAndRowers> { | ||||||
|  |         let mut tx = db.begin().await.unwrap(); | ||||||
|  |         let ret = Self::completed_with_user_tx(&mut tx, user).await; | ||||||
|  |         tx.commit().await.unwrap(); | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn completed_with_user_tx( | ||||||
|  |         db: &mut Transaction<'_, Sqlite>, | ||||||
|  |         user: &User, | ||||||
|  |     ) -> Vec<LogbookWithBoatAndRowers> { | ||||||
|  |         let logs = sqlx::query_as( | ||||||
|  |                &format!(" | ||||||
|  |     SELECT id, boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype | ||||||
|  |     FROM logbook | ||||||
|  |     JOIN rower ON logbook.id = rower.logbook_id | ||||||
|  |     WHERE arrival is not null AND rower_id = {} | ||||||
|  |     ORDER BY arrival DESC | ||||||
|  |             ",  user.id) | ||||||
|  |             ) | ||||||
|  |             .fetch_all(db.deref_mut()) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); //TODO: fixme | ||||||
|  |  | ||||||
|  |         let mut ret = Vec::new(); | ||||||
|  |         for log in logs { | ||||||
|  |             ret.push(LogbookWithBoatAndRowers::from_tx(db, log).await); | ||||||
|  |         } | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn year_first_logbook_entry(db: &SqlitePool, user: &User) -> Option<i32> { | ||||||
|  |         let log: Option<Self> = sqlx::query_as( | ||||||
|  |                &format!(" | ||||||
|  |     SELECT id, boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype | ||||||
|  |     FROM logbook | ||||||
|  |     JOIN rower ON logbook.id = rower.logbook_id | ||||||
|  |     WHERE arrival is not null AND rower_id = {} | ||||||
|  |     ORDER BY arrival  | ||||||
|  |     LIMIT 1 | ||||||
|  |             ",  user.id) | ||||||
|  |             ) | ||||||
|  |             .fetch_optional(db) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); //TODO: fixme | ||||||
|  |  | ||||||
|  |         if let Some(log) = log { | ||||||
|  |             Some(log.arrival.unwrap().year()) | ||||||
|  |         } else { | ||||||
|  |             None | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn year_last_logbook_entry(db: &SqlitePool, user: &User) -> Option<i32> { | ||||||
|  |         let log: Option<Self> = sqlx::query_as( | ||||||
|  |                &format!(" | ||||||
|  |     SELECT id, boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype | ||||||
|  |     FROM logbook | ||||||
|  |     JOIN rower ON logbook.id = rower.logbook_id | ||||||
|  |     WHERE arrival is not null AND rower_id = {} | ||||||
|  |     ORDER BY arrival DESC | ||||||
|  |     LIMIT 1 | ||||||
|  |             ",  user.id) | ||||||
|  |             ) | ||||||
|  |             .fetch_optional(db) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); //TODO: fixme | ||||||
|  |  | ||||||
|  |         if let Some(log) = log { | ||||||
|  |             Some(log.arrival.unwrap().year()) | ||||||
|  |         } else { | ||||||
|  |             None | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) async fn completed_wanderfahrten_with_user_over_km_in_year_tx( | ||||||
|  |         db: &mut Transaction<'_, Sqlite>, | ||||||
|  |         user: &User, | ||||||
|  |         min_distance: i32, | ||||||
|  |         year: i32, | ||||||
|  |         filter: Filter, | ||||||
|  |     ) -> Vec<LogbookWithBoatAndRowers> { | ||||||
|  |         let logs: Vec<Logbook> = sqlx::query_as( | ||||||
|  |                &format!(" | ||||||
|  |     SELECT id, boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype | ||||||
|  |     FROM logbook | ||||||
|  |     JOIN rower ON logbook.id = rower.logbook_id | ||||||
|  |     WHERE arrival is not null AND rower_id = {} AND logtype = 1 AND distance_in_km >= {} AND arrival like '{}-%'  | ||||||
|  |     ORDER BY arrival DESC | ||||||
|  |             ",  user.id, min_distance, year) | ||||||
|  |             ) | ||||||
|  |             .fetch_all(db.deref_mut()) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); //TODO: fixme | ||||||
|  |  | ||||||
|  |         let mut ret = Vec::new(); | ||||||
|  |         for log in logs { | ||||||
|  |             let trip_days = log.arrival.unwrap() - log.departure; | ||||||
|  |             let trip_days = trip_days.num_days(); | ||||||
|  |             match filter { | ||||||
|  |                 Filter::SingleDayOnly => { | ||||||
|  |                     if trip_days == 0 { | ||||||
|  |                         ret.push(LogbookWithBoatAndRowers::from_tx(db, log).await); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 Filter::MultiDayOnly => { | ||||||
|  |                     if trip_days > 0 { | ||||||
|  |                         ret.push(LogbookWithBoatAndRowers::from_tx(db, log).await); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  |  | ||||||
|     pub async fn completed(db: &SqlitePool) -> Vec<LogbookWithBoatAndRowers> { |     pub async fn completed(db: &SqlitePool) -> Vec<LogbookWithBoatAndRowers> { | ||||||
|         let year = chrono::Utc::now().year(); |         let year = chrono::Local::now().year(); | ||||||
|  |         Self::completed_in_year(db, year).await | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn completed_in_year(db: &SqlitePool, year: i32) -> Vec<LogbookWithBoatAndRowers> { | ||||||
|         let logs = sqlx::query_as( |         let logs = sqlx::query_as( | ||||||
|                &format!(" |                &format!(" | ||||||
|     SELECT id, boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype |     SELECT id, boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype | ||||||
|     FROM logbook |     FROM logbook | ||||||
|     WHERE arrival is not null AND arrival LIKE '{}-%' |     WHERE arrival is not null AND arrival LIKE '{}-%' | ||||||
|     ORDER BY departure DESC |     ORDER BY arrival DESC | ||||||
|             ", year) |             ", year) | ||||||
|             ) |             ) | ||||||
|             .fetch_all(db) |             .fetch_all(db) | ||||||
| @@ -242,15 +472,7 @@ ORDER BY departure DESC | |||||||
|  |  | ||||||
|         let mut ret = Vec::new(); |         let mut ret = Vec::new(); | ||||||
|         for log in logs { |         for log in logs { | ||||||
|             ret.push(LogbookWithBoatAndRowers { |             ret.push(LogbookWithBoatAndRowers::from(db, log).await); | ||||||
|                 rowers: Rower::for_log(db, &log).await, |  | ||||||
|                 boat: Boat::find_by_id(db, log.boat_id as i32).await.unwrap(), |  | ||||||
|                 shipmaster_user: User::find_by_id(db, log.shipmaster as i32).await.unwrap(), |  | ||||||
|                 steering_user: User::find_by_id(db, log.steering_person as i32) |  | ||||||
|                     .await |  | ||||||
|                     .unwrap(), |  | ||||||
|                 logbook: log, |  | ||||||
|             }); |  | ||||||
|         } |         } | ||||||
|         ret |         ret | ||||||
|     } |     } | ||||||
| @@ -259,11 +481,18 @@ ORDER BY departure DESC | |||||||
|         db: &SqlitePool, |         db: &SqlitePool, | ||||||
|         mut log: LogToAdd, |         mut log: LogToAdd, | ||||||
|         created_by_user: &User, |         created_by_user: &User, | ||||||
|     ) -> Result<(), LogbookCreateError> { |         smtp_pw: &str, | ||||||
|  |     ) -> Result<String, LogbookCreateError> { | ||||||
|         let Some(boat) = Boat::find_by_id(db, log.boat_id).await else { |         let Some(boat) = Boat::find_by_id(db, log.boat_id).await else { | ||||||
|             return Err(LogbookCreateError::BoatNotFound); |             return Err(LogbookCreateError::BoatNotFound); | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|  |         if log.shipmaster_only_steering != boat.default_shipmaster_only_steering | ||||||
|  |             && !boat.convert_handoperated_possible | ||||||
|  |         { | ||||||
|  |             return Err(LogbookCreateError::CantChangeHandoperatableStatusForThisBoat); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         if boat.amount_seats == 1 && log.rowers.is_empty() { |         if boat.amount_seats == 1 && log.rowers.is_empty() { | ||||||
|             log.rowers = vec![created_by_user.id]; |             log.rowers = vec![created_by_user.id]; | ||||||
|         } |         } | ||||||
| @@ -274,8 +503,6 @@ ORDER BY departure DESC | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Ok(log_to_finalize) = TryInto::<LogToFinalize>::try_into(log.clone()) { |         if let Ok(log_to_finalize) = TryInto::<LogToFinalize>::try_into(log.clone()) { | ||||||
|             //TODO: fix clone() above |  | ||||||
|  |  | ||||||
|             if !boat.shipmaster_allowed(db, created_by_user).await { |             if !boat.shipmaster_allowed(db, created_by_user).await { | ||||||
|                 return Err(LogbookCreateError::UserNotAllowedToUseBoat); |                 return Err(LogbookCreateError::UserNotAllowedToUseBoat); | ||||||
|             } |             } | ||||||
| @@ -283,13 +510,12 @@ ORDER BY departure DESC | |||||||
|             let mut tx = db.begin().await.unwrap(); |             let mut tx = db.begin().await.unwrap(); | ||||||
|  |  | ||||||
|             let inserted_row = sqlx::query!( |             let inserted_row = sqlx::query!( | ||||||
|                 "INSERT INTO logbook(boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype) VALUES (?,?,?,?,?,?,?,?,?,?) RETURNING id", |                 "INSERT INTO logbook(boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, destination, distance_in_km, comments, logtype) VALUES (?,?,?,?,?,?,?,?,?) RETURNING id", | ||||||
|                 log.boat_id, |                 log.boat_id, | ||||||
|                 log.shipmaster, |                 log.shipmaster, | ||||||
|                 log.steering_person, |                 log.steering_person, | ||||||
|                 log.shipmaster_only_steering, |                 log.shipmaster_only_steering, | ||||||
|                 log.departure, |                 log.departure, | ||||||
|                 log.arrival, |  | ||||||
|                 log.destination, |                 log.destination, | ||||||
|                 log.distance_in_km, |                 log.distance_in_km, | ||||||
|                 log.comments, |                 log.comments, | ||||||
| @@ -303,12 +529,12 @@ ORDER BY departure DESC | |||||||
|                 .unwrap(); //ok |                 .unwrap(); //ok | ||||||
|  |  | ||||||
|             return match logbook |             return match logbook | ||||||
|                 .home_with_transaction(&mut tx, created_by_user, log_to_finalize) |                 .home_with_transaction(&mut tx, created_by_user, log_to_finalize, smtp_pw) | ||||||
|                 .await |                 .await | ||||||
|             { |             { | ||||||
|                 Ok(_) => { |                 Ok(_) => { | ||||||
|                     tx.commit().await.unwrap(); |                     tx.commit().await.unwrap(); | ||||||
|                     Ok(()) |                     Ok(String::new()) | ||||||
|                 } |                 } | ||||||
|                 Err(a) => Err(a.into()), |                 Err(a) => Err(a.into()), | ||||||
|             }; |             }; | ||||||
| @@ -343,7 +569,19 @@ ORDER BY departure DESC | |||||||
|             let user = User::find_by_id(db, *rower as i32).await.unwrap(); |             let user = User::find_by_id(db, *rower as i32).await.unwrap(); | ||||||
|  |  | ||||||
|             if user.on_water(db).await { |             if user.on_water(db).await { | ||||||
|                 return Err(LogbookCreateError::RowerAlreadyOnWater(user)); |                 return Err(LogbookCreateError::RowerAlreadyOnWater(Box::new(user))); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if user.name == "Externe Steuerperson" { | ||||||
|  |                 if let (Some(steering_id), Some(shipmaster_id)) = | ||||||
|  |                     (log.steering_person, log.shipmaster) | ||||||
|  |                 { | ||||||
|  |                     if steering_id != user.id && shipmaster_id != user.id { | ||||||
|  |                         return Err( | ||||||
|  |                             LogbookCreateError::ExternalSteeringPersonMustSteerOrShipmaster, | ||||||
|  |                         ); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -380,26 +618,40 @@ ORDER BY departure DESC | |||||||
|  |  | ||||||
|         tx.commit().await.unwrap(); |         tx.commit().await.unwrap(); | ||||||
|  |  | ||||||
|         Ok(()) |         let mut ret = String::new(); | ||||||
|  |         for rower in &log.rowers { | ||||||
|  |             let user = User::find_by_id(db, *rower as i32).await.unwrap(); | ||||||
|  |             if let Some(msg) = user.close_thousands_trip(db).await { | ||||||
|  |                 ret.push_str(&format!(" • {msg}")); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Ok(ret) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn distances(db: &SqlitePool) -> Vec<(String, i64)> { |     pub async fn update(&self, db: &SqlitePool, data: LogToUpdate, changed_by: &VorstandUser) { | ||||||
|         let result = sqlx::query!("SELECT destination, distance_in_km FROM logbook WHERE id IN (SELECT MIN(id) FROM logbook GROUP BY destination) AND destination IS NOT NULL AND distance_in_km IS NOT NULL;") |         sqlx::query!( | ||||||
|         .fetch_all(db) |                 "UPDATE logbook SET boat_id=?, shipmaster=?, steering_person=?, shipmaster_only_steering=?, departure=?, arrival=?, destination=?, distance_in_km=?, comments=?, logtype=? WHERE id=?", | ||||||
|         .await |                 data.boat_id, | ||||||
|         .unwrap(); |                 data.shipmaster, | ||||||
|  |                 data.steering_person, | ||||||
|  |                 data.shipmaster_only_steering, | ||||||
|  |                 data.departure, | ||||||
|  |                 data.arrival, | ||||||
|  |                 data.destination, | ||||||
|  |                 data.distance_in_km, | ||||||
|  |                 data.comments, | ||||||
|  |                 data.logtype, | ||||||
|  |                 self.id | ||||||
|  |             ) | ||||||
|  |             .execute(db) | ||||||
|  |             .await.unwrap(); | ||||||
|  |  | ||||||
|         result |         Log::create( | ||||||
|             .into_iter() |             db, | ||||||
|             .filter_map(|r| { |             format!("{changed_by} updated log entry={:?} to {:?}", self, data), | ||||||
|                 if let (Some(destination), Some(distance_in_km)) = (r.destination, r.distance_in_km) |         ) | ||||||
|                 { |         .await; | ||||||
|                     Some((destination, distance_in_km)) |  | ||||||
|                 } else { |  | ||||||
|                     None |  | ||||||
|                 } |  | ||||||
|             }) |  | ||||||
|             .collect() |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     async fn remove_rowers(&self, db: &mut Transaction<'_, Sqlite>) { |     async fn remove_rowers(&self, db: &mut Transaction<'_, Sqlite>) { | ||||||
| @@ -424,9 +676,11 @@ ORDER BY departure DESC | |||||||
|         db: &SqlitePool, |         db: &SqlitePool, | ||||||
|         user: &User, |         user: &User, | ||||||
|         log: LogToFinalize, |         log: LogToFinalize, | ||||||
|  |         smtp_pw: &str, | ||||||
|     ) -> Result<(), LogbookUpdateError> { |     ) -> Result<(), LogbookUpdateError> { | ||||||
|         let mut tx = db.begin().await.unwrap(); |         let mut tx = db.begin().await.unwrap(); | ||||||
|         self.home_with_transaction(&mut tx, user, log).await?; |         self.home_with_transaction(&mut tx, user, log, smtp_pw) | ||||||
|  |             .await?; | ||||||
|         tx.commit().await.unwrap(); |         tx.commit().await.unwrap(); | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
| @@ -436,12 +690,17 @@ ORDER BY departure DESC | |||||||
|         db: &mut Transaction<'_, Sqlite>, |         db: &mut Transaction<'_, Sqlite>, | ||||||
|         user: &User, |         user: &User, | ||||||
|         mut log: LogToFinalize, |         mut log: LogToFinalize, | ||||||
|  |         smtp_pw: &str, | ||||||
|     ) -> Result<(), LogbookUpdateError> { |     ) -> Result<(), LogbookUpdateError> { | ||||||
|         //TODO: extract common tests with `create()` |         //TODO: extract common tests with `create()` | ||||||
|         if user.id != self.shipmaster { |         if !user.has_role_tx(db, "Vorstand").await && user.id != self.shipmaster { | ||||||
|             return Err(LogbookUpdateError::NotYourEntry); |             return Err(LogbookUpdateError::NotYourEntry); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         if self.arrival.is_some() { | ||||||
|  |             return Err(LogbookUpdateError::AlreadyFinalized); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         let boat = Boat::find_by_id_tx(db, self.boat_id as i32).await.unwrap(); //ok |         let boat = Boat::find_by_id_tx(db, self.boat_id as i32).await.unwrap(); //ok | ||||||
|  |  | ||||||
|         if boat.amount_seats == 1 { |         if boat.amount_seats == 1 { | ||||||
| @@ -471,13 +730,33 @@ ORDER BY departure DESC | |||||||
|  |  | ||||||
|         let dep = NaiveDateTime::parse_from_str(&log.departure, "%Y-%m-%dT%H:%M").unwrap(); |         let dep = NaiveDateTime::parse_from_str(&log.departure, "%Y-%m-%dT%H:%M").unwrap(); | ||||||
|         let arr = NaiveDateTime::parse_from_str(&log.arrival, "%Y-%m-%dT%H:%M").unwrap(); |         let arr = NaiveDateTime::parse_from_str(&log.arrival, "%Y-%m-%dT%H:%M").unwrap(); | ||||||
|         if arr.timestamp() <= dep.timestamp() { |         if arr.and_utc().timestamp() < dep.and_utc().timestamp() { | ||||||
|             return Err(LogbookUpdateError::ArrivalNotAfterDeparture); |             return Err(LogbookUpdateError::ArrivalNotAfterDeparture); | ||||||
|         } |         } | ||||||
|         let today = Utc::now().date_naive(); |  | ||||||
|  |         if !boat.external && boat.on_water_between(db, dep, arr).await { | ||||||
|  |             return Err(LogbookUpdateError::BoatAlreadyOnWater); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let duration_in_mins = (arr.and_utc().timestamp() - dep.and_utc().timestamp()) / 60; | ||||||
|  |         // Not possible to row < 1 min / 500 m = < 2 min / km | ||||||
|  |         let possible_distance_km = duration_in_mins / 2; | ||||||
|  |         if log.distance_in_km > possible_distance_km { | ||||||
|  |             return Err(LogbookUpdateError::TooFast( | ||||||
|  |                 log.distance_in_km, | ||||||
|  |                 duration_in_mins, | ||||||
|  |             )); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let today = Local::now().date_naive(); | ||||||
|         let day_diff = today - arr.date(); |         let day_diff = today - arr.date(); | ||||||
|         let day_diff = day_diff.num_days(); |         let day_diff = day_diff.num_days(); | ||||||
|         if day_diff >= 7 && !user.has_role_tx(db, "admin").await { |         if day_diff >= 7 | ||||||
|  |             && !user.has_role_tx(db, "admin").await | ||||||
|  |             && !user | ||||||
|  |                 .has_role_tx(db, "allow-retroactive-logbookentries") | ||||||
|  |                 .await | ||||||
|  |         { | ||||||
|             return Err(LogbookUpdateError::OnlyAllowedToEndTripsEndingToday); |             return Err(LogbookUpdateError::OnlyAllowedToEndTripsEndingToday); | ||||||
|         } |         } | ||||||
|         if day_diff < 0 && !user.has_role_tx(db, "admin").await { |         if day_diff < 0 && !user.has_role_tx(db, "admin").await { | ||||||
| @@ -488,9 +767,40 @@ ORDER BY departure DESC | |||||||
|  |  | ||||||
|         self.remove_rowers(db).await; |         self.remove_rowers(db).await; | ||||||
|         for rower in &log.rowers { |         for rower in &log.rowers { | ||||||
|  |             let user = User::find_by_id_tx(db, *rower as i32).await.unwrap(); | ||||||
|  |             if user.name == "Externe Steuerperson" { | ||||||
|  |                 if let (Some(steering_id), Some(shipmaster_id)) = | ||||||
|  |                     (log.steering_person, log.shipmaster) | ||||||
|  |                 { | ||||||
|  |                     if steering_id != user.id && shipmaster_id != user.id { | ||||||
|  |                         return Err( | ||||||
|  |                             LogbookUpdateError::ExternalSteeringPersonMustSteerOrShipmaster, | ||||||
|  |                         ); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|             Rower::create(db, self.id, *rower) |             Rower::create(db, self.id, *rower) | ||||||
|                 .await |                 .await | ||||||
|                 .map_err(|e| LogbookUpdateError::RowerCreateError(*rower, e.to_string()))?; |                 .map_err(|e| LogbookUpdateError::RowerCreateError(*rower, e.to_string()))?; | ||||||
|  |  | ||||||
|  |             let user = User::find_by_id_tx(db, *rower as i32).await.unwrap(); | ||||||
|  |             Notification::create_with_tx( | ||||||
|  |                 db, | ||||||
|  |                 &user, | ||||||
|  |                 &format!( | ||||||
|  |                     "Ausfahrt am {}.{}.{}; Ziel: {} ({} km)", | ||||||
|  |                     dep.day(), | ||||||
|  |                     dep.month(), | ||||||
|  |                     dep.year(), | ||||||
|  |                     log.destination, | ||||||
|  |                     log.distance_in_km | ||||||
|  |                 ), | ||||||
|  |                 "Neuer Logbucheintrag", | ||||||
|  |                 None, | ||||||
|  |                 None, | ||||||
|  |             ) | ||||||
|  |             .await; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         sqlx::query!( |         sqlx::query!( | ||||||
| @@ -509,18 +819,94 @@ ORDER BY departure DESC | |||||||
|         .execute(db.deref_mut()) |         .execute(db.deref_mut()) | ||||||
|         .await.unwrap(); //TODO: fixme |         .await.unwrap(); //TODO: fixme | ||||||
|  |  | ||||||
|  |         let duration = arr - dep; | ||||||
|  |         if duration.num_days() > 0 { | ||||||
|  |             let vorstand = Role::find_by_name_tx(db, "Vorstand").await.unwrap(); | ||||||
|  |  | ||||||
|  |             Notification::create_for_role_tx( | ||||||
|  |                 db, | ||||||
|  |                 &vorstand, | ||||||
|  |                 &format!("'{}' hat eine mehrtägige Ausfahrt vom {} bis {} eingetragen ({} km; Ziel: {}; Anmerkungen: {}). Falls das nicht stimmen sollte, bitte nachhaken.",user.name,log.departure, log.arrival, log.distance_in_km, log.destination, log.comments.clone().unwrap_or("".into())), | ||||||
|  |                 "Mehrtägige Ausfahrt eingetragen", | ||||||
|  |                 None,None | ||||||
|  |             ).await; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if boat.external { | ||||||
|  |             let vorstand = Role::find_by_name_tx(db, "Vorstand").await.unwrap(); | ||||||
|  |  | ||||||
|  |             Notification::create_for_role_tx( | ||||||
|  |                 db, | ||||||
|  |                 &vorstand, | ||||||
|  |                 &format!("'{}' hat eine Ausfahrt mit externem Boot '{}' am {} eingetragen ({} km; Ziel: {}; Anmerkungen: {}). Falls das nicht stimmen sollte, bitte nachhaken.",user.name,boat.name,log.departure,log.distance_in_km, log.destination, log.comments.unwrap_or("".into())), | ||||||
|  |                 "Ausfahrt mit externem Boot eingetragen", | ||||||
|  |                 None,None, | ||||||
|  |             ).await; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         for rower in &log.rowers { | ||||||
|  |             let user = User::find_by_id_tx(db, *rower as i32).await.unwrap(); | ||||||
|  |             user.received_new_logentry(db, smtp_pw).await; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn delete(&self, db: &SqlitePool, user: &User) -> Result<(), LogbookDeleteError> { |     pub async fn delete(&self, db: &SqlitePool, user: &User) -> Result<(), LogbookDeleteError> { | ||||||
|         Log::create(db, format!("{user:?} deleted trip: {self:?}")).await; |         if self.arrival.is_none() { | ||||||
|  |             if user.has_role(db, "admin").await | ||||||
|  |                 || user.has_role(db, "Vorstand").await | ||||||
|  |                 || user.id == self.shipmaster | ||||||
|  |             { | ||||||
|  |                 Log::create(db, format!("{} deleted trip: {self:?}", user.name)).await; | ||||||
|  |                 let now = Local::now().naive_local(); | ||||||
|  |                 let difference = now - self.departure; | ||||||
|  |                 if difference > Duration::hours(1) { | ||||||
|  |                     let vorstand = Role::find_by_name(db, "Vorstand").await.unwrap(); | ||||||
|  |                     let logbook = LogbookWithBoatAndRowers::from(db, self.clone()).await; | ||||||
|  |  | ||||||
|         if user.has_role(db, "admin").await || user.id == self.shipmaster { |                     Notification::create_for_role( | ||||||
|             sqlx::query!("DELETE FROM logbook WHERE id=?", self.id) |                         db, | ||||||
|                 .execute(db) |                         &vorstand, | ||||||
|                 .await |                         &format!("{user} hat folgenden Logbuch-Eintrag jetzt gelöscht, welcher bereits vor über einer Stunde begonnen wurde: {logbook}"), | ||||||
|                 .unwrap(); //Okay, because we can only create a Logbook of a valid id |                         "Ungewöhnliches Verhalten", | ||||||
|             return Ok(()); |                         None, | ||||||
|  |                         None, | ||||||
|  |                     ) | ||||||
|  |                     .await; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 sqlx::query!("DELETE FROM logbook WHERE id=?", self.id) | ||||||
|  |                     .execute(db) | ||||||
|  |                     .await | ||||||
|  |                     .unwrap(); //Okay, because we can only create a Logbook of a valid id | ||||||
|  |                 return Ok(()); | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             // Only admins+Vorstand can delete completed logbook entries | ||||||
|  |             if user.has_role(db, "admin").await || user.has_role(db, "Vorstand").await { | ||||||
|  |                 let logbookdetails = LogbookWithBoatAndRowers::from(db, self.clone()).await; | ||||||
|  |                 ActivityBuilder::from(ReasonLogbook::BoardOrAdminDeleted(user, &logbookdetails)) | ||||||
|  |                     .save(db) | ||||||
|  |                     .await; | ||||||
|  |  | ||||||
|  |                 let vorstand = Role::find_by_name(db, "Vorstand").await.unwrap(); | ||||||
|  |                 Notification::create_for_role( | ||||||
|  |                     db, | ||||||
|  |                     &vorstand, | ||||||
|  |                     &format!("{user} hat den Logbuch-Eintrag gelöscht: {logbookdetails}"), | ||||||
|  |                     "Logbuch gelöscht", | ||||||
|  |                     None, | ||||||
|  |                     None, | ||||||
|  |                 ) | ||||||
|  |                 .await; | ||||||
|  |  | ||||||
|  |                 sqlx::query!("DELETE FROM logbook WHERE id=?", self.id) | ||||||
|  |                     .execute(db) | ||||||
|  |                     .await | ||||||
|  |                     .unwrap(); //Okay, because we can only create a Logbook of a valid id | ||||||
|  |                 return Ok(()); | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|         Err(LogbookDeleteError::NotYourEntry) |         Err(LogbookDeleteError::NotYourEntry) | ||||||
|     } |     } | ||||||
| @@ -532,6 +918,7 @@ mod test { | |||||||
|     use crate::model::user::User; |     use crate::model::user::User; | ||||||
|     use crate::testdb; |     use crate::testdb; | ||||||
|  |  | ||||||
|  |     use chrono::Duration; | ||||||
|     use sqlx::SqlitePool; |     use sqlx::SqlitePool; | ||||||
|  |  | ||||||
|     #[sqlx::test] |     #[sqlx::test] | ||||||
| @@ -583,7 +970,7 @@ mod test { | |||||||
|     fn test_succ_create() { |     fn test_succ_create() { | ||||||
|         let pool = testdb!(); |         let pool = testdb!(); | ||||||
|  |  | ||||||
|         Logbook::create( |         let msg = Logbook::create( | ||||||
|             &pool, |             &pool, | ||||||
|             LogToAdd { |             LogToAdd { | ||||||
|                 boat_id: 3, |                 boat_id: 3, | ||||||
| @@ -599,9 +986,67 @@ mod test { | |||||||
|                 rowers: vec![4], |                 rowers: vec![4], | ||||||
|             }, |             }, | ||||||
|             &User::find_by_id(&pool, 4).await.unwrap(), |             &User::find_by_id(&pool, 4).await.unwrap(), | ||||||
|  |             "", | ||||||
|         ) |         ) | ||||||
|         .await |         .await | ||||||
|         .unwrap() |         .unwrap(); | ||||||
|  |         assert_eq!(msg, String::from("")); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_succ_create_with_thousands_msg() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let logbook = Logbook::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |         let user = User::find_by_id(&pool, 2).await.unwrap(); | ||||||
|  |         let current_date = chrono::Local::now().format("%Y-%m-%d").to_string(); | ||||||
|  |         let start_date = chrono::Local::now() - Duration::days(3); | ||||||
|  |         let start_date = start_date.format("%Y-%m-%d").to_string(); | ||||||
|  |         logbook | ||||||
|  |             .home( | ||||||
|  |                 &pool, | ||||||
|  |                 &user, | ||||||
|  |                 super::LogToFinalize { | ||||||
|  |                     destination: "new-destination".into(), | ||||||
|  |                     distance_in_km: 995, | ||||||
|  |                     comments: Some("Perfect water".into()), | ||||||
|  |                     logtype: None, | ||||||
|  |                     rowers: vec![2], | ||||||
|  |                     shipmaster: Some(2), | ||||||
|  |                     steering_person: Some(2), | ||||||
|  |                     shipmaster_only_steering: false, | ||||||
|  |                     departure: format!("{}T10:00", start_date), | ||||||
|  |                     arrival: format!("{}T12:00", current_date), | ||||||
|  |                 }, | ||||||
|  |                 "", | ||||||
|  |             ) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|  |  | ||||||
|  |         let msg = Logbook::create( | ||||||
|  |             &pool, | ||||||
|  |             LogToAdd { | ||||||
|  |                 boat_id: 3, | ||||||
|  |                 shipmaster: Some(2), | ||||||
|  |                 steering_person: Some(2), | ||||||
|  |                 shipmaster_only_steering: false, | ||||||
|  |                 departure: "2128-05-20T12:00".into(), | ||||||
|  |                 arrival: None, | ||||||
|  |                 destination: None, | ||||||
|  |                 distance_in_km: None, | ||||||
|  |                 comments: None, | ||||||
|  |                 logtype: None, | ||||||
|  |                 rowers: vec![2], | ||||||
|  |             }, | ||||||
|  |             &User::find_by_id(&pool, 1).await.unwrap(), | ||||||
|  |             "", | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |         assert_eq!( | ||||||
|  |             msg, | ||||||
|  |             String::from(" • rower braucht nur mehr 5 km bis die 1000 km voll sind 🤑") | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     #[sqlx::test] |     #[sqlx::test] | ||||||
| @@ -624,6 +1069,7 @@ mod test { | |||||||
|                 rowers: vec![5], |                 rowers: vec![5], | ||||||
|             }, |             }, | ||||||
|             &User::find_by_id(&pool, 4).await.unwrap(), |             &User::find_by_id(&pool, 4).await.unwrap(), | ||||||
|  |             "", | ||||||
|         ) |         ) | ||||||
|         .await; |         .await; | ||||||
|  |  | ||||||
| @@ -650,6 +1096,7 @@ mod test { | |||||||
|                 rowers: vec![5], |                 rowers: vec![5], | ||||||
|             }, |             }, | ||||||
|             &User::find_by_id(&pool, 4).await.unwrap(), |             &User::find_by_id(&pool, 4).await.unwrap(), | ||||||
|  |             "", | ||||||
|         ) |         ) | ||||||
|         .await; |         .await; | ||||||
|  |  | ||||||
| @@ -676,6 +1123,7 @@ mod test { | |||||||
|                 rowers: vec![5], |                 rowers: vec![5], | ||||||
|             }, |             }, | ||||||
|             &User::find_by_id(&pool, 5).await.unwrap(), |             &User::find_by_id(&pool, 5).await.unwrap(), | ||||||
|  |             "", | ||||||
|         ) |         ) | ||||||
|         .await; |         .await; | ||||||
|  |  | ||||||
| @@ -702,6 +1150,7 @@ mod test { | |||||||
|                 rowers: vec![5], |                 rowers: vec![5], | ||||||
|             }, |             }, | ||||||
|             &User::find_by_id(&pool, 5).await.unwrap(), |             &User::find_by_id(&pool, 5).await.unwrap(), | ||||||
|  |             "", | ||||||
|         ) |         ) | ||||||
|         .await; |         .await; | ||||||
|  |  | ||||||
| @@ -728,6 +1177,7 @@ mod test { | |||||||
|                 rowers: Vec::new(), |                 rowers: Vec::new(), | ||||||
|             }, |             }, | ||||||
|             &User::find_by_id(&pool, 2).await.unwrap(), |             &User::find_by_id(&pool, 2).await.unwrap(), | ||||||
|  |             "", | ||||||
|         ) |         ) | ||||||
|         .await; |         .await; | ||||||
|  |  | ||||||
| @@ -754,6 +1204,7 @@ mod test { | |||||||
|                 rowers: vec![5], |                 rowers: vec![5], | ||||||
|             }, |             }, | ||||||
|             &User::find_by_id(&pool, 5).await.unwrap(), |             &User::find_by_id(&pool, 5).await.unwrap(), | ||||||
|  |             "", | ||||||
|         ) |         ) | ||||||
|         .await; |         .await; | ||||||
|  |  | ||||||
| @@ -780,27 +1231,13 @@ mod test { | |||||||
|                 rowers: vec![1, 5], |                 rowers: vec![1, 5], | ||||||
|             }, |             }, | ||||||
|             &User::find_by_id(&pool, 5).await.unwrap(), |             &User::find_by_id(&pool, 5).await.unwrap(), | ||||||
|  |             "", | ||||||
|         ) |         ) | ||||||
|         .await; |         .await; | ||||||
|  |  | ||||||
|         assert_eq!(res, Err(LogbookCreateError::TooManyRowers(1, 2))); |         assert_eq!(res, Err(LogbookCreateError::TooManyRowers(1, 2))); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     #[sqlx::test] |  | ||||||
|     fn test_distances() { |  | ||||||
|         let pool = testdb!(); |  | ||||||
|  |  | ||||||
|         let res = Logbook::distances(&pool).await; |  | ||||||
|  |  | ||||||
|         assert_eq!( |  | ||||||
|             res, |  | ||||||
|             vec![ |  | ||||||
|                 ("Ottensheim".into(), 25 as i64), |  | ||||||
|                 ("Ottensheim + Regattastrecke".into(), 29 as i64), |  | ||||||
|             ] |  | ||||||
|         ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     #[sqlx::test] |     #[sqlx::test] | ||||||
|     fn test_succ_home() { |     fn test_succ_home() { | ||||||
|         let pool = testdb!(); |         let pool = testdb!(); | ||||||
| @@ -826,6 +1263,7 @@ mod test { | |||||||
|                     departure: format!("{}T10:00", current_date), |                     departure: format!("{}T10:00", current_date), | ||||||
|                     arrival: format!("{}T12:00", current_date), |                     arrival: format!("{}T12:00", current_date), | ||||||
|                 }, |                 }, | ||||||
|  |                 "", | ||||||
|             ) |             ) | ||||||
|             .await |             .await | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
| @@ -854,6 +1292,7 @@ mod test { | |||||||
|                     departure: "1990-01-01T10:00".into(), |                     departure: "1990-01-01T10:00".into(), | ||||||
|                     arrival: "1990-01-01T12:00".into(), |                     arrival: "1990-01-01T12:00".into(), | ||||||
|                 }, |                 }, | ||||||
|  |                 "", | ||||||
|             ) |             ) | ||||||
|             .await; |             .await; | ||||||
|  |  | ||||||
| @@ -883,6 +1322,7 @@ mod test { | |||||||
|                     departure: "1990-01-01T10:00".into(), |                     departure: "1990-01-01T10:00".into(), | ||||||
|                     arrival: "1990-01-01T12:00".into(), |                     arrival: "1990-01-01T12:00".into(), | ||||||
|                 }, |                 }, | ||||||
|  |                 "", | ||||||
|             ) |             ) | ||||||
|             .await; |             .await; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,19 +1,92 @@ | |||||||
| use std::error::Error; | use std::{error::Error, fs}; | ||||||
|  |  | ||||||
| use lettre::{ | use lettre::{ | ||||||
|     message::header::ContentType, transport::smtp::authentication::Credentials, Message, |     Address, Message, SmtpTransport, Transport, | ||||||
|     SmtpTransport, Transport, |     message::{Attachment, MultiPart, SinglePart, header::ContentType}, | ||||||
|  |     transport::smtp::authentication::Credentials, | ||||||
| }; | }; | ||||||
| use sqlx::SqlitePool; | use sqlx::{Sqlite, SqlitePool, Transaction}; | ||||||
|  |  | ||||||
| use crate::tera::admin::mail::MailToSend; | use crate::tera::admin::mail::MailToSend; | ||||||
|  |  | ||||||
| use super::{family::Family, role::Role, user::User}; | use super::{activity::ActivityBuilder, family::Family, log::Log, role::Role, user::User}; | ||||||
|  |  | ||||||
| pub struct Mail {} | pub struct Mail {} | ||||||
|  |  | ||||||
| impl Mail { | impl Mail { | ||||||
|     pub async fn send(db: &SqlitePool, data: MailToSend, smtp_pw: String) -> bool { |     pub async fn send_single( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         to: &str, | ||||||
|  |         subject: &str, | ||||||
|  |         body: String, | ||||||
|  |         smtp_pw: &str, | ||||||
|  |     ) -> Result<(), String> { | ||||||
|  |         let mut tx = db.begin().await.unwrap(); | ||||||
|  |         let ret = Self::send_single_tx(&mut tx, to, subject, body, smtp_pw).await; | ||||||
|  |         tx.commit().await.unwrap(); | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn send_single_tx( | ||||||
|  |         db: &mut Transaction<'_, Sqlite>, | ||||||
|  |         to: &str, | ||||||
|  |         subject: &str, | ||||||
|  |         body: String, | ||||||
|  |         smtp_pw: &str, | ||||||
|  |     ) -> Result<(), String> { | ||||||
|  |         let mut email = Message::builder() | ||||||
|  |             .from( | ||||||
|  |                 "ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>" | ||||||
|  |                     .parse() | ||||||
|  |                     .unwrap(), | ||||||
|  |             ) | ||||||
|  |             .reply_to( | ||||||
|  |                 "ASKÖ Ruderverein Donau Linz <info@rudernlinz.at>" | ||||||
|  |                     .parse() | ||||||
|  |                     .unwrap(), | ||||||
|  |             ) | ||||||
|  |             .to("ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>" | ||||||
|  |                 .parse() | ||||||
|  |                 .unwrap()); | ||||||
|  |         let splitted = to.split(','); | ||||||
|  |         for single_rec in splitted { | ||||||
|  |             match single_rec.parse() { | ||||||
|  |                 Ok(new_bcc_mail) => email = email.bcc(new_bcc_mail), | ||||||
|  |                 Err(_) => { | ||||||
|  |                     Log::create_with_tx( | ||||||
|  |                         db, | ||||||
|  |                         format!("Mail not sent to {single_rec}, because it could not be parsed"), | ||||||
|  |                     ) | ||||||
|  |                     .await; | ||||||
|  |                     return Err(format!( | ||||||
|  |                         "Mail nicht versandt, da '{single_rec}' keine gültige Mailadresse ist." | ||||||
|  |                     )); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let email = email | ||||||
|  |             .subject(subject) | ||||||
|  |             .header(ContentType::TEXT_PLAIN) | ||||||
|  |             .body(body) | ||||||
|  |             .unwrap(); | ||||||
|  |  | ||||||
|  |         let creds = Credentials::new("no-reply@rudernlinz.at".to_owned(), smtp_pw.into()); | ||||||
|  |  | ||||||
|  |         let mailer = SmtpTransport::relay("mail.your-server.de") | ||||||
|  |             .unwrap() | ||||||
|  |             .credentials(creds) | ||||||
|  |             .build(); | ||||||
|  |  | ||||||
|  |         // Send the email | ||||||
|  |         if let Err(e) = mailer.send(&email) { | ||||||
|  |             Log::create_with_tx(db, format!("Mail nicht versandt: {e:?}")).await; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn send(db: &SqlitePool, data: MailToSend<'_>, smtp_pw: String) -> bool { | ||||||
|         let mut email = Message::builder() |         let mut email = Message::builder() | ||||||
|             .from( |             .from( | ||||||
|                 "ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>" |                 "ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>" | ||||||
| @@ -32,17 +105,38 @@ impl Mail { | |||||||
|         for rec in role.mails_from_role(db).await { |         for rec in role.mails_from_role(db).await { | ||||||
|             let splitted = rec.split(','); |             let splitted = rec.split(','); | ||||||
|             for single_rec in splitted { |             for single_rec in splitted { | ||||||
|                 email = email.bcc(single_rec.parse().unwrap()); |                 match single_rec.parse() { | ||||||
|  |                     Ok(new_bcc_mail) => email = email.bcc(new_bcc_mail), | ||||||
|  |                     Err(_) => { | ||||||
|  |                         Log::create( | ||||||
|  |                             db, | ||||||
|  |                             format!("Mail not sent to {rec}, because it could not be parsed"), | ||||||
|  |                         ) | ||||||
|  |                         .await; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // TODO: handle attachments |         let mut multipart = MultiPart::mixed().singlepart(SinglePart::plain(data.body)); | ||||||
|  |  | ||||||
|         let email = email |         for temp_file in &data.files { | ||||||
|             .subject(data.subject) |             let content = fs::read(temp_file.path().unwrap()).unwrap(); | ||||||
|             .header(ContentType::TEXT_PLAIN) |             let media_type = format!("{}", temp_file.content_type().unwrap().media_type()); | ||||||
|             .body(String::from(data.body)) |             let content_type = ContentType::parse(&media_type).unwrap(); | ||||||
|             .unwrap(); |             if let Some(name) = temp_file.name() { | ||||||
|  |                 let attachment = Attachment::new(format!( | ||||||
|  |                     "{}.{}", | ||||||
|  |                     name, | ||||||
|  |                     temp_file.content_type().unwrap().extension().unwrap() | ||||||
|  |                 )) | ||||||
|  |                 .body(content, content_type); | ||||||
|  |  | ||||||
|  |                 multipart = multipart.singlepart(attachment); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let email = email.subject(data.subject).multipart(multipart).unwrap(); | ||||||
|  |  | ||||||
|         let creds = Credentials::new("no-reply@rudernlinz.at".to_owned(), smtp_pw); |         let creds = Credentials::new("no-reply@rudernlinz.at".to_owned(), smtp_pw); | ||||||
|  |  | ||||||
| @@ -59,10 +153,20 @@ impl Mail { | |||||||
|         false |         false | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn fees(db: &SqlitePool, smtp_pw: String) { |     pub async fn fees(db: &SqlitePool, smtp_pw: String, test: Option<User>) { | ||||||
|         let users = User::all_payer_groups(db).await; |         let users = User::all_payer_groups(db).await; | ||||||
|         for user in users { |         for user in users { | ||||||
|             if !user.has_role(db, "paid").await { |             if let Some(test) = &test { | ||||||
|  |                 if user.id != test.id { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if user.has_role(db, "schnupperant").await || user.has_role(db, "scheckbuch").await { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if !user.has_role(db, "paid").await || test.is_some() { | ||||||
|                 let mut is_family = false; |                 let mut is_family = false; | ||||||
|                 let mut send_to = String::new(); |                 let mut send_to = String::new(); | ||||||
|                 match Family::find_by_opt_id(db, user.family_id).await { |                 match Family::find_by_opt_id(db, user.family_id).await { | ||||||
| @@ -76,7 +180,7 @@ impl Mail { | |||||||
|                     } |                     } | ||||||
|                     None => { |                     None => { | ||||||
|                         if let Some(mail) = &user.mail { |                         if let Some(mail) = &user.mail { | ||||||
|                             send_to.push_str(&mail) |                             send_to.push_str(mail) | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
| @@ -103,12 +207,11 @@ dein Vereinsbeitrag für das aktuelle Jahr beträgt {}€", | |||||||
|                             fees.name |                             fees.name | ||||||
|                         )) |                         )) | ||||||
|                     } |                     } | ||||||
|                     content.push_str("\nBitte überweise diesen auf folgendes Konto: IBAN: AT13 1200 0804 1300 1200. Auf https://app.rudernlinz.at/planned findest du einen QR Code, den du mit deiner Bankapp scannen kannst um alle Eingaben bereits ausgefüllt zu haben.\n\n\ |                     content.push_str("\nBitte überweise diesen auf folgendes Konto: IBAN: AT58 2032 0321 0072 9256. Auf https://app.rudernlinz.at/planned findest du einen QR Code, den du mit deiner Bankapp scannen kannst um alle Eingaben bereits ausgefüllt zu haben.\n\n\ | ||||||
| Falls die Berechnung nicht stimmt (korrekte Preise findest du unter https://rudernlinz.at/unser-verein/gebuhren/) melde dich bitte bei it@rudernlinz.at. @Studenten: Bitte die aktuelle Studienbestätigung an it@rudernlinz.at schicken.\n\n\ | Falls die Berechnung nicht stimmt (korrekte Preise findest du unter https://rudernlinz.at/unser-verein/gebuhren/) melde dich bitte bei kassier@rudernlinz.at. @Studenten: Bitte die aktuelle Studienbestätigung an kassier@rudernlinz.at schicken.\n\n\ | ||||||
| Wenn du die Vereinsgebühren schon bezahlt hast, kannst du diese Mail einfach ignorieren.\n\n | Wenn du die Vereinsgebühren schon bezahlt hast, kannst du diese Mail einfach ignorieren.\n\n | ||||||
| Beste Grüße\n\ | Beste Grüße\n\ | ||||||
| Der Vorstand | Der Vorstand"); | ||||||
|                                                                           "); |  | ||||||
|                     let mut email = Message::builder() |                     let mut email = Message::builder() | ||||||
|                         .from( |                         .from( | ||||||
|                             "ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>" |                             "ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>" | ||||||
| @@ -116,7 +219,7 @@ Der Vorstand | |||||||
|                                 .unwrap(), |                                 .unwrap(), | ||||||
|                         ) |                         ) | ||||||
|                         .reply_to( |                         .reply_to( | ||||||
|                             "ASKÖ Ruderverein Donau Linz <it@rudernlinz.at>" |                             "ASKÖ Ruderverein Donau Linz <kassier@rudernlinz.at>" | ||||||
|                                 .parse() |                                 .parse() | ||||||
|                                 .unwrap(), |                                 .unwrap(), | ||||||
|                         ) |                         ) | ||||||
| @@ -155,9 +258,153 @@ Der Vorstand | |||||||
|  |  | ||||||
|                         // Send the email |                         // Send the email | ||||||
|                         mailer.send(&email).unwrap(); |                         mailer.send(&email).unwrap(); | ||||||
|  |                         ActivityBuilder::new(&format!( | ||||||
|  |                             "{user} hat die Info-Mail bzgl. Gebühren gesendet bekommen." | ||||||
|  |                         )) | ||||||
|  |                         .user(&user) | ||||||
|  |                         .save(db) | ||||||
|  |                         .await; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn fees_final(db: &SqlitePool, smtp_pw: String, test: Option<User>) { | ||||||
|  |         let users = User::all_payer_groups(db).await; | ||||||
|  |         for user in users { | ||||||
|  |             if let Some(test) = &test { | ||||||
|  |                 if user.id != test.id { | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if user.has_role(db, "schnupperant").await || user.has_role(db, "scheckbuch").await { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if let Some(fee) = user.fee(db).await { | ||||||
|  |                 if !fee.paid || test.is_some() { | ||||||
|  |                     let mut is_family = false; | ||||||
|  |                     let mut send_to = String::new(); | ||||||
|  |                     match Family::find_by_opt_id(db, user.family_id).await { | ||||||
|  |                         Some(family) => { | ||||||
|  |                             is_family = true; | ||||||
|  |                             for member in family.members(db).await { | ||||||
|  |                                 if let Some(mail) = member.mail { | ||||||
|  |                                     send_to.push_str(&format!("{mail},")) | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                         None => { | ||||||
|  |                             if let Some(mail) = &user.mail { | ||||||
|  |                                 send_to.push_str(mail) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     let fees = user.fee(db).await; | ||||||
|  |                     if let Some(fees) = fees { | ||||||
|  |                         let mut content = format!( | ||||||
|  |                             "Liebes Vereinsmitglied, \n\n\ | ||||||
|  | wir möchten darauf hinweisen, dass wir deinen Mitgliedsbeitrag für das laufende Jahr bislang nicht verbuchen konnten. Es besteht die Möglichkeit, dass es sich hierbei um ein Versehen unsererseits handelt. Solltest du den Betrag bereits überwiesen haben, bitte kurz auf diese E-Mail antworten, damit wir es richtigstellen können. | ||||||
|  |  | ||||||
|  | Falls die Zahlung noch nicht erfolgt ist, bitten wir um umgehende Überweisung des ausstehenden Betrags, spätestens jedoch binnen 14 Tagen, auf unser Bankkonto.\n\n\ | ||||||
|  | Dein Vereinsbeitrag für das aktuelle Jahr beträgt {}€", | ||||||
|  |                             fees.sum_in_cents / 100, | ||||||
|  |                         ); | ||||||
|  |  | ||||||
|  |                         if fees.parts.len() == 1 { | ||||||
|  |                             content.push_str(&format!(" ({}).\n", fees.parts[0].0)) | ||||||
|  |                         } else { | ||||||
|  |                             content | ||||||
|  |                                 .push_str(". Dieser setzt sich aus folgenden Teilen zusammen: \n"); | ||||||
|  |                             for (desc, fee_in_cents) in fees.parts { | ||||||
|  |                                 content.push_str(&format!("- {}: {}€\n", desc, fee_in_cents / 100)) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                         if is_family { | ||||||
|  |                             content.push_str(&format!( | ||||||
|  |                                 "Dieser gilt für die gesamte Familie ({}). Diese Mail wird an alle Familienmitglieder verschickt, bezahlen müsst ihr natürlich nur 1x.\n", | ||||||
|  |                                 fees.name | ||||||
|  |                             )) | ||||||
|  |                         } | ||||||
|  |                         content.push_str("\n\ | ||||||
|  | Gemäß § 7 Abs. 3 lit. c unseres Status behalten wir uns vor, bei ausbleibender Zahlung die Mitgliedschaft zu beenden. Dies möchten wir vermeiden und hoffen auf deine Unterstützung.\n\n\ | ||||||
|  | Bei Fragen oder Problemen stehen wir gerne zur Verfügung. | ||||||
|  |  | ||||||
|  | Bankverbindung: IBAN: AT58 2032 0321 0072 9256 (Unter https://app.rudernlinz.at/planned findest du einen QR Code, den du mit deiner Bankapp scannen kannst um alle Eingaben bereits ausgefüllt zu haben.) | ||||||
|  |  | ||||||
|  | Mit freundlichen Grüßen,\n\ | ||||||
|  | Der Vorstand"); | ||||||
|  |                         let mut email = Message::builder() | ||||||
|  |                             .from( | ||||||
|  |                                 "ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>" | ||||||
|  |                                     .parse() | ||||||
|  |                                     .unwrap(), | ||||||
|  |                             ) | ||||||
|  |                             .reply_to( | ||||||
|  |                                 "ASKÖ Ruderverein Donau Linz <kassier@rudernlinz.at>" | ||||||
|  |                                     .parse() | ||||||
|  |                                     .unwrap(), | ||||||
|  |                             ) | ||||||
|  |                             .to("ASKÖ Ruderverein Donau Linz <no-reply@rudernlinz.at>" | ||||||
|  |                                 .parse() | ||||||
|  |                                 .unwrap()); | ||||||
|  |                         let splitted = send_to.split(','); | ||||||
|  |                         let mut send_mail = false; | ||||||
|  |                         for single_rec in splitted { | ||||||
|  |                             let single_rec = single_rec.trim(); | ||||||
|  |                             match single_rec.parse() { | ||||||
|  |                                 Ok(val) => { | ||||||
|  |                                     email = email.bcc(val); | ||||||
|  |                                     send_mail = true; | ||||||
|  |                                 } | ||||||
|  |                                 Err(_) => { | ||||||
|  |                                     println!("Error in mail: {single_rec}"); | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         if send_mail { | ||||||
|  |                             let email = email | ||||||
|  |                                 .subject("Mahnung Vereinsgebühren | ASKÖ Ruderverein Donau Linz") | ||||||
|  |                                 .header(ContentType::TEXT_PLAIN) | ||||||
|  |                                 .body(content) | ||||||
|  |                                 .unwrap(); | ||||||
|  |  | ||||||
|  |                             let creds = Credentials::new( | ||||||
|  |                                 "no-reply@rudernlinz.at".to_owned(), | ||||||
|  |                                 smtp_pw.clone(), | ||||||
|  |                             ); | ||||||
|  |  | ||||||
|  |                             let mailer = SmtpTransport::relay("mail.your-server.de") | ||||||
|  |                                 .unwrap() | ||||||
|  |                                 .credentials(creds) | ||||||
|  |                                 .build(); | ||||||
|  |  | ||||||
|  |                             // Send the email | ||||||
|  |                             mailer.send(&email).unwrap(); | ||||||
|  |                             ActivityBuilder::new(&format!( | ||||||
|  |                                 "{user} hat die Mahn-Mail bzgl. Gebühren gesendet bekommen." | ||||||
|  |                             )) | ||||||
|  |                             .user(&user) | ||||||
|  |                             .save(db) | ||||||
|  |                             .await; | ||||||
|  |                         } | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | pub(crate) fn valid_mails(mails: &str) -> bool { | ||||||
|  |     let splitted = mails.split(','); | ||||||
|  |     for single_rec in splitted { | ||||||
|  |         if single_rec.parse::<Address>().is_err() { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     true | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,60 +1,91 @@ | |||||||
| use chrono::NaiveDate; | use chrono::{Local, NaiveDate}; | ||||||
| use serde::Serialize; | use serde::Serialize; | ||||||
| use sqlx::SqlitePool; | use sqlx::SqlitePool; | ||||||
|  | use waterlevel::WaterlevelDay; | ||||||
|  |  | ||||||
| use self::{ | use crate::AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD; | ||||||
|     planned_event::{PlannedEvent, PlannedEventWithUserAndTriptype}, |  | ||||||
|     trip::{Trip, TripWithUserAndType}, | use self::{waterlevel::Waterlevel, weather::Weather}; | ||||||
|  | use boatreservation::{BoatReservation, BoatReservationWithDetails}; | ||||||
|  | use planned::{ | ||||||
|  |     event::{Event, EventWithDetails}, | ||||||
|  |     trip::{Trip, TripWithDetails}, | ||||||
| }; | }; | ||||||
|  | use std::collections::HashMap; | ||||||
|  |  | ||||||
|  | pub mod activity; | ||||||
| pub mod boat; | pub mod boat; | ||||||
| pub mod boatdamage; | pub mod boatdamage; | ||||||
|  | pub mod boathouse; | ||||||
|  | pub mod boatreservation; | ||||||
|  | pub mod distance; | ||||||
| pub mod family; | pub mod family; | ||||||
| pub mod location; | pub mod location; | ||||||
| pub mod log; | pub mod log; | ||||||
| pub mod logbook; | pub mod logbook; | ||||||
| pub mod logtype; | pub mod logtype; | ||||||
| pub mod mail; | pub mod mail; | ||||||
| pub mod planned_event; | pub mod notification; | ||||||
|  | pub mod personal; | ||||||
|  | pub mod planned; | ||||||
| pub mod role; | pub mod role; | ||||||
| pub mod rower; | pub mod rower; | ||||||
| pub mod stat; | pub mod stat; | ||||||
| pub mod trip; | pub mod trailer; | ||||||
| pub mod tripdetails; | pub mod trailerreservation; | ||||||
| pub mod triptype; |  | ||||||
| pub mod user; | pub mod user; | ||||||
| pub mod usertrip; | pub mod waterlevel; | ||||||
|  | pub mod weather; | ||||||
|  |  | ||||||
| #[derive(Serialize, Debug)] | #[derive(Serialize, Debug)] | ||||||
| pub struct Day { | pub struct Day { | ||||||
|     day: NaiveDate, |     day: NaiveDate, | ||||||
|     planned_events: Vec<PlannedEventWithUserAndTriptype>, |     events: Vec<EventWithDetails>, | ||||||
|     trips: Vec<TripWithUserAndType>, |     trips: Vec<TripWithDetails>, | ||||||
|     is_pinned: bool, |     is_pinned: bool, | ||||||
|  |     regular_sees_this_day: bool, | ||||||
|  |     max_waterlevel: Option<WaterlevelDay>, | ||||||
|  |     weather: Option<Weather>, | ||||||
|  |     boat_reservations: HashMap<String, Vec<BoatReservationWithDetails>>, | ||||||
| } | } | ||||||
|  |  | ||||||
| impl Day { | impl Day { | ||||||
|     pub async fn new(db: &SqlitePool, day: NaiveDate, is_pinned: bool) -> Self { |     pub async fn new(db: &SqlitePool, day: NaiveDate, is_pinned: bool) -> Self { | ||||||
|  |         let today = Local::now().date_naive(); | ||||||
|  |         let day_diff = (day - today).num_days() + 1; | ||||||
|  |         let regular_sees_this_day = day_diff <= AMOUNT_DAYS_TO_SHOW_TRIPS_AHEAD; | ||||||
|         if is_pinned { |         if is_pinned { | ||||||
|             Self { |             Self { | ||||||
|                 day, |                 day, | ||||||
|                 planned_events: PlannedEvent::get_pinned_for_day(db, day).await, |                 events: Event::get_pinned_for_day(db, day).await, | ||||||
|                 trips: Trip::get_pinned_for_day(db, day).await, |                 trips: Trip::get_pinned_for_day(db, day).await, | ||||||
|                 is_pinned, |                 is_pinned, | ||||||
|  |                 regular_sees_this_day, | ||||||
|  |                 max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await, | ||||||
|  |                 weather: Weather::find_by_day(db, day).await, | ||||||
|  |                 boat_reservations: BoatReservation::with_groups( | ||||||
|  |                     BoatReservation::for_day(db, day).await, | ||||||
|  |                 ), | ||||||
|             } |             } | ||||||
|         } else { |         } else { | ||||||
|             Self { |             Self { | ||||||
|                 day, |                 day, | ||||||
|                 planned_events: PlannedEvent::get_for_day(db, day).await, |                 events: Event::get_for_day(db, day).await, | ||||||
|                 trips: Trip::get_for_day(db, day).await, |                 trips: Trip::get_for_day(db, day).await, | ||||||
|                 is_pinned, |                 is_pinned, | ||||||
|  |                 regular_sees_this_day, | ||||||
|  |                 max_waterlevel: Waterlevel::max_waterlevel_for_day(db, day).await, | ||||||
|  |                 weather: Weather::find_by_day(db, day).await, | ||||||
|  |                 boat_reservations: BoatReservation::with_groups( | ||||||
|  |                     BoatReservation::for_day(db, day).await, | ||||||
|  |                 ), | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|     pub async fn new_guest(db: &SqlitePool, day: NaiveDate, is_pinned: bool) -> Self { |     pub async fn new_guest(db: &SqlitePool, day: NaiveDate, is_pinned: bool) -> Self { | ||||||
|         let mut day = Self::new(db, day, is_pinned).await; |         let mut day = Self::new(db, day, is_pinned).await; | ||||||
|  |  | ||||||
|         day.planned_events.retain(|e| e.planned_event.allow_guests); |         day.events.retain(|e| e.event.allow_guests); | ||||||
|         day.trips.retain(|t| t.trip.allow_guests); |         day.trips.retain(|t| t.trip.allow_guests); | ||||||
|  |  | ||||||
|         day |         day | ||||||
|   | |||||||
| @@ -1,36 +1,341 @@ | |||||||
| use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc}; | use std::ops::DerefMut; | ||||||
| use serde::{Deserialize, Serialize}; |  | ||||||
| use sqlx::{FromRow, SqlitePool}; |  | ||||||
|  |  | ||||||
| #[derive(FromRow, Debug, Serialize, Deserialize)] | use chrono::NaiveDateTime; | ||||||
|  | use regex::Regex; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  | use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; | ||||||
|  |  | ||||||
|  | use super::{planned::usertrip::UserTrip, role::Role, user::User}; | ||||||
|  |  | ||||||
|  | #[derive(FromRow, Debug, Serialize, Deserialize, Clone)] | ||||||
| pub struct Notification { | pub struct Notification { | ||||||
|     pub id: i64, |     pub id: i64, | ||||||
|     pub user_id: i64, |     pub user_id: i64, | ||||||
|     pub message: String, |     pub message: String, | ||||||
|     pub read_at: NaiveDateTime, |     pub read_at: Option<NaiveDateTime>, | ||||||
|  |     pub created_at: NaiveDateTime, | ||||||
|     pub category: String, |     pub category: String, | ||||||
|  |     pub link: Option<String>, | ||||||
|  |     pub action_after_reading: Option<String>, | ||||||
| } | } | ||||||
|  |  | ||||||
| impl Notification { | impl Notification { | ||||||
|     //pub async fn create(db: &SqlitePool, msg: String) -> bool { |     pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> { | ||||||
|     //    sqlx::query!("INSERT INTO log(msg) VALUES (?)", msg,) |         sqlx::query_as!(Self, "SELECT id, user_id, message, read_at, created_at, category, link, action_after_reading FROM notification WHERE id like ?", id) | ||||||
|     //        .execute(db) |             .fetch_one(db) | ||||||
|     //        .await |             .await | ||||||
|     //        .is_ok() |             .ok() | ||||||
|     //} |     } | ||||||
|  |     pub async fn create_with_tx( | ||||||
|  |         db: &mut Transaction<'_, Sqlite>, | ||||||
|  |         user: &User, | ||||||
|  |         message: &str, | ||||||
|  |         category: &str, | ||||||
|  |         link: Option<&str>, | ||||||
|  |         action_after_reading: Option<&str>, | ||||||
|  |     ) { | ||||||
|  |         sqlx::query!( | ||||||
|  |             "INSERT INTO notification(user_id, message, category, link, action_after_reading) VALUES (?, ?, ?, ?, ?)", | ||||||
|  |             user.id, | ||||||
|  |             message, | ||||||
|  |             category, | ||||||
|  |             link, | ||||||
|  |             action_after_reading | ||||||
|  |         ) | ||||||
|  |         .execute(db.deref_mut()) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     async fn for_user(db: &SqlitePool, user: &User) -> Vec<Self> { |     pub async fn create( | ||||||
|         sqlx::query_as!( |         db: &SqlitePool, | ||||||
|             Log, |         user: &User, | ||||||
|  |         message: &str, | ||||||
|  |         category: &str, | ||||||
|  |         link: Option<&str>, | ||||||
|  |         action_after_reading: Option<&str>, | ||||||
|  |     ) { | ||||||
|  |         let mut tx = db.begin().await.unwrap(); | ||||||
|  |         Self::create_with_tx(&mut tx, user, message, category, link, action_after_reading).await; | ||||||
|  |         tx.commit().await.unwrap(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn create_for_role_tx( | ||||||
|  |         db: &mut Transaction<'_, Sqlite>, | ||||||
|  |         role: &Role, | ||||||
|  |         message: &str, | ||||||
|  |         category: &str, | ||||||
|  |         link: Option<&str>, | ||||||
|  |         action_after_reading: Option<&str>, | ||||||
|  |     ) { | ||||||
|  |         let users = User::all_with_role_tx(db, role).await; | ||||||
|  |  | ||||||
|  |         for user in users { | ||||||
|  |             Self::create_with_tx(db, &user, message, category, link, action_after_reading).await; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn create_for_role( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         role: &Role, | ||||||
|  |         message: &str, | ||||||
|  |         category: &str, | ||||||
|  |         link: Option<&str>, | ||||||
|  |         action_after_reading: Option<&str>, | ||||||
|  |     ) { | ||||||
|  |         let mut tx = db.begin().await.unwrap(); | ||||||
|  |         Self::create_for_role_tx(&mut tx, role, message, category, link, action_after_reading) | ||||||
|  |             .await; | ||||||
|  |         tx.commit().await.unwrap(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn create_for_steering_people_tx( | ||||||
|  |         db: &mut Transaction<'_, Sqlite>, | ||||||
|  |         message: &str, | ||||||
|  |         category: &str, | ||||||
|  |         link: Option<&str>, | ||||||
|  |         action_after_reading: Option<&str>, | ||||||
|  |     ) { | ||||||
|  |         let cox = Role::find_by_name_tx(db, "cox").await.unwrap(); | ||||||
|  |         Self::create_for_role_tx(db, &cox, message, category, link, action_after_reading).await; | ||||||
|  |         let bootsf = Role::find_by_name_tx(db, "Bootsführer").await.unwrap(); | ||||||
|  |         Self::create_for_role_tx(db, &bootsf, message, category, link, action_after_reading).await; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn create_for_steering_people( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         message: &str, | ||||||
|  |         category: &str, | ||||||
|  |         link: Option<&str>, | ||||||
|  |         action_after_reading: Option<&str>, | ||||||
|  |     ) { | ||||||
|  |         let cox = Role::find_by_name(db, "cox").await.unwrap(); | ||||||
|  |         Self::create_for_role(db, &cox, message, category, link, action_after_reading).await; | ||||||
|  |         let bootsf = Role::find_by_name(db, "Bootsführer").await.unwrap(); | ||||||
|  |         Self::create_for_role(db, &bootsf, message, category, link, action_after_reading).await; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn for_user(db: &SqlitePool, user: &User) -> Vec<Self> { | ||||||
|  |         let rows = sqlx::query!( | ||||||
|             " |             " | ||||||
| SELECT id, user_id, message, read_at, category | SELECT id, user_id, message, read_at, datetime(created_at, 'localtime') as created_at, category, link, action_after_reading FROM notification  | ||||||
| FROM notification | WHERE  | ||||||
| WHERE user_id = {} |   user_id = ?  | ||||||
|     ", |   AND ( | ||||||
|  |     read_at IS NULL  | ||||||
|  |     OR read_at >= datetime('now', '-14 days') | ||||||
|  |   )  | ||||||
|  |   AND created_at is not NULL | ||||||
|  | ORDER BY read_at DESC, created_at DESC; | ||||||
|  |             ", | ||||||
|             user.id |             user.id | ||||||
|         ) |         ) | ||||||
|         .fetch_all(db) |         .fetch_all(db) | ||||||
|         .await |         .await | ||||||
|         .unwrap() |         .unwrap(); | ||||||
|  |  | ||||||
|  |         rows.into_iter() | ||||||
|  |             .map(|rec| Notification { | ||||||
|  |                 id: rec.id, | ||||||
|  |                 user_id: rec.user_id, | ||||||
|  |                 message: rec.message, | ||||||
|  |                 read_at: rec.read_at, | ||||||
|  |                 created_at: NaiveDateTime::parse_from_str( | ||||||
|  |                     &rec.created_at.unwrap(), | ||||||
|  |                     "%Y-%m-%d %H:%M:%S", | ||||||
|  |                 ) | ||||||
|  |                 .unwrap(), | ||||||
|  |                 category: rec.category, | ||||||
|  |                 link: rec.link, | ||||||
|  |                 action_after_reading: rec.action_after_reading, | ||||||
|  |             }) | ||||||
|  |             .collect() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn mark_read(self, db: &SqlitePool) { | ||||||
|  |         sqlx::query!( | ||||||
|  |             "UPDATE notification SET read_at=CURRENT_TIMESTAMP WHERE id=?", | ||||||
|  |             self.id | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         if let Some(action) = self.action_after_reading.as_ref() { | ||||||
|  |             // User read notification about cancelled trip/event | ||||||
|  |             let re = Regex::new(r"^remove_user_trip_with_trip_details_id:(\d+)$").unwrap(); | ||||||
|  |             if let Some(caps) = re.captures(action) { | ||||||
|  |                 if let Some(matched) = caps.get(1) { | ||||||
|  |                     if let Ok(number) = matched.as_str().parse::<i64>() { | ||||||
|  |                         if let Some(usertrip) = | ||||||
|  |                             UserTrip::find_by_userid_and_trip_detail_id(db, self.user_id, number) | ||||||
|  |                                 .await | ||||||
|  |                         { | ||||||
|  |                             let _ = usertrip.self_delete(db).await; | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             // Cox read notification about cancelled event | ||||||
|  |             let re = Regex::new(r"^remove_trip_by_event:(\d+)$").unwrap(); | ||||||
|  |             if let Some(caps) = re.captures(action) { | ||||||
|  |                 if let Some(matched) = caps.get(1) { | ||||||
|  |                     if let Ok(number) = matched.as_str().parse::<i32>() { | ||||||
|  |                         let _ = sqlx::query!( | ||||||
|  |                             "DELETE FROM trip WHERE cox_id = ? AND planned_event_id = ?", | ||||||
|  |                             self.user_id, | ||||||
|  |                             number | ||||||
|  |                         ) | ||||||
|  |                         .execute(db) | ||||||
|  |                         .await | ||||||
|  |                         .unwrap(); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) async fn mark_all_read(db: &SqlitePool, user: &User) { | ||||||
|  |         let notifications = Self::for_user(db, user).await; | ||||||
|  |  | ||||||
|  |         for notification in notifications { | ||||||
|  |             notification.mark_read(db).await; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) async fn delete_by_action(db: &sqlx::Pool<Sqlite>, action: &str) { | ||||||
|  |         sqlx::query!( | ||||||
|  |             "DELETE FROM notification WHERE action_after_reading=? and read_at is null", | ||||||
|  |             action | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) async fn delete_by_link(db: &sqlx::Pool<Sqlite>, link: &str) { | ||||||
|  |         let link = Some(link); | ||||||
|  |         sqlx::query!("DELETE FROM notification WHERE link=?", link) | ||||||
|  |             .execute(db) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[cfg(test)] | ||||||
|  | mod test { | ||||||
|  |     use crate::{ | ||||||
|  |         model::{ | ||||||
|  |             notification::Notification, | ||||||
|  |             planned::{ | ||||||
|  |                 event::{Event, EventUpdate, Registration}, | ||||||
|  |                 trip::Trip, | ||||||
|  |                 tripdetails::{TripDetails, TripDetailsToAdd}, | ||||||
|  |                 usertrip::UserTrip, | ||||||
|  |             }, | ||||||
|  |             user::{EventUser, SteeringUser, User}, | ||||||
|  |         }, | ||||||
|  |         testdb, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     use chrono::Local; | ||||||
|  |     use sqlx::SqlitePool; | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn event_canceled() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         // Create event | ||||||
|  |         let add_tripdetails = TripDetailsToAdd { | ||||||
|  |             planned_starting_time: "10:00", | ||||||
|  |             max_people: 4, | ||||||
|  |             day: Local::now().date_naive().format("%Y-%m-%d").to_string(), | ||||||
|  |             notes: None, | ||||||
|  |             trip_type: None, | ||||||
|  |             allow_guests: false, | ||||||
|  |         }; | ||||||
|  |         let tripdetails_id = TripDetails::create(&pool, add_tripdetails).await; | ||||||
|  |         let trip_details = TripDetails::find_by_id(&pool, tripdetails_id) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|  |         let user = EventUser::new(&pool, &User::find_by_id(&pool, 1).await.unwrap()) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|  |         Event::create(&pool, &user, "new-event".into(), 2, false, &trip_details).await; | ||||||
|  |         let event = Event::find_by_trip_details(&pool, trip_details.id) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|  |  | ||||||
|  |         // Rower + Cox joins | ||||||
|  |         let rower = User::find_by_name(&pool, "rower").await.unwrap(); | ||||||
|  |         UserTrip::create(&pool, &rower, &trip_details, None) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|  |         let cox = SteeringUser::new(&pool, &User::find_by_name(&pool, "cox").await.unwrap()) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|  |         Trip::new_join(&pool, &cox, &event).await.unwrap(); | ||||||
|  |  | ||||||
|  |         // Cancel Event | ||||||
|  |         let cancel_update = EventUpdate { | ||||||
|  |             name: &event.name, | ||||||
|  |             planned_amount_cox: event.planned_amount_cox as i32, | ||||||
|  |             max_people: -1, | ||||||
|  |             notes: event.notes.as_deref(), | ||||||
|  |             always_show: event.always_show, | ||||||
|  |             is_locked: event.is_locked, | ||||||
|  |             trip_type_id: None, | ||||||
|  |         }; | ||||||
|  |         event.update(&pool, &user, &cancel_update).await; | ||||||
|  |  | ||||||
|  |         // Rower received notification | ||||||
|  |         let notifications = Notification::for_user(&pool, &rower).await; | ||||||
|  |         let rower_notification = notifications[0].clone(); | ||||||
|  |         assert_eq!(rower_notification.category, "Absage Ausfahrt"); | ||||||
|  |         assert_eq!( | ||||||
|  |             rower_notification.action_after_reading.as_deref(), | ||||||
|  |             Some("remove_user_trip_with_trip_details_id:4") | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         // Cox received notification | ||||||
|  |         let notifications = Notification::for_user(&pool, &cox.user).await; | ||||||
|  |         let cox_notification = notifications[0].clone(); | ||||||
|  |         assert_eq!(cox_notification.category, "Absage Ausfahrt"); | ||||||
|  |         assert_eq!( | ||||||
|  |             cox_notification.action_after_reading.as_deref(), | ||||||
|  |             Some("remove_trip_by_event:2") | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         // Notification removed if cancellation is cancelled | ||||||
|  |         let update = EventUpdate { | ||||||
|  |             name: &event.name, | ||||||
|  |             planned_amount_cox: event.planned_amount_cox as i32, | ||||||
|  |             max_people: 3, | ||||||
|  |             notes: event.notes.as_deref(), | ||||||
|  |             always_show: event.always_show, | ||||||
|  |             is_locked: event.is_locked, | ||||||
|  |             trip_type_id: None, | ||||||
|  |         }; | ||||||
|  |         event.update(&pool, &user, &update).await; | ||||||
|  |         assert!(Notification::for_user(&pool, &rower).await.is_empty()); | ||||||
|  |         assert!(Notification::for_user(&pool, &cox.user).await.is_empty()); | ||||||
|  |  | ||||||
|  |         // Cancel event again | ||||||
|  |         event.update(&pool, &user, &cancel_update).await; | ||||||
|  |  | ||||||
|  |         // Rower is removed if notification is accepted | ||||||
|  |         assert!(event.is_rower_registered(&pool, &rower).await); | ||||||
|  |         rower_notification.mark_read(&pool).await; | ||||||
|  |         assert!(!event.is_rower_registered(&pool, &rower).await); | ||||||
|  |  | ||||||
|  |         // Cox is removed if notification is accepted | ||||||
|  |         let registration = Registration::all_cox(&pool, event.id).await; | ||||||
|  |         assert_eq!(registration.len(), 1); | ||||||
|  |         assert_eq!(registration[0].name, "cox"); | ||||||
|  |  | ||||||
|  |         cox_notification.mark_read(&pool).await; | ||||||
|  |  | ||||||
|  |         let registration = Registration::all_cox(&pool, event.id).await; | ||||||
|  |         assert!(registration.is_empty()); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										157
									
								
								src/model/personal/cal.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,157 @@ | |||||||
|  | use std::io::Write; | ||||||
|  |  | ||||||
|  | use ics::{ | ||||||
|  |     ICalendar, | ||||||
|  |     components::Property, | ||||||
|  |     properties::{DtEnd, DtStart, Summary}, | ||||||
|  | }; | ||||||
|  | use sqlx::SqlitePool; | ||||||
|  |  | ||||||
|  | use crate::model::{ | ||||||
|  |     planned::{event::Event, trip::Trip}, | ||||||
|  |     user::User, | ||||||
|  | }; | ||||||
|  | use chrono::{Duration, NaiveTime}; | ||||||
|  |  | ||||||
|  | pub(crate) async fn get_personal_cal(db: &SqlitePool, user: &User) -> String { | ||||||
|  |     let mut calendar = ICalendar::new("2.0", "ics-rs"); | ||||||
|  |     calendar.push(Property::new( | ||||||
|  |         "X-WR-CALNAME", | ||||||
|  |         "Donau Linz - Deine Ausfahrten", | ||||||
|  |     )); | ||||||
|  |  | ||||||
|  |     let events = Event::all_with_user(db, user).await; | ||||||
|  |     for event in events { | ||||||
|  |         calendar.add_event(event.get_vevent(db).await); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let trips = Trip::all_with_user(db, user).await; | ||||||
|  |     for trip in trips { | ||||||
|  |         calendar.add_event(trip.get_vevent(db, user).await); | ||||||
|  |     } | ||||||
|  |     let mut buf = Vec::new(); | ||||||
|  |     write!(&mut buf, "{}", calendar).unwrap(); | ||||||
|  |     String::from_utf8(buf).unwrap() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Trip { | ||||||
|  |     pub(crate) async fn get_vevent<'a>(self, db: &'a SqlitePool, user: &'a User) -> ics::Event<'a> { | ||||||
|  |         let mut vevent = | ||||||
|  |             ics::Event::new(format!("trip-{}@rudernlinz.at", self.id), "19900101T180000"); | ||||||
|  |         let time_str = self.planned_starting_time.replace(':', ""); | ||||||
|  |         let formatted_time = if time_str.len() == 3 { | ||||||
|  |             format!("0{}", time_str) | ||||||
|  |         } else { | ||||||
|  |             time_str | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         vevent.push(DtStart::new(format!( | ||||||
|  |             "{}T{}00", | ||||||
|  |             self.day.replace('-', ""), | ||||||
|  |             formatted_time | ||||||
|  |         ))); | ||||||
|  |  | ||||||
|  |         let original_time = NaiveTime::parse_from_str(&self.planned_starting_time, "%H:%M") | ||||||
|  |             .expect("Failed to parse time"); | ||||||
|  |         let long_trip = match self.trip_type(db).await { | ||||||
|  |             Some(a) if a.name == "Lange Ausfahrt" => true, | ||||||
|  |             _ => false, | ||||||
|  |         }; | ||||||
|  |         let later_time = if long_trip { | ||||||
|  |             original_time + Duration::hours(6) | ||||||
|  |         } else { | ||||||
|  |             original_time + Duration::hours(3) | ||||||
|  |         }; | ||||||
|  |         if later_time > original_time { | ||||||
|  |             // Check if no day-overflow | ||||||
|  |             let time_three_hours_later = later_time.format("%H%M").to_string(); | ||||||
|  |             vevent.push(DtEnd::new(format!( | ||||||
|  |                 "{}T{}00", | ||||||
|  |                 self.day.replace('-', ""), | ||||||
|  |                 time_three_hours_later | ||||||
|  |             ))); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let mut name = String::new(); | ||||||
|  |         if self.is_cancelled() { | ||||||
|  |             name.push_str("ABGESAGT"); | ||||||
|  |             if let Some(notes) = &self.notes { | ||||||
|  |                 if !notes.is_empty() { | ||||||
|  |                     name.push_str(&format!(" (Grund: {notes})")) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             name.push_str("! :-( "); | ||||||
|  |         } | ||||||
|  |         if self.cox_id == user.id { | ||||||
|  |             name.push_str("Ruderausfahrt (selber ausgeschrieben)"); | ||||||
|  |         } else { | ||||||
|  |             name.push_str(&format!("Ruderausfahrt mit {} ", self.cox_name)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         vevent.push(Summary::new(name)); | ||||||
|  |         vevent | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Event { | ||||||
|  |     pub(crate) async fn get_vevent(self, db: &SqlitePool) -> ics::Event { | ||||||
|  |         let mut vevent = ics::Event::new( | ||||||
|  |             format!("event-{}@rudernlinz.at", self.id), | ||||||
|  |             "19900101T180000", | ||||||
|  |         ); | ||||||
|  |         let time_str = self.planned_starting_time.replace(':', ""); | ||||||
|  |         let formatted_time = if time_str.len() == 3 { | ||||||
|  |             format!("0{}", time_str) | ||||||
|  |         } else { | ||||||
|  |             time_str.clone() // TODO: remove again | ||||||
|  |         }; | ||||||
|  |         vevent.push(DtStart::new(format!( | ||||||
|  |             "{}T{}00", | ||||||
|  |             self.day.replace('-', ""), | ||||||
|  |             formatted_time | ||||||
|  |         ))); | ||||||
|  |  | ||||||
|  |         let original_time = NaiveTime::parse_from_str(&self.planned_starting_time, "%H:%M") | ||||||
|  |             .expect("Failed to parse time"); | ||||||
|  |  | ||||||
|  |         let long_trip = match self.trip_type(db).await { | ||||||
|  |             Some(a) if a.name == "Lange Ausfahrt" => true, | ||||||
|  |             _ => false, | ||||||
|  |         }; | ||||||
|  |         let later_time = if long_trip { | ||||||
|  |             original_time + Duration::hours(6) | ||||||
|  |         } else { | ||||||
|  |             original_time + Duration::hours(3) | ||||||
|  |         }; | ||||||
|  |         if later_time > original_time { | ||||||
|  |             // Check if no day-overflow | ||||||
|  |             let time_three_hours_later = later_time.format("%H%M").to_string(); | ||||||
|  |             vevent.push(DtEnd::new(format!( | ||||||
|  |                 "{}T{}00", | ||||||
|  |                 self.day.replace('-', ""), | ||||||
|  |                 time_three_hours_later | ||||||
|  |             ))); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let tripdetails = self.trip_details(db).await; | ||||||
|  |         let mut name = String::new(); | ||||||
|  |         if self.is_cancelled() { | ||||||
|  |             name.push_str("ABGESAGT"); | ||||||
|  |             if let Some(notes) = &tripdetails.notes { | ||||||
|  |                 if !notes.is_empty() { | ||||||
|  |                     name.push_str(&format!(" (Grund: {notes})")) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             name.push_str("! :-( "); | ||||||
|  |         } | ||||||
|  |         name.push_str(&format!("{} ", self.name)); | ||||||
|  |  | ||||||
|  |         if let Some(triptype) = tripdetails.triptype(db).await { | ||||||
|  |             name.push_str(&format!("• {} ", triptype.name)) | ||||||
|  |         } | ||||||
|  |         vevent.push(Summary::new(name)); | ||||||
|  |         vevent | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										104
									
								
								src/model/personal/equatorprice.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,104 @@ | |||||||
|  | use crate::model::{logbook::Logbook, stat::Stat, user::User}; | ||||||
|  | use serde::Serialize; | ||||||
|  |  | ||||||
|  | #[derive(Serialize, PartialEq, Debug)] | ||||||
|  | pub(crate) enum Level { | ||||||
|  |     None, | ||||||
|  |     Bronze, | ||||||
|  |     Silver, | ||||||
|  |     Gold, | ||||||
|  |     Diamond, | ||||||
|  |     Done, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Level { | ||||||
|  |     fn required_km(&self) -> i32 { | ||||||
|  |         match self { | ||||||
|  |             Level::Bronze => 40_000, | ||||||
|  |             Level::Silver => 80_000, | ||||||
|  |             Level::Gold => 100_000, | ||||||
|  |             Level::Diamond => 200_000, | ||||||
|  |             Level::Done => 0, | ||||||
|  |             Level::None => 0, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fn next_level(km: i32) -> Self { | ||||||
|  |         if km < Level::Bronze.required_km() { | ||||||
|  |             Level::Bronze | ||||||
|  |         } else if km < Level::Silver.required_km() { | ||||||
|  |             Level::Silver | ||||||
|  |         } else if km < Level::Gold.required_km() { | ||||||
|  |             Level::Gold | ||||||
|  |         } else if km < Level::Diamond.required_km() { | ||||||
|  |             Level::Diamond | ||||||
|  |         } else { | ||||||
|  |             Level::Done | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) fn curr_level(km: i32) -> Self { | ||||||
|  |         if km < Level::Bronze.required_km() { | ||||||
|  |             Level::None | ||||||
|  |         } else if km < Level::Silver.required_km() { | ||||||
|  |             Level::Bronze | ||||||
|  |         } else if km < Level::Gold.required_km() { | ||||||
|  |             Level::Silver | ||||||
|  |         } else if km < Level::Diamond.required_km() { | ||||||
|  |             Level::Gold | ||||||
|  |         } else { | ||||||
|  |             Level::Diamond | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) fn desc(&self) -> &str { | ||||||
|  |         match self { | ||||||
|  |             Level::Bronze => "Bronze", | ||||||
|  |             Level::Silver => "Silber", | ||||||
|  |             Level::Gold => "Gold", | ||||||
|  |             Level::Diamond => "Diamant", | ||||||
|  |             Level::Done => "", | ||||||
|  |             Level::None => "-", | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Serialize)] | ||||||
|  | pub(crate) struct Next { | ||||||
|  |     level: Level, | ||||||
|  |     desc: String, | ||||||
|  |     missing_km: i32, | ||||||
|  |     required_km: i32, | ||||||
|  |     rowed_km: i32, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Next { | ||||||
|  |     pub(crate) fn new(rowed_km: i32) -> Self { | ||||||
|  |         let level = Level::next_level(rowed_km); | ||||||
|  |         let required_km = level.required_km(); | ||||||
|  |         let missing_km = required_km - rowed_km; | ||||||
|  |         Self { | ||||||
|  |             desc: level.desc().to_string(), | ||||||
|  |             level, | ||||||
|  |             missing_km, | ||||||
|  |             required_km, | ||||||
|  |             rowed_km, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub(crate) async fn new_level_with_last_log( | ||||||
|  |     db: &mut sqlx::Transaction<'_, sqlx::Sqlite>, | ||||||
|  |     user: &User, | ||||||
|  | ) -> Option<String> { | ||||||
|  |     let rowed_km = Stat::total_km_tx(db, user).await.rowed_km; | ||||||
|  |  | ||||||
|  |     if let Some(last_logbookentry) = Logbook::completed_with_user_tx(db, user).await.last() { | ||||||
|  |         let last_trip_km = last_logbookentry.logbook.distance_in_km.unwrap(); | ||||||
|  |         if Level::curr_level(rowed_km) != Level::curr_level(rowed_km - last_trip_km as i32) { | ||||||
|  |             return Some(Level::curr_level(rowed_km).desc().to_string()); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     None | ||||||
|  | } | ||||||
							
								
								
									
										52
									
								
								src/model/personal/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,52 @@ | |||||||
|  | use chrono::{Datelike, Local}; | ||||||
|  | use equatorprice::Level; | ||||||
|  | use serde::Serialize; | ||||||
|  | use sqlx::SqlitePool; | ||||||
|  |  | ||||||
|  | use super::{logbook::Logbook, stat::Stat, user::User}; | ||||||
|  |  | ||||||
|  | pub(crate) mod cal; | ||||||
|  | pub(crate) mod equatorprice; | ||||||
|  | pub(crate) mod rowingbadge; | ||||||
|  |  | ||||||
|  | #[derive(Serialize)] | ||||||
|  | pub(crate) struct Achievements { | ||||||
|  |     pub(crate) equatorprice: equatorprice::Next, | ||||||
|  |     pub(crate) curr_equatorprice_name: String, | ||||||
|  |     pub(crate) new_equatorprice_this_season: bool, | ||||||
|  |     pub(crate) rowingbadge: Option<rowingbadge::Status>, | ||||||
|  |     pub(crate) all_time_km: i32, | ||||||
|  |     pub(crate) year_first_mentioned: Option<i32>, | ||||||
|  |     pub(crate) year_last_mentioned: Option<i32>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Achievements { | ||||||
|  |     pub(crate) async fn for_user(db: &SqlitePool, user: &User) -> Self { | ||||||
|  |         let rowed_km = Stat::total_km(db, user).await.rowed_km; | ||||||
|  |         let rowed_km_this_season = if Local::now().month() == 1 { | ||||||
|  |             Stat::person(db, Some(Local::now().year() - 1), user) | ||||||
|  |                 .await | ||||||
|  |                 .rowed_km | ||||||
|  |                 + Stat::person(db, Some(Local::now().year()), user) | ||||||
|  |                     .await | ||||||
|  |                     .rowed_km | ||||||
|  |         } else { | ||||||
|  |             Stat::person(db, Some(Local::now().year()), user) | ||||||
|  |                 .await | ||||||
|  |                 .rowed_km | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         let new_equatorprice_this_season = | ||||||
|  |             Level::curr_level(rowed_km) != Level::curr_level(rowed_km - rowed_km_this_season); | ||||||
|  |  | ||||||
|  |         Self { | ||||||
|  |             equatorprice: equatorprice::Next::new(rowed_km), | ||||||
|  |             curr_equatorprice_name: equatorprice::Level::curr_level(rowed_km).desc().to_string(), | ||||||
|  |             new_equatorprice_this_season, | ||||||
|  |             rowingbadge: rowingbadge::Status::for_user(db, user).await, | ||||||
|  |             all_time_km: rowed_km, | ||||||
|  |             year_first_mentioned: Logbook::year_first_logbook_entry(db, user).await, | ||||||
|  |             year_last_mentioned: Logbook::year_last_logbook_entry(db, user).await, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										223
									
								
								src/model/personal/rowingbadge.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,223 @@ | |||||||
|  | use std::cmp; | ||||||
|  |  | ||||||
|  | use chrono::{Datelike, Local, NaiveDate}; | ||||||
|  | use serde::Serialize; | ||||||
|  | use sqlx::{Acquire, Sqlite, SqlitePool, Transaction}; | ||||||
|  |  | ||||||
|  | use crate::model::{ | ||||||
|  |     logbook::{Filter, Logbook, LogbookWithBoatAndRowers}, | ||||||
|  |     stat::Stat, | ||||||
|  |     user::User, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | enum AgeBracket { | ||||||
|  |     Till14, | ||||||
|  |     From14Till18, | ||||||
|  |     From19Till30, | ||||||
|  |     From31Till60, | ||||||
|  |     From61Till75, | ||||||
|  |     From76, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl AgeBracket { | ||||||
|  |     fn cat(&self) -> &str { | ||||||
|  |         match self { | ||||||
|  |             AgeBracket::Till14 => "Schülerinnen und Schüler bis 14 Jahre", | ||||||
|  |             AgeBracket::From14Till18 => "Juniorinnen und Junioren, Para-Ruderer bis 18 Jahre", | ||||||
|  |             AgeBracket::From19Till30 => "Frauen und Männer, Para-Ruderer bis 30 Jahre", | ||||||
|  |             AgeBracket::From31Till60 => "Frauen und Männer, Para-Ruderer von 31 bis 60 Jahre", | ||||||
|  |             AgeBracket::From61Till75 => "Frauen und Männer, Para-Ruderer von 61 bis 75 Jahre", | ||||||
|  |             AgeBracket::From76 => "Frauen und Männer, Para-Ruderer ab 76 Jahre", | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fn dist_in_km(&self) -> i32 { | ||||||
|  |         match self { | ||||||
|  |             AgeBracket::Till14 => 500, | ||||||
|  |             AgeBracket::From14Till18 => 1000, | ||||||
|  |             AgeBracket::From19Till30 => 1200, | ||||||
|  |             AgeBracket::From31Till60 => 1000, | ||||||
|  |             AgeBracket::From61Till75 => 800, | ||||||
|  |             AgeBracket::From76 => 600, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fn required_dist_multi_day_in_km(&self) -> i32 { | ||||||
|  |         match self { | ||||||
|  |             AgeBracket::Till14 => 60, | ||||||
|  |             AgeBracket::From14Till18 => 60, | ||||||
|  |             AgeBracket::From19Till30 => 80, | ||||||
|  |             AgeBracket::From31Till60 => 80, | ||||||
|  |             AgeBracket::From61Till75 => 80, | ||||||
|  |             AgeBracket::From76 => 80, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fn required_dist_single_day_in_km(&self) -> i32 { | ||||||
|  |         match self { | ||||||
|  |             AgeBracket::Till14 => 30, | ||||||
|  |             AgeBracket::From14Till18 => 30, | ||||||
|  |             AgeBracket::From19Till30 => 40, | ||||||
|  |             AgeBracket::From31Till60 => 40, | ||||||
|  |             AgeBracket::From61Till75 => 40, | ||||||
|  |             AgeBracket::From76 => 40, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl TryFrom<&User> for AgeBracket { | ||||||
|  |     type Error = String; | ||||||
|  |  | ||||||
|  |     fn try_from(value: &User) -> Result<Self, Self::Error> { | ||||||
|  |         let Some(birthdate) = value.birthdate.clone() else { | ||||||
|  |             return Err("User has no birthdate".to_string()); | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         let Ok(birthdate) = NaiveDate::parse_from_str(&birthdate, "%Y-%m-%d") else { | ||||||
|  |             return Err("Birthdate in wrong format...".to_string()); | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         let today = Local::now().date_naive(); | ||||||
|  |  | ||||||
|  |         let age = today.year() - birthdate.year(); | ||||||
|  |         if age <= 14 { | ||||||
|  |             Ok(AgeBracket::Till14) | ||||||
|  |         } else if age <= 18 { | ||||||
|  |             Ok(AgeBracket::From14Till18) | ||||||
|  |         } else if age <= 30 { | ||||||
|  |             Ok(AgeBracket::From19Till30) | ||||||
|  |         } else if age <= 60 { | ||||||
|  |             Ok(AgeBracket::From31Till60) | ||||||
|  |         } else if age <= 75 { | ||||||
|  |             Ok(AgeBracket::From61Till75) | ||||||
|  |         } else { | ||||||
|  |             Ok(AgeBracket::From76) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Serialize)] | ||||||
|  | pub(crate) struct Status { | ||||||
|  |     pub(crate) year: i32, | ||||||
|  |     rowed_km: i32, | ||||||
|  |     category: String, | ||||||
|  |     required_km: i32, | ||||||
|  |     missing_km: i32, | ||||||
|  |     multi_day_trips_over_required_distance: Vec<LogbookWithBoatAndRowers>, | ||||||
|  |     multi_day_trips_required_distance: i32, | ||||||
|  |     single_day_trips_over_required_distance: Vec<LogbookWithBoatAndRowers>, | ||||||
|  |     single_day_trips_required_distance: i32, | ||||||
|  |     achieved: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Status { | ||||||
|  |     fn calc( | ||||||
|  |         agebracket: &AgeBracket, | ||||||
|  |         rowed_km: i32, | ||||||
|  |         single_day_trips_over_required_distance: usize, | ||||||
|  |         multi_day_trips_over_required_distance: usize, | ||||||
|  |         year: i32, | ||||||
|  |     ) -> Self { | ||||||
|  |         let category = agebracket.cat().to_string(); | ||||||
|  |  | ||||||
|  |         let required_km = agebracket.dist_in_km(); | ||||||
|  |         let missing_km = cmp::max(required_km - rowed_km, 0); | ||||||
|  |  | ||||||
|  |         let achieved = missing_km == 0 | ||||||
|  |             && (multi_day_trips_over_required_distance >= 1 | ||||||
|  |                 || single_day_trips_over_required_distance >= 2); | ||||||
|  |  | ||||||
|  |         Self { | ||||||
|  |             year, | ||||||
|  |             rowed_km, | ||||||
|  |             category, | ||||||
|  |             required_km, | ||||||
|  |             missing_km, | ||||||
|  |             multi_day_trips_over_required_distance: vec![], | ||||||
|  |             single_day_trips_over_required_distance: vec![], | ||||||
|  |             multi_day_trips_required_distance: agebracket.required_dist_multi_day_in_km(), | ||||||
|  |             single_day_trips_required_distance: agebracket.required_dist_single_day_in_km(), | ||||||
|  |             achieved, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) async fn for_user_tx(db: &mut Transaction<'_, Sqlite>, user: &User) -> Option<Self> { | ||||||
|  |         let Ok(agebracket) = AgeBracket::try_from(user) else { | ||||||
|  |             return None; | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         let year = if Local::now().month() == 1 { | ||||||
|  |             Local::now().year() - 1 | ||||||
|  |         } else { | ||||||
|  |             Local::now().year() | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         let rowed_km = Stat::person_tx(db, Some(year), user).await.rowed_km; | ||||||
|  |         let single_day_trips_over_required_distance = | ||||||
|  |             Logbook::completed_wanderfahrten_with_user_over_km_in_year_tx( | ||||||
|  |                 db, | ||||||
|  |                 user, | ||||||
|  |                 agebracket.required_dist_single_day_in_km(), | ||||||
|  |                 year, | ||||||
|  |                 Filter::SingleDayOnly, | ||||||
|  |             ) | ||||||
|  |             .await; | ||||||
|  |         let multi_day_trips_over_required_distance = | ||||||
|  |             Logbook::completed_wanderfahrten_with_user_over_km_in_year_tx( | ||||||
|  |                 db, | ||||||
|  |                 user, | ||||||
|  |                 agebracket.required_dist_multi_day_in_km(), | ||||||
|  |                 year, | ||||||
|  |                 Filter::MultiDayOnly, | ||||||
|  |             ) | ||||||
|  |             .await; | ||||||
|  |  | ||||||
|  |         let ret = Self::calc( | ||||||
|  |             &agebracket, | ||||||
|  |             rowed_km, | ||||||
|  |             single_day_trips_over_required_distance.len(), | ||||||
|  |             multi_day_trips_over_required_distance.len(), | ||||||
|  |             year, | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         Some(Self { | ||||||
|  |             multi_day_trips_over_required_distance, | ||||||
|  |             single_day_trips_over_required_distance, | ||||||
|  |             ..ret | ||||||
|  |         }) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) async fn for_user(db: &SqlitePool, user: &User) -> Option<Self> { | ||||||
|  |         let mut tx = db.begin().await.unwrap(); | ||||||
|  |         let ret = Self::for_user_tx(&mut tx, user).await; | ||||||
|  |         tx.commit().await.unwrap(); | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) async fn completed_with_last_log( | ||||||
|  |         db: &mut Transaction<'_, Sqlite>, | ||||||
|  |         user: &User, | ||||||
|  |     ) -> bool { | ||||||
|  |         if let Some(status) = Self::for_user_tx(db, user).await { | ||||||
|  |             // if user has agebracket... | ||||||
|  |             if status.achieved { | ||||||
|  |                 // ... and has achieved the 'Fahrtenabzeichen' | ||||||
|  |                 let mut without_last = db.begin().await.unwrap(); | ||||||
|  |                 let last = Logbook::completed_with_user_tx(&mut without_last, user).await; | ||||||
|  |                 let last = last.last().unwrap(); | ||||||
|  |                 sqlx::query!("DELETE FROM logbook WHERE id=?", last.logbook.id) | ||||||
|  |                     .execute(&mut *without_last) | ||||||
|  |                     .await | ||||||
|  |                     .unwrap(); //Okay, because we can only create a Logbook of a valid id | ||||||
|  |  | ||||||
|  |                 let without_last_entry = Self::for_user_tx(&mut without_last, user).await.unwrap(); | ||||||
|  |                 if !without_last_entry.achieved { | ||||||
|  |                     // ... and this wasn't the case before the last logentry | ||||||
|  |                     return true; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         false | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										542
									
								
								src/model/planned/event.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,542 @@ | |||||||
|  | use std::io::Write; | ||||||
|  |  | ||||||
|  | use chrono::NaiveDate; | ||||||
|  | use ics::ICalendar; | ||||||
|  | use serde::Serialize; | ||||||
|  | use sqlx::{FromRow, Row, SqlitePool}; | ||||||
|  |  | ||||||
|  | use super::{tripdetails::TripDetails, triptype::TripType}; | ||||||
|  | use crate::model::{ | ||||||
|  |     log::Log, | ||||||
|  |     notification::Notification, | ||||||
|  |     role::Role, | ||||||
|  |     user::{EventUser, User}, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /// DB structure of an event | ||||||
|  | #[derive(Serialize, Clone, FromRow, Debug, PartialEq)] | ||||||
|  | pub struct Event { | ||||||
|  |     pub id: i64, | ||||||
|  |     pub name: String, | ||||||
|  |     pub(crate) planned_amount_cox: i64, | ||||||
|  |     trip_details_id: i64, | ||||||
|  |     pub planned_starting_time: String, | ||||||
|  |     pub(crate) max_people: i64, | ||||||
|  |     pub day: String, | ||||||
|  |     pub notes: Option<String>, | ||||||
|  |     pub allow_guests: bool, | ||||||
|  |     trip_type_id: Option<i64>, | ||||||
|  |     pub(crate) always_show: bool, | ||||||
|  |     pub(crate) is_locked: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Serialize, Debug)] | ||||||
|  | pub struct EventWithDetails { | ||||||
|  |     #[serde(flatten)] | ||||||
|  |     pub event: Event, | ||||||
|  |     trip_type: Option<TripType>, | ||||||
|  |     tripdetails: TripDetails, | ||||||
|  |     cox_needed: bool, | ||||||
|  |     cancelled: bool, | ||||||
|  |     cox: Vec<Registration>, | ||||||
|  |     rower: Vec<Registration>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | //TODO: move to appropriate place | ||||||
|  | #[derive(Serialize, Debug)] | ||||||
|  | pub struct Registration { | ||||||
|  |     pub name: String, | ||||||
|  |     pub registered_at: String, | ||||||
|  |     pub is_guest: bool, | ||||||
|  |     pub is_real_guest: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Registration { | ||||||
|  |     pub async fn all_rower(db: &SqlitePool, trip_details_id: i64) -> Vec<Registration> { | ||||||
|  |         sqlx::query( | ||||||
|  |             &format!( | ||||||
|  |             r#" | ||||||
|  | SELECT | ||||||
|  |     (SELECT name FROM user WHERE user_trip.user_id = user.id) as "name?",  | ||||||
|  |     user_note, | ||||||
|  |     user_id, | ||||||
|  |     (SELECT created_at FROM user WHERE user_trip.user_id = user.id) as registered_at, | ||||||
|  |     (SELECT EXISTS (SELECT 1 FROM user_role WHERE user_role.user_id = user_trip.user_id AND user_role.role_id = (SELECT id FROM role WHERE name = 'scheckbuch'))) as is_guest | ||||||
|  | FROM user_trip WHERE trip_details_id = {}  | ||||||
|  |         "#,trip_details_id), | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap() | ||||||
|  |         .into_iter() | ||||||
|  |         .map(|r| | ||||||
|  |             Registration { | ||||||
|  |             name: r.get::<Option<String>, usize>(0).or(r.get::<Option<String>, usize>(1)).unwrap(), //Ok, either name or user_note needs to be set | ||||||
|  |             registered_at: r.get::<String,usize>(3), | ||||||
|  |             is_guest: r.get::<bool, usize>(4), | ||||||
|  |             is_real_guest: r.get::<Option<i64>, usize>(2).is_none(), | ||||||
|  |         }) | ||||||
|  |         .collect() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn all_cox(db: &SqlitePool, event_id: i64) -> Vec<Registration> { | ||||||
|  |         //TODO: switch to join | ||||||
|  |         sqlx::query!( | ||||||
|  |             " | ||||||
|  | SELECT | ||||||
|  |     (SELECT name FROM user WHERE cox_id = id) as name, | ||||||
|  |     (SELECT created_at FROM user WHERE cox_id = id) as registered_at | ||||||
|  | FROM trip WHERE planned_event_id = ? | ||||||
|  |         ", | ||||||
|  |             event_id | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap() | ||||||
|  |         .into_iter() | ||||||
|  |         .map(|r| Registration { | ||||||
|  |             name: r.name.unwrap(), | ||||||
|  |             registered_at: r.registered_at.unwrap(), | ||||||
|  |             is_guest: false, | ||||||
|  |             is_real_guest: false, | ||||||
|  |         }) | ||||||
|  |         .collect() //Okay, as Event can only be created with proper DB backing | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug)] | ||||||
|  | pub struct EventUpdate<'a> { | ||||||
|  |     pub name: &'a str, | ||||||
|  |     pub planned_amount_cox: i32, | ||||||
|  |     pub max_people: i32, | ||||||
|  |     pub notes: Option<&'a str>, | ||||||
|  |     pub always_show: bool, | ||||||
|  |     pub is_locked: bool, | ||||||
|  |     pub trip_type_id: Option<i64>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl EventUpdate<'_> { | ||||||
|  |     fn cancelled(&self) -> bool { | ||||||
|  |         self.max_people == -1 | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Event { | ||||||
|  |     pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  | SELECT | ||||||
|  |     planned_event.id, planned_event.name, planned_amount_cox, trip_details_id, planned_starting_time, max_people, day, notes, allow_guests, trip_type_id, always_show, is_locked | ||||||
|  | FROM planned_event  | ||||||
|  | INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id | ||||||
|  | WHERE planned_event.id like ? | ||||||
|  |         ", | ||||||
|  |             id | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db) | ||||||
|  |         .await | ||||||
|  |         .ok() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) async fn trip_type(&self, db: &SqlitePool) -> Option<TripType> { | ||||||
|  |         if let Some(trip_type_id) = self.trip_type_id { | ||||||
|  |             TripType::find_by_id(db, trip_type_id).await | ||||||
|  |         } else { | ||||||
|  |             None | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn get_pinned_for_day(db: &SqlitePool, day: NaiveDate) -> Vec<EventWithDetails> { | ||||||
|  |         let mut events = Self::get_for_day(db, day).await; | ||||||
|  |         events.retain(|e| e.event.always_show); | ||||||
|  |         events | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn get_for_day(db: &SqlitePool, day: NaiveDate) -> Vec<EventWithDetails> { | ||||||
|  |         let day = format!("{day}"); | ||||||
|  |         let events = sqlx::query_as!( | ||||||
|  |             Event, | ||||||
|  |             "SELECT planned_event.id, planned_event.name, planned_amount_cox, trip_details_id, planned_starting_time, always_show, max_people, day, notes, allow_guests, trip_type_id, is_locked | ||||||
|  | FROM planned_event | ||||||
|  | INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id | ||||||
|  | WHERE day=?", | ||||||
|  |         day | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); //TODO: fixme | ||||||
|  |  | ||||||
|  |         let mut ret = Vec::new(); | ||||||
|  |         for event in events { | ||||||
|  |             let cox = Registration::all_cox(db, event.id).await; | ||||||
|  |             let mut trip_type = None; | ||||||
|  |             if let Some(trip_type_id) = event.trip_type_id { | ||||||
|  |                 trip_type = TripType::find_by_id(db, trip_type_id).await; | ||||||
|  |             } | ||||||
|  |             let tripdetails = TripDetails::find_by_id(db, event.trip_details_id) | ||||||
|  |                 .await | ||||||
|  |                 .expect("db constraints"); | ||||||
|  |             ret.push(EventWithDetails { | ||||||
|  |                 cox_needed: event.planned_amount_cox > cox.len() as i64, | ||||||
|  |                 cox, | ||||||
|  |                 rower: Registration::all_rower(db, event.trip_details_id).await, | ||||||
|  |                 cancelled: tripdetails.cancelled(), | ||||||
|  |                 tripdetails, | ||||||
|  |                 event, | ||||||
|  |                 trip_type, | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn all(db: &SqlitePool) -> Vec<Event> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Event, | ||||||
|  |             "SELECT planned_event.id, planned_event.name, planned_amount_cox, trip_details_id, planned_starting_time, always_show, max_people, day, notes, allow_guests, trip_type_id, is_locked | ||||||
|  | FROM planned_event | ||||||
|  | INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id", | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap() //TODO: fixme | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn all_with_user(db: &SqlitePool, user: &User) -> Vec<Event> { | ||||||
|  |         let mut ret = Vec::new(); | ||||||
|  |         let events = Self::all(db).await; | ||||||
|  |         for event in events { | ||||||
|  |             if event.is_rower_registered(db, user).await || event.is_cox_registered(db, user).await | ||||||
|  |             { | ||||||
|  |                 ret.push(event); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     //TODO: add tests | ||||||
|  |     pub async fn is_rower_registered(&self, db: &SqlitePool, user: &User) -> bool { | ||||||
|  |         let is_rower = sqlx::query!( | ||||||
|  |             "SELECT count(*) as amount | ||||||
|  |             FROM user_trip | ||||||
|  |             WHERE trip_details_id = | ||||||
|  |                 (SELECT trip_details_id FROM planned_event WHERE id = ?) | ||||||
|  |             AND user_id = ?", | ||||||
|  |             self.id, | ||||||
|  |             user.id | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); //Okay, bc planned_event can only be created with proper DB backing | ||||||
|  |         is_rower.amount > 0 | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn is_cox_registered(&self, db: &SqlitePool, user: &User) -> bool { | ||||||
|  |         let is_rower = sqlx::query!( | ||||||
|  |             "SELECT count(*) as amount | ||||||
|  |             FROM trip  | ||||||
|  |             WHERE planned_event_id = ? | ||||||
|  |             AND cox_id = ?", | ||||||
|  |             self.id, | ||||||
|  |             user.id | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); //Okay, bc planned_event can only be created with proper DB backing | ||||||
|  |         is_rower.amount > 0 | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn find_by_trip_details(db: &SqlitePool, tripdetails_id: i64) -> Option<Self> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  | SELECT planned_event.id, planned_event.name, planned_amount_cox, trip_details_id, planned_starting_time, always_show, max_people, day, notes, allow_guests, trip_type_id, is_locked | ||||||
|  | FROM planned_event  | ||||||
|  | INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id | ||||||
|  | WHERE trip_details.id=? | ||||||
|  |         ", | ||||||
|  |             tripdetails_id | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db) | ||||||
|  |         .await | ||||||
|  |         .ok() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async fn advertise(db: &SqlitePool, day: &str, planned_starting_time: &str, name: &str) { | ||||||
|  |         let donau = Role::find_by_name(db, "Donau Linz").await.unwrap(); | ||||||
|  |         Notification::create_for_role( | ||||||
|  |                 db, | ||||||
|  |                 &donau, | ||||||
|  |                 &format!("Am {} um {} wurde ein neues Event angelegt: {} Wir freuen uns wenn du dabei mitmachst, die Anmeldung ist ab sofort offen :-)", day, planned_starting_time, name), | ||||||
|  |                 "Neues Event", | ||||||
|  |                 Some(&format!("/planned#{day}")), | ||||||
|  |                 None, | ||||||
|  |             ) | ||||||
|  |             .await; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn create( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         user: &EventUser, | ||||||
|  |         name: &str, | ||||||
|  |         planned_amount_cox: i32, | ||||||
|  |         always_show: bool, | ||||||
|  |         trip_details: &TripDetails, | ||||||
|  |     ) { | ||||||
|  |         if trip_details.always_show { | ||||||
|  |             Self::advertise( | ||||||
|  |                 db, | ||||||
|  |                 &trip_details.day, | ||||||
|  |                 &trip_details.planned_starting_time, | ||||||
|  |                 name, | ||||||
|  |             ) | ||||||
|  |             .await; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if always_show && !trip_details.always_show { | ||||||
|  |             trip_details.set_always_show(db, true).await; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         sqlx::query!( | ||||||
|  |             "INSERT INTO planned_event(name, planned_amount_cox, trip_details_id) VALUES(?, ?, ?)", | ||||||
|  |             name, | ||||||
|  |             planned_amount_cox, | ||||||
|  |             trip_details.id, | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); //Okay, as TripDetails can only be created with proper DB backing | ||||||
|  |  | ||||||
|  |         Log::create( | ||||||
|  |             db, | ||||||
|  |             format!( | ||||||
|  |                 "{} created event {} on {} at {}.", | ||||||
|  |                 user.user.name, name, trip_details.day, trip_details.planned_starting_time | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |         .await; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     //TODO: create unit test | ||||||
|  |     pub async fn update(&self, db: &SqlitePool, user: &EventUser, update: &EventUpdate<'_>) { | ||||||
|  |         sqlx::query!( | ||||||
|  |             "UPDATE planned_event SET name = ?, planned_amount_cox = ? WHERE id = ?", | ||||||
|  |             update.name, | ||||||
|  |             update.planned_amount_cox, | ||||||
|  |             self.id | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); //Okay, as planned_event can only be created with proper DB backing | ||||||
|  |  | ||||||
|  |         let tripdetails = self.trip_details(db).await; | ||||||
|  |         let was_already_cancelled = tripdetails.cancelled(); | ||||||
|  |  | ||||||
|  |         sqlx::query!( | ||||||
|  |             "UPDATE trip_details SET max_people = ?, notes = ?, always_show = ?, is_locked = ?, trip_type_id = ? WHERE id = ?", | ||||||
|  |             update.max_people, | ||||||
|  |             update.notes, | ||||||
|  |             update.always_show, | ||||||
|  |             update.is_locked, | ||||||
|  |             update.trip_type_id, | ||||||
|  |             self.trip_details_id | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); //Okay, as planned_event can only be created with proper DB backing | ||||||
|  |  | ||||||
|  |         Log::create( | ||||||
|  |             db, | ||||||
|  |             format!( | ||||||
|  |                 "{} updated the event {} on {} at {} from {:?} to {:?}", | ||||||
|  |                 user.user.name, | ||||||
|  |                 self.name, | ||||||
|  |                 tripdetails.day, | ||||||
|  |                 tripdetails.planned_starting_time, | ||||||
|  |                 self, | ||||||
|  |                 update | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |         .await; | ||||||
|  |  | ||||||
|  |         if !tripdetails.always_show && update.always_show { | ||||||
|  |             Self::advertise( | ||||||
|  |                 db, | ||||||
|  |                 &tripdetails.day, | ||||||
|  |                 &tripdetails.planned_starting_time, | ||||||
|  |                 update.name, | ||||||
|  |             ) | ||||||
|  |             .await; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if update.cancelled() && !was_already_cancelled { | ||||||
|  |             let coxes = Registration::all_cox(db, self.id).await; | ||||||
|  |             for user in coxes { | ||||||
|  |                 if let Some(user) = User::find_by_name(db, &user.name).await { | ||||||
|  |                     let notes = match update.notes { | ||||||
|  |                         Some(n) if !n.is_empty() => format!("Grund der Absage: {n}"), | ||||||
|  |                         _ => String::from(""), | ||||||
|  |                     }; | ||||||
|  |                     Notification::create( | ||||||
|  |                         db, | ||||||
|  |                         &user, | ||||||
|  |                         &format!( | ||||||
|  |                             "Die Ausfahrt {} am {} um {} wurde abgesagt. {}", | ||||||
|  |                             self.name, self.day, self.planned_starting_time, notes | ||||||
|  |                         ), | ||||||
|  |                         "Absage Ausfahrt", | ||||||
|  |                         None, | ||||||
|  |                         Some(&format!("remove_trip_by_event:{}", self.id)), | ||||||
|  |                     ) | ||||||
|  |                     .await; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             let rower = Registration::all_rower(db, self.trip_details_id).await; | ||||||
|  |             for user in rower { | ||||||
|  |                 if let Some(user) = User::find_by_name(db, &user.name).await { | ||||||
|  |                     let notes = match update.notes { | ||||||
|  |                         Some(n) if !n.is_empty() => format!("Grund der Absage: {n}"), | ||||||
|  |                         _ => String::from(""), | ||||||
|  |                     }; | ||||||
|  |  | ||||||
|  |                     Notification::create( | ||||||
|  |                         db, | ||||||
|  |                         &user, | ||||||
|  |                         &format!( | ||||||
|  |                             "Die Ausfahrt {} am {} um {} wurde abgesagt. {}", | ||||||
|  |                             self.name, self.day, self.planned_starting_time, notes | ||||||
|  |                         ), | ||||||
|  |                         "Absage Ausfahrt", | ||||||
|  |                         None, | ||||||
|  |                         Some(&format!( | ||||||
|  |                             "remove_user_trip_with_trip_details_id:{}", | ||||||
|  |                             tripdetails.id | ||||||
|  |                         )), | ||||||
|  |                     ) | ||||||
|  |                     .await; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         if !update.cancelled() && was_already_cancelled { | ||||||
|  |             Notification::delete_by_action( | ||||||
|  |                 db, | ||||||
|  |                 &format!("remove_user_trip_with_trip_details_id:{}", tripdetails.id), | ||||||
|  |             ) | ||||||
|  |             .await; | ||||||
|  |             Notification::delete_by_action(db, &format!("remove_trip_by_event:{}", self.id)).await; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn delete(&self, db: &SqlitePool) -> Result<(), String> { | ||||||
|  |         if !Registration::all_rower(db, self.trip_details_id) | ||||||
|  |             .await | ||||||
|  |             .is_empty() | ||||||
|  |         { | ||||||
|  |             return Err( | ||||||
|  |                 "Event kann nicht gelöscht werden, weil mind. 1 Ruderer angemeldet ist.".into(), | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |         if !Registration::all_cox(db, self.trip_details_id) | ||||||
|  |             .await | ||||||
|  |             .is_empty() | ||||||
|  |         { | ||||||
|  |             return Err( | ||||||
|  |                 "Event kann nicht gelöscht werden, weil mind. 1 Steuerperson angemeldet ist." | ||||||
|  |                     .into(), | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         sqlx::query!("DELETE FROM planned_event WHERE id = ?", self.id) | ||||||
|  |             .execute(db) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); //Okay, as Event can only be created with proper DB backing | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub fn is_cancelled(&self) -> bool { | ||||||
|  |         self.max_people == -1 | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn get_ics_feed(db: &SqlitePool) -> String { | ||||||
|  |         let mut calendar = ICalendar::new("2.0", "ics-rs"); | ||||||
|  |  | ||||||
|  |         let events = Event::all(db).await; | ||||||
|  |         for event in events { | ||||||
|  |             calendar.add_event(event.get_vevent(db).await); | ||||||
|  |         } | ||||||
|  |         let mut buf = Vec::new(); | ||||||
|  |         write!(&mut buf, "{}", calendar).unwrap(); | ||||||
|  |         String::from_utf8(buf).unwrap() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn trip_details(&self, db: &SqlitePool) -> TripDetails { | ||||||
|  |         TripDetails::find_by_id(db, self.trip_details_id) | ||||||
|  |             .await | ||||||
|  |             .unwrap() //ok, not null in db | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[cfg(test)] | ||||||
|  | mod test { | ||||||
|  |     use crate::{ | ||||||
|  |         model::{ | ||||||
|  |             planned::tripdetails::TripDetails, | ||||||
|  |             user::{EventUser, User}, | ||||||
|  |         }, | ||||||
|  |         testdb, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     use super::Event; | ||||||
|  |     use chrono::Local; | ||||||
|  |     use sqlx::SqlitePool; | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_get_day() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let res = Event::get_for_day(&pool, Local::now().date_naive()).await; | ||||||
|  |         assert_eq!(res.len(), 1); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_create() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |  | ||||||
|  |         let admin = EventUser::new(&pool, &User::find_by_id(&pool, 1).await.unwrap()) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|  |         Event::create(&pool, &admin, "new-event".into(), 2, false, &trip_details).await; | ||||||
|  |  | ||||||
|  |         let res = Event::get_for_day(&pool, Local::now().date_naive()).await; | ||||||
|  |         assert_eq!(res.len(), 2); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_delete() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |         let planned_event = Event::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |  | ||||||
|  |         planned_event.delete(&pool).await.unwrap(); | ||||||
|  |  | ||||||
|  |         let res = Event::get_for_day(&pool, Local::now().date_naive()).await; | ||||||
|  |         assert_eq!(res.len(), 0); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_ics() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let today = Local::now().date_naive().format("%Y%m%d").to_string(); | ||||||
|  |         let actual = Event::get_ics_feed(&pool).await; | ||||||
|  |         assert_eq!( | ||||||
|  |             format!( | ||||||
|  |                 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:ics-rs\r\nBEGIN:VEVENT\r\nUID:event-1@rudernlinz.at\r\nDTSTAMP:19900101T180000\r\nDTSTART:{today}T100000\r\nDTEND:{today}T130000\r\nSUMMARY:test-planned-event \r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" | ||||||
|  |             ), | ||||||
|  |             actual | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										19
									
								
								src/model/planned/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,19 @@ | |||||||
|  | //! This module contains everything for managing planned trips and events. | ||||||
|  | //! `Cox` can create trips, `EventUser` can create events. Rowers can join those. | ||||||
|  |  | ||||||
|  | /// Events can be created by everyone who has the `manage_events` role. They are used if multiple coxes are needed, e.g. for "Fetzenfahrt", "Anrudern", .... Additionally, events are shown in public calendar (e.g. on the website). | ||||||
|  | pub mod event; | ||||||
|  |  | ||||||
|  | /// Trips can be created by every cox. They are "simple", every-day trips. | ||||||
|  | pub mod trip; | ||||||
|  |  | ||||||
|  | /// Extracts the common data for both Trips and Events. Rower can register using this. | ||||||
|  | pub mod tripdetails; | ||||||
|  |  | ||||||
|  | /// Type of the trip | ||||||
|  | pub mod triptype; | ||||||
|  |  | ||||||
|  | /// Associative table between `User` and `TripDetails`. Its functionality should probably move into | ||||||
|  | /// those files. | ||||||
|  | // TODO: make this mod unnecessary | ||||||
|  | pub mod usertrip; | ||||||
							
								
								
									
										79
									
								
								src/model/planned/trip/create.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,79 @@ | |||||||
|  | use super::Trip; | ||||||
|  | use crate::model::{ | ||||||
|  |     log::Log, | ||||||
|  |     notification::Notification, | ||||||
|  |     planned::{tripdetails::TripDetails, triptype::TripType}, | ||||||
|  |     user::{ErgoUser, SteeringUser, User}, | ||||||
|  | }; | ||||||
|  | use sqlx::SqlitePool; | ||||||
|  |  | ||||||
|  | impl Trip { | ||||||
|  |     /// Cox decides to create own trip. | ||||||
|  |     pub async fn new_own(db: &SqlitePool, cox: &SteeringUser, trip_details: TripDetails) { | ||||||
|  |         Self::perform_new(db, &cox.user, trip_details).await | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// ErgoUser decides to create ergo 'trip'. Returns false, if trip is not a ergo-session (and | ||||||
|  |     /// thus User is not allowed to create such a trip) | ||||||
|  |     pub async fn new_own_ergo(db: &SqlitePool, ergo: &ErgoUser, trip_details: TripDetails) -> bool { | ||||||
|  |         if let Some(typ) = trip_details.triptype(db).await { | ||||||
|  |             let allowed_type = TripType::find_by_id(db, 4).await.unwrap(); | ||||||
|  |             if typ == allowed_type { | ||||||
|  |                 Self::perform_new(db, &ergo.user, trip_details).await; | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         false | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async fn perform_new(db: &SqlitePool, user: &User, trip_details: TripDetails) { | ||||||
|  |         let _ = sqlx::query!( | ||||||
|  |             "INSERT INTO trip (cox_id, trip_details_id) VALUES(?, ?)", | ||||||
|  |             user.id, | ||||||
|  |             trip_details.id | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await; | ||||||
|  |  | ||||||
|  |         Log::create(db, format!("{user} created a new trip: {trip_details}")).await; | ||||||
|  |  | ||||||
|  |         Self::notify_trips_same_datetime(db, trip_details, user).await; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     async fn notify_trips_same_datetime(db: &SqlitePool, trip_details: TripDetails, user: &User) { | ||||||
|  |         let same_starting_datetime = TripDetails::find_by_startingdatetime( | ||||||
|  |             db, | ||||||
|  |             trip_details.day, | ||||||
|  |             trip_details.planned_starting_time, | ||||||
|  |         ) | ||||||
|  |         .await; | ||||||
|  |  | ||||||
|  |         for notify in same_starting_datetime { | ||||||
|  |             // don't notify oneself | ||||||
|  |             if notify.id == trip_details.id { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // don't notify people who have cancelled their trip | ||||||
|  |             if notify.cancelled() { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if let Some(trip) = Trip::find_by_trip_details(db, notify.id).await { | ||||||
|  |                 let user_earlier_trip = User::find_by_id(db, trip.cox_id as i32).await.unwrap(); | ||||||
|  |                 Notification::create( | ||||||
|  |                     db, | ||||||
|  |                     &user_earlier_trip, | ||||||
|  |                     &format!( | ||||||
|  |                         "{user} hat eine Ausfahrt zur selben Zeit ({} um {}) wie du erstellt", | ||||||
|  |                         trip.day, trip.planned_starting_time | ||||||
|  |                     ), | ||||||
|  |                     "Neue Ausfahrt zur selben Zeit", | ||||||
|  |                     None, | ||||||
|  |                     None, | ||||||
|  |                 ) | ||||||
|  |                 .await; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										697
									
								
								src/model/planned/trip/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,697 @@ | |||||||
|  | use chrono::{Local, NaiveDate}; | ||||||
|  | use serde::Serialize; | ||||||
|  | use sqlx::SqlitePool; | ||||||
|  |  | ||||||
|  | mod create; | ||||||
|  |  | ||||||
|  | use super::{ | ||||||
|  |     event::{Event, Registration}, | ||||||
|  |     tripdetails::TripDetails, | ||||||
|  |     triptype::TripType, | ||||||
|  |     usertrip::UserTrip, | ||||||
|  | }; | ||||||
|  | use crate::model::{ | ||||||
|  |     log::Log, | ||||||
|  |     notification::Notification, | ||||||
|  |     user::{SteeringUser, User}, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | #[derive(Serialize, Clone, Debug)] | ||||||
|  | pub struct Trip { | ||||||
|  |     pub id: i64, | ||||||
|  |     pub cox_id: i64, | ||||||
|  |     pub cox_name: String, | ||||||
|  |     trip_details_id: Option<i64>, | ||||||
|  |     pub planned_starting_time: String, | ||||||
|  |     pub max_people: i64, | ||||||
|  |     pub day: String, | ||||||
|  |     pub notes: Option<String>, | ||||||
|  |     pub allow_guests: bool, | ||||||
|  |     trip_type_id: Option<i64>, | ||||||
|  |     always_show: bool, | ||||||
|  |     is_locked: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Serialize, Debug)] | ||||||
|  | pub struct TripWithDetails { | ||||||
|  |     #[serde(flatten)] | ||||||
|  |     pub trip: Trip, | ||||||
|  |     pub rower: Vec<Registration>, | ||||||
|  |     trip_type: Option<TripType>, | ||||||
|  |     cancelled: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | pub struct TripUpdate<'a> { | ||||||
|  |     pub cox: &'a User, | ||||||
|  |     pub trip: &'a Trip, | ||||||
|  |     pub max_people: i32, | ||||||
|  |     pub notes: Option<&'a str>, | ||||||
|  |     pub trip_type: Option<i64>, //TODO: Move to `TripType` | ||||||
|  |     pub is_locked: bool, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl TripUpdate<'_> { | ||||||
|  |     fn cancelled(&self) -> bool { | ||||||
|  |         self.max_people == -1 | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl TripWithDetails { | ||||||
|  |     pub async fn from(db: &SqlitePool, trip: Trip) -> Self { | ||||||
|  |         let mut trip_type = None; | ||||||
|  |         if let Some(trip_type_id) = trip.trip_type_id { | ||||||
|  |             trip_type = TripType::find_by_id(db, trip_type_id).await; | ||||||
|  |         } | ||||||
|  |         Self { | ||||||
|  |             rower: Registration::all_rower(db, trip.trip_details_id.unwrap()).await, | ||||||
|  |             trip_type, | ||||||
|  |             cancelled: trip.is_cancelled(), | ||||||
|  |             trip, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Trip { | ||||||
|  |     pub async fn find_by_trip_details(db: &SqlitePool, tripdetails_id: i64) -> Option<Self> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  | SELECT trip.id, cox_id, user.name as cox_name, trip_details_id, planned_starting_time, max_people, day, trip_details.notes, allow_guests, trip_type_id, always_show, is_locked | ||||||
|  | FROM trip  | ||||||
|  | INNER JOIN trip_details ON trip.trip_details_id = trip_details.id | ||||||
|  | INNER JOIN user ON trip.cox_id = user.id | ||||||
|  | WHERE trip_details.id=? | ||||||
|  |         ", | ||||||
|  |             tripdetails_id | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db) | ||||||
|  |         .await | ||||||
|  |         .ok() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) async fn trip_type(&self, db: &SqlitePool) -> Option<TripType> { | ||||||
|  |         if let Some(trip_type_id) = self.trip_type_id { | ||||||
|  |             TripType::find_by_id(db, trip_type_id).await | ||||||
|  |         } else { | ||||||
|  |             None | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn all(db: &SqlitePool) -> Vec<Self> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  | SELECT trip.id, cox_id, user.name as cox_name, trip_details_id, planned_starting_time, max_people, day, trip_details.notes, allow_guests, trip_type_id, always_show, is_locked | ||||||
|  | FROM trip  | ||||||
|  | INNER JOIN trip_details ON trip.trip_details_id = trip_details.id | ||||||
|  | INNER JOIN user ON trip.cox_id = user.id | ||||||
|  | ", | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap() //TODO: fixme | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn all_with_user(db: &SqlitePool, user: &User) -> Vec<Self> { | ||||||
|  |         let mut ret = Vec::new(); | ||||||
|  |         let trips = Self::all(db).await; | ||||||
|  |         for trip in trips { | ||||||
|  |             if user.id == trip.cox_id { | ||||||
|  |                 ret.push(trip.clone()); | ||||||
|  |             } | ||||||
|  |             if let Some(trip_details_id) = trip.trip_details_id { | ||||||
|  |                 if UserTrip::find_by_userid_and_trip_detail_id(db, user.id, trip_details_id) | ||||||
|  |                     .await | ||||||
|  |                     .is_some() | ||||||
|  |                 { | ||||||
|  |                     ret.push(trip); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  | SELECT trip.id, cox_id, user.name as cox_name, trip_details_id, planned_starting_time, max_people, day, trip_details.notes, allow_guests, trip_type_id, always_show, is_locked | ||||||
|  | FROM trip  | ||||||
|  | INNER JOIN trip_details ON trip.trip_details_id = trip_details.id | ||||||
|  | INNER JOIN user ON trip.cox_id = user.id | ||||||
|  | WHERE trip.id=? | ||||||
|  |         ", | ||||||
|  |             id | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db) | ||||||
|  |         .await | ||||||
|  |         .ok() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Cox decides to help in a event. | ||||||
|  |     pub async fn new_join( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         cox: &SteeringUser, | ||||||
|  |         event: &Event, | ||||||
|  |     ) -> Result<(), CoxHelpError> { | ||||||
|  |         if event.is_rower_registered(db, cox).await { | ||||||
|  |             return Err(CoxHelpError::AlreadyRegisteredAsRower); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if event.trip_details(db).await.is_locked { | ||||||
|  |             return Err(CoxHelpError::DetailsLocked); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if event.is_cancelled() { | ||||||
|  |             return Err(CoxHelpError::CanceledEvent); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         match sqlx::query!( | ||||||
|  |             "INSERT INTO trip (cox_id, planned_event_id) VALUES(?, ?)", | ||||||
|  |             cox.id, | ||||||
|  |             event.id | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         { | ||||||
|  |             Ok(_) => Ok(()), | ||||||
|  |             Err(_) => Err(CoxHelpError::AlreadyRegisteredAsCox), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn get_for_today(db: &SqlitePool) -> Vec<TripWithDetails> { | ||||||
|  |         let today = Local::now().date_naive(); | ||||||
|  |         Self::get_for_day(db, today).await | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn get_for_day(db: &SqlitePool, day: NaiveDate) -> Vec<TripWithDetails> { | ||||||
|  |         let day = format!("{day}"); | ||||||
|  |         let trips = sqlx::query_as!( | ||||||
|  |             Trip, | ||||||
|  |             " | ||||||
|  | SELECT trip.id, cox_id, user.name as cox_name, trip_details_id, planned_starting_time, max_people, day, trip_details.notes, allow_guests, trip_type_id, always_show, is_locked | ||||||
|  | FROM trip  | ||||||
|  | INNER JOIN trip_details ON trip.trip_details_id = trip_details.id | ||||||
|  | INNER JOIN user ON trip.cox_id = user.id | ||||||
|  | WHERE day=? | ||||||
|  |     ", | ||||||
|  |             day | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); //Okay, as Trip can only be created with proper DB backing | ||||||
|  |  | ||||||
|  |         let mut ret = Vec::new(); | ||||||
|  |         for trip in trips { | ||||||
|  |             ret.push(TripWithDetails::from(db, trip).await); | ||||||
|  |         } | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// Cox decides to update own trip. | ||||||
|  |     pub async fn update_own( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         update: &TripUpdate<'_>, | ||||||
|  |     ) -> Result<(), TripUpdateError> { | ||||||
|  |         if !update.trip.is_trip_from_user(update.cox.id) { | ||||||
|  |             return Err(TripUpdateError::NotYourTrip); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if update.trip_type != Some(4) && !update.cox.allowed_to_steer(db).await { | ||||||
|  |             return Err(TripUpdateError::TripTypeNotAllowed); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let Some(trip_details_id) = update.trip.trip_details_id else { | ||||||
|  |             return Err(TripUpdateError::TripDetailsDoesNotExist); //TODO: Remove? | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         let tripdetails = TripDetails::find_by_id(db, trip_details_id).await.unwrap(); | ||||||
|  |         let was_already_cancelled = tripdetails.cancelled(); | ||||||
|  |  | ||||||
|  |         let is_locked = if update.cancelled() { | ||||||
|  |             false | ||||||
|  |         } else { | ||||||
|  |             update.is_locked | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         sqlx::query!( | ||||||
|  |             "UPDATE trip_details SET max_people = ?, notes = ?, trip_type_id = ?, is_locked = ? WHERE id = ?", | ||||||
|  |             update.max_people, | ||||||
|  |             update.notes, | ||||||
|  |             update.trip_type, | ||||||
|  |             is_locked, | ||||||
|  |             trip_details_id | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); //Okay, as trip_details can only be created with proper DB backing | ||||||
|  |  | ||||||
|  |         if update.cancelled() && !was_already_cancelled { | ||||||
|  |             let rowers = TripWithDetails::from(db, update.trip.clone()).await.rower; | ||||||
|  |             for user in rowers { | ||||||
|  |                 if let Some(user) = User::find_by_name(db, &user.name).await { | ||||||
|  |                     let notes = match update.notes { | ||||||
|  |                         Some(n) if !n.is_empty() => format!("Grund der Absage: {n}"), | ||||||
|  |                         _ => String::from(""), | ||||||
|  |                     }; | ||||||
|  |  | ||||||
|  |                     Notification::create( | ||||||
|  |                         db, | ||||||
|  |                         &user, | ||||||
|  |                         &format!( | ||||||
|  |                             "Die Ausfahrt von {} am {} um {} wurde abgesagt. {} Bitte gib Bescheid, dass du die Info erhalten hast indem du auf ✓ klickst.", | ||||||
|  |                             update.cox.name, | ||||||
|  |                             update.trip.day, | ||||||
|  |                             update.trip.planned_starting_time, | ||||||
|  |                             notes | ||||||
|  |                         ), | ||||||
|  |                         "Absage Ausfahrt", | ||||||
|  |                         None, | ||||||
|  |                         Some(&format!( | ||||||
|  |                             "remove_user_trip_with_trip_details_id:{}", | ||||||
|  |                             trip_details_id | ||||||
|  |                         )), | ||||||
|  |                     ) | ||||||
|  |                     .await; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             Notification::delete_by_action( | ||||||
|  |                 db, | ||||||
|  |                 &format!("remove_user_trip_with_trip_details_id:{}", trip_details_id), | ||||||
|  |             ) | ||||||
|  |             .await; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if !update.cancelled() && was_already_cancelled { | ||||||
|  |             Notification::delete_by_action( | ||||||
|  |                 db, | ||||||
|  |                 &format!("remove_user_trip_with_trip_details_id:{}", trip_details_id), | ||||||
|  |             ) | ||||||
|  |             .await; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let trip_details = TripDetails::find_by_id(db, trip_details_id).await.unwrap(); | ||||||
|  |         trip_details.check_free_spaces(db).await; | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn trip_details(&self, db: &SqlitePool) -> Option<TripDetails> { | ||||||
|  |         if let Some(trip_details_id) = self.trip_type_id { | ||||||
|  |             return TripDetails::find_by_id(db, trip_details_id).await; | ||||||
|  |         } | ||||||
|  |         None | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn delete_by_planned_event( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         cox: &SteeringUser, | ||||||
|  |         event: &Event, | ||||||
|  |     ) -> Result<(), TripHelpDeleteError> { | ||||||
|  |         if event.trip_details(db).await.is_locked { | ||||||
|  |             return Err(TripHelpDeleteError::DetailsLocked); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         let affected_rows = sqlx::query!( | ||||||
|  |             "DELETE FROM trip WHERE cox_id = ? AND planned_event_id = ?", | ||||||
|  |             cox.id, | ||||||
|  |             event.id | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap() | ||||||
|  |         .rows_affected(); | ||||||
|  |  | ||||||
|  |         if affected_rows == 0 { | ||||||
|  |             return Err(TripHelpDeleteError::CoxNotHelping); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) async fn delete(&self, db: &SqlitePool, user: &User) -> Result<(), TripDeleteError> { | ||||||
|  |         let registered_rower = Registration::all_rower(db, self.trip_details_id.unwrap()).await; | ||||||
|  |         if !registered_rower.is_empty() { | ||||||
|  |             return Err(TripDeleteError::SomebodyAlreadyRegistered); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if !self.is_trip_from_user(user.id) && !user.has_role(db, "admin").await { | ||||||
|  |             return Err(TripDeleteError::NotYourTrip); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Log::create(db, format!("{} deleted trip: {:#?}", user.name, self)).await; | ||||||
|  |  | ||||||
|  |         sqlx::query!("DELETE FROM trip WHERE id = ?", self.id) | ||||||
|  |             .execute(db) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); //TODO: fixme | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fn is_trip_from_user(&self, user_id: i64) -> bool { | ||||||
|  |         self.cox_id == user_id | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) async fn toggle_always_show(&self, db: &SqlitePool) { | ||||||
|  |         if let Some(trip_details) = self.trip_details_id { | ||||||
|  |             let new_state = !self.always_show; | ||||||
|  |             sqlx::query!( | ||||||
|  |                 "UPDATE trip_details SET always_show = ? WHERE id = ?", | ||||||
|  |                 new_state, | ||||||
|  |                 trip_details | ||||||
|  |             ) | ||||||
|  |             .execute(db) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) async fn get_pinned_for_day( | ||||||
|  |         db: &sqlx::Pool<sqlx::Sqlite>, | ||||||
|  |         day: NaiveDate, | ||||||
|  |     ) -> Vec<TripWithDetails> { | ||||||
|  |         let mut trips = Self::get_for_day(db, day).await; | ||||||
|  |         trips.retain(|e| e.trip.always_show); | ||||||
|  |         trips | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub(crate) fn is_cancelled(&self) -> bool { | ||||||
|  |         self.max_people == -1 | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug)] | ||||||
|  | pub enum CoxHelpError { | ||||||
|  |     AlreadyRegisteredAsRower, | ||||||
|  |     AlreadyRegisteredAsCox, | ||||||
|  |     DetailsLocked, | ||||||
|  |     CanceledEvent, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, PartialEq)] | ||||||
|  | pub enum TripHelpDeleteError { | ||||||
|  |     DetailsLocked, | ||||||
|  |     CoxNotHelping, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug, PartialEq)] | ||||||
|  | pub enum TripDeleteError { | ||||||
|  |     SomebodyAlreadyRegistered, | ||||||
|  |     NotYourTrip, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Debug)] | ||||||
|  | pub enum TripUpdateError { | ||||||
|  |     NotYourTrip, | ||||||
|  |     TripDetailsDoesNotExist, | ||||||
|  |     TripTypeNotAllowed, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[cfg(test)] | ||||||
|  | mod test { | ||||||
|  |     use crate::{ | ||||||
|  |         model::{ | ||||||
|  |             notification::Notification, | ||||||
|  |             planned::{ | ||||||
|  |                 event::Event, | ||||||
|  |                 trip::{self, TripDeleteError}, | ||||||
|  |                 tripdetails::TripDetails, | ||||||
|  |                 usertrip::UserTrip, | ||||||
|  |             }, | ||||||
|  |             user::{SteeringUser, User}, | ||||||
|  |         }, | ||||||
|  |         testdb, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     use chrono::Local; | ||||||
|  |     use sqlx::SqlitePool; | ||||||
|  |  | ||||||
|  |     use super::Trip; | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_new_own() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let cox = SteeringUser::new( | ||||||
|  |             &pool, | ||||||
|  |             &User::find_by_name(&pool, "cox".into()).await.unwrap(), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |  | ||||||
|  |         Trip::new_own(&pool, &cox, trip_details).await; | ||||||
|  |  | ||||||
|  |         assert!(Trip::find_by_id(&pool, 1).await.is_some()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_notification_cox_if_same_datetime() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |         let cox = SteeringUser::new( | ||||||
|  |             &pool, | ||||||
|  |             &User::find_by_name(&pool, "cox".into()).await.unwrap(), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |         let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |         Trip::new_own(&pool, &cox, trip_details).await; | ||||||
|  |  | ||||||
|  |         let cox2 = SteeringUser::new( | ||||||
|  |             &pool, | ||||||
|  |             &User::find_by_name(&pool, "cox2".into()).await.unwrap(), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |         let trip_details = TripDetails::find_by_id(&pool, 3).await.unwrap(); | ||||||
|  |         Trip::new_own(&pool, &cox2, trip_details).await; | ||||||
|  |  | ||||||
|  |         let last_notification = &Notification::for_user(&pool, &cox).await[0]; | ||||||
|  |  | ||||||
|  |         assert!( | ||||||
|  |             last_notification | ||||||
|  |                 .message | ||||||
|  |                 .starts_with("cox2 hat eine Ausfahrt zur selben Zeit") | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_get_day_cox_trip() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let tomorrow = Local::now().date_naive() + chrono::Duration::days(1); | ||||||
|  |         let res = Trip::get_for_day(&pool, tomorrow).await; | ||||||
|  |         assert_eq!(res.len(), 1); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_new_succ_join() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let cox = SteeringUser::new( | ||||||
|  |             &pool, | ||||||
|  |             &User::find_by_name(&pool, "cox2".into()).await.unwrap(), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         let planned_event = Event::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |  | ||||||
|  |         assert!(Trip::new_join(&pool, &cox, &planned_event).await.is_ok()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_new_failed_join_already_cox() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let cox = SteeringUser::new( | ||||||
|  |             &pool, | ||||||
|  |             &User::find_by_name(&pool, "cox2".into()).await.unwrap(), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         let planned_event = Event::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |  | ||||||
|  |         Trip::new_join(&pool, &cox, &planned_event).await.unwrap(); | ||||||
|  |         assert!(Trip::new_join(&pool, &cox, &planned_event).await.is_err()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_succ_update_own() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let cox = SteeringUser::new( | ||||||
|  |             &pool, | ||||||
|  |             &User::find_by_name(&pool, "cox".into()).await.unwrap(), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         let trip = Trip::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |  | ||||||
|  |         let update = trip::TripUpdate { | ||||||
|  |             cox: &cox, | ||||||
|  |             trip: &trip, | ||||||
|  |             max_people: 10, | ||||||
|  |             notes: None, | ||||||
|  |             trip_type: None, | ||||||
|  |             is_locked: false, | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         assert!(Trip::update_own(&pool, &update).await.is_ok()); | ||||||
|  |  | ||||||
|  |         let trip = Trip::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |         assert_eq!(trip.max_people, 10); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_succ_update_own_with_triptype() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let cox = SteeringUser::new( | ||||||
|  |             &pool, | ||||||
|  |             &User::find_by_name(&pool, "cox".into()).await.unwrap(), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         let trip = Trip::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |  | ||||||
|  |         let update = trip::TripUpdate { | ||||||
|  |             cox: &cox, | ||||||
|  |             trip: &trip, | ||||||
|  |             max_people: 10, | ||||||
|  |             notes: None, | ||||||
|  |             trip_type: Some(1), | ||||||
|  |             is_locked: false, | ||||||
|  |         }; | ||||||
|  |         assert!(Trip::update_own(&pool, &update).await.is_ok()); | ||||||
|  |  | ||||||
|  |         let trip = Trip::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |         assert_eq!(trip.max_people, 10); | ||||||
|  |         assert_eq!(trip.trip_type_id, Some(1)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_fail_update_own_not_your_trip() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let cox = SteeringUser::new( | ||||||
|  |             &pool, | ||||||
|  |             &User::find_by_name(&pool, "cox2".into()).await.unwrap(), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         let trip = Trip::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |  | ||||||
|  |         let update = trip::TripUpdate { | ||||||
|  |             cox: &cox, | ||||||
|  |             trip: &trip, | ||||||
|  |             max_people: 10, | ||||||
|  |             notes: None, | ||||||
|  |             trip_type: None, | ||||||
|  |             is_locked: false, | ||||||
|  |         }; | ||||||
|  |         assert!(Trip::update_own(&pool, &update).await.is_err()); | ||||||
|  |         assert_eq!(trip.max_people, 1); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_succ_delete_by_planned_event() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let cox = SteeringUser::new( | ||||||
|  |             &pool, | ||||||
|  |             &User::find_by_name(&pool, "cox".into()).await.unwrap(), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         let planned_event = Event::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |  | ||||||
|  |         Trip::new_join(&pool, &cox, &planned_event).await.unwrap(); | ||||||
|  |  | ||||||
|  |         //TODO: check why following assert fails | ||||||
|  |         //assert!(Trip::find_by_id(&pool, 2).await.is_some()); | ||||||
|  |         Trip::delete_by_planned_event(&pool, &cox, &planned_event) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|  |         assert!(Trip::find_by_id(&pool, 2).await.is_none()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_succ_delete() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let cox = SteeringUser::new( | ||||||
|  |             &pool, | ||||||
|  |             &User::find_by_name(&pool, "cox".into()).await.unwrap(), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         let trip = Trip::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |  | ||||||
|  |         trip.delete(&pool, &cox).await.unwrap(); | ||||||
|  |  | ||||||
|  |         assert!(Trip::find_by_id(&pool, 1).await.is_none()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_fail_delete_diff_cox() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let cox = SteeringUser::new( | ||||||
|  |             &pool, | ||||||
|  |             &User::find_by_name(&pool, "cox2".into()).await.unwrap(), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         let trip = Trip::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |  | ||||||
|  |         let result = trip | ||||||
|  |             .delete(&pool, &cox) | ||||||
|  |             .await | ||||||
|  |             .expect_err("It should not be possible to delete trips from others"); | ||||||
|  |         let expected = TripDeleteError::NotYourTrip; | ||||||
|  |  | ||||||
|  |         assert_eq!(result, expected); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     #[sqlx::test] | ||||||
|  |     fn test_fail_delete_someone_registered() { | ||||||
|  |         let pool = testdb!(); | ||||||
|  |  | ||||||
|  |         let cox = SteeringUser::new( | ||||||
|  |             &pool, | ||||||
|  |             &User::find_by_name(&pool, "cox".into()).await.unwrap(), | ||||||
|  |         ) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         let trip = Trip::find_by_id(&pool, 1).await.unwrap(); | ||||||
|  |  | ||||||
|  |         let trip_details = TripDetails::find_by_id(&pool, trip.trip_details_id.unwrap()) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|  |         let user = User::find_by_name(&pool, "rower".into()).await.unwrap(); | ||||||
|  |  | ||||||
|  |         UserTrip::create(&pool, &user, &trip_details, None) | ||||||
|  |             .await | ||||||
|  |             .unwrap(); | ||||||
|  |  | ||||||
|  |         let result = trip | ||||||
|  |             .delete(&pool, &cox) | ||||||
|  |             .await | ||||||
|  |             .expect_err("It should not be possible to delete trips if somebody already registered"); | ||||||
|  |         let expected = TripDeleteError::SomebodyAlreadyRegistered; | ||||||
|  |  | ||||||
|  |         assert_eq!(result, expected); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,9 +1,15 @@ | |||||||
| use crate::model::user::User; | use crate::model::{notification::Notification, user::User}; | ||||||
| use chrono::NaiveDate; | use chrono::{Local, NaiveDate}; | ||||||
| use rocket::FromForm; | use rocket::FromForm; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use sqlx::{FromRow, SqlitePool}; | use sqlx::{FromRow, SqlitePool}; | ||||||
| 
 | 
 | ||||||
|  | use super::{ | ||||||
|  |     trip::{Trip, TripWithDetails}, | ||||||
|  |     triptype::TripType, | ||||||
|  | }; | ||||||
|  | use std::fmt::Display; | ||||||
|  | 
 | ||||||
| #[derive(FromRow, Debug, Serialize, Deserialize)] | #[derive(FromRow, Debug, Serialize, Deserialize)] | ||||||
| pub struct TripDetails { | pub struct TripDetails { | ||||||
|     pub id: i64, |     pub id: i64, | ||||||
| @@ -17,6 +23,20 @@ pub struct TripDetails { | |||||||
|     pub is_locked: bool, |     pub is_locked: bool, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | impl Display for TripDetails { | ||||||
|  |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  |         f.write_str(&format!( | ||||||
|  |             "Ausfahrt am {} um {} mit {} Personen", | ||||||
|  |             self.day, self.planned_starting_time, self.max_people | ||||||
|  |         ))?; | ||||||
|  |         if let Some(notes) = &self.notes { | ||||||
|  |             f.write_str(&format!(" Notizen: {notes}"))?; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| #[derive(FromForm, Serialize)] | #[derive(FromForm, Serialize)] | ||||||
| pub struct TripDetailsToAdd<'r> { | pub struct TripDetailsToAdd<'r> { | ||||||
|     //TODO: properly parse `planned_starting_time`
 |     //TODO: properly parse `planned_starting_time`
 | ||||||
| @@ -27,7 +47,6 @@ pub struct TripDetailsToAdd<'r> { | |||||||
|     pub notes: Option<&'r str>, |     pub notes: Option<&'r str>, | ||||||
|     pub trip_type: Option<i64>, |     pub trip_type: Option<i64>, | ||||||
|     pub allow_guests: bool, |     pub allow_guests: bool, | ||||||
|     pub always_show: bool, |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl TripDetails { | impl TripDetails { | ||||||
| @@ -46,17 +65,125 @@ WHERE id like ? | |||||||
|         .ok() |         .ok() | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     pub async fn triptype(&self, db: &SqlitePool) -> Option<TripType> { | ||||||
|  |         match self.trip_type_id { | ||||||
|  |             None => None, | ||||||
|  |             Some(id) => TripType::find_by_id(db, id).await, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub fn date(&self) -> NaiveDate { | ||||||
|  |         NaiveDate::parse_from_str(&self.day, "%Y-%m-%d").unwrap() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub(crate) async fn user_sees_trip(&self, db: &SqlitePool, user: &User) -> bool { | ||||||
|  |         let today = Local::now().date_naive(); | ||||||
|  |         let day_diff = self.date() - today; | ||||||
|  |         let day_diff = day_diff.num_days(); | ||||||
|  |         if day_diff < 0 { | ||||||
|  |             // tripdetails is in past
 | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         if day_diff <= user.amount_days_to_show(db).await { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         self.always_show | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub async fn find_by_startingdatetime( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         day: String, | ||||||
|  |         planned_starting_time: String, | ||||||
|  |     ) -> Vec<Self> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  | SELECT id, planned_starting_time, max_people, day, notes, allow_guests, trip_type_id, always_show, is_locked | ||||||
|  | FROM trip_details 
 | ||||||
|  | WHERE day = ? AND planned_starting_time = ? | ||||||
|  |         " | ||||||
|  |         , day, planned_starting_time | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await.unwrap() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub fn cancelled(&self) -> bool { | ||||||
|  |         self.max_people == -1 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /// This function is called when a person registers to a trip or when the cox changes the
 | ||||||
|  |     /// amount of free places.
 | ||||||
|  |     pub async fn check_free_spaces(&self, db: &SqlitePool) { | ||||||
|  |         if !self.is_full(db).await { | ||||||
|  |             // We still have space for new people, no need to do anything
 | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if self.cancelled() { | ||||||
|  |             // Cox cancelled event, thus it's probably bad weather. Don't bother with sending
 | ||||||
|  |             // notifications
 | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if Trip::find_by_trip_details(db, self.id).await.is_none() { | ||||||
|  |             // This trip_details belongs to a planned_event, no need to do anything
 | ||||||
|  |             return; | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         let other_trips_same_time = Self::find_by_startingdatetime( | ||||||
|  |             db, | ||||||
|  |             self.day.clone(), | ||||||
|  |             self.planned_starting_time.clone(), | ||||||
|  |         ) | ||||||
|  |         .await; | ||||||
|  | 
 | ||||||
|  |         for trip in &other_trips_same_time { | ||||||
|  |             if !trip.is_full(db).await { | ||||||
|  |                 // There are trips on the same time, with open places
 | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // We just got fully booked and there are no other trips with remaining rower places. Send
 | ||||||
|  |         // notification to all coxes which are registered as non-cox.
 | ||||||
|  |         for trip_details in other_trips_same_time { | ||||||
|  |             let Some(trip) = Trip::find_by_trip_details(db, trip_details.id).await else { | ||||||
|  |                 // This trip_details belongs to a planned_event, no need to do anything
 | ||||||
|  |                 continue; | ||||||
|  |             }; | ||||||
|  |             let pot_coxes = TripWithDetails::from(db, trip.clone()).await; | ||||||
|  |             let pot_coxes = pot_coxes.rower; | ||||||
|  |             for user in pot_coxes { | ||||||
|  |                 let cox = User::find_by_id(db, trip.cox_id as i32).await.unwrap(); | ||||||
|  |                 let Some(user) = User::find_by_name(db, &user.name).await else { | ||||||
|  |                     // User is a guest, no need to bother.
 | ||||||
|  |                     continue; | ||||||
|  |                 }; | ||||||
|  |                 if !user.allowed_to_steer(db).await { | ||||||
|  |                     // User is no cox, no need to bother
 | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  |                 if user.id == cox.id { | ||||||
|  |                     // User already offers a trip, no need to bother
 | ||||||
|  |                     continue; | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 Notification::create(db, &user, &format!("Du hast dich als Ruderer bei der Ausfahrt von {} am {} um {} angemeldet. Bei allen Ausfahrten zu dieser Zeit sind nun alle Plätze ausgebucht. Damit noch mehr (Nicht-Steuerleute) mitfahren können, wäre es super, wenn du eine eigene Ausfahrt zur selben Zeit ausschreiben könntest.", cox.name, self.day, self.planned_starting_time), "Volle Ausfahrt", None, None).await; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     /// Creates a new entry in `trip_details` and returns its id.
 |     /// Creates a new entry in `trip_details` and returns its id.
 | ||||||
|     pub async fn create(db: &SqlitePool, tripdetails: TripDetailsToAdd<'_>) -> i64 { |     pub async fn create(db: &SqlitePool, tripdetails: TripDetailsToAdd<'_>) -> i64 { | ||||||
|         let query = sqlx::query!( |         let query = sqlx::query!( | ||||||
|             "INSERT INTO trip_details(planned_starting_time, max_people, day, notes, allow_guests, trip_type_id, always_show) VALUES(?, ?, ?, ?, ?, ?, ?)" , |             "INSERT INTO trip_details(planned_starting_time, max_people, day, notes, allow_guests, trip_type_id) VALUES(?, ?, ?, ?, ?, ?)" , | ||||||
|             tripdetails.planned_starting_time, |             tripdetails.planned_starting_time, | ||||||
|             tripdetails.max_people, |             tripdetails.max_people, | ||||||
|             tripdetails.day, |             tripdetails.day, | ||||||
|             tripdetails.notes, |             tripdetails.notes, | ||||||
|             tripdetails.allow_guests, |             tripdetails.allow_guests, | ||||||
|             tripdetails.trip_type, |             tripdetails.trip_type, | ||||||
|             tripdetails.always_show |  | ||||||
|         ) |         ) | ||||||
|         .execute(db) |         .execute(db) | ||||||
|         .await |         .await | ||||||
| @@ -64,6 +191,17 @@ WHERE id like ? | |||||||
|         query.last_insert_rowid() |         query.last_insert_rowid() | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     pub async fn set_always_show(&self, db: &SqlitePool, value: bool) { | ||||||
|  |         sqlx::query!( | ||||||
|  |             "UPDATE trip_details SET always_show = ? WHERE id = ?", | ||||||
|  |             value, | ||||||
|  |             self.id | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); //Okay, as planned_event can only be created with proper DB backing
 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     pub async fn is_full(&self, db: &SqlitePool) -> bool { |     pub async fn is_full(&self, db: &SqlitePool) -> bool { | ||||||
|         let amount_currently_registered = sqlx::query!( |         let amount_currently_registered = sqlx::query!( | ||||||
|             "SELECT COUNT(*) as count FROM user_trip WHERE trip_details_id = ?", |             "SELECT COUNT(*) as count FROM user_trip WHERE trip_details_id = ?", | ||||||
| @@ -72,7 +210,7 @@ WHERE id like ? | |||||||
|         .fetch_one(db) |         .fetch_one(db) | ||||||
|         .await |         .await | ||||||
|         .unwrap(); //TODO: fixme
 |         .unwrap(); //TODO: fixme
 | ||||||
|         let amount_currently_registered = i64::from(amount_currently_registered.count); |         let amount_currently_registered = amount_currently_registered.count; | ||||||
| 
 | 
 | ||||||
|         amount_currently_registered >= self.max_people |         amount_currently_registered >= self.max_people | ||||||
|     } |     } | ||||||
| @@ -120,7 +258,7 @@ ORDER BY day;", | |||||||
| 
 | 
 | ||||||
|     pub(crate) async fn user_allowed_to_change(&self, db: &SqlitePool, user: &User) -> bool { |     pub(crate) async fn user_allowed_to_change(&self, db: &SqlitePool, user: &User) -> bool { | ||||||
|         if self.belongs_to_event(db).await { |         if self.belongs_to_event(db).await { | ||||||
|             user.has_role(db, "admin").await |             user.has_role(db, "manage_events").await | ||||||
|         } else { |         } else { | ||||||
|             self.user_is_cox(db, user).await != CoxAtTrip::No |             self.user_is_cox(db, user).await != CoxAtTrip::No | ||||||
|         } |         } | ||||||
| @@ -179,7 +317,7 @@ pub(crate) enum Action { | |||||||
| 
 | 
 | ||||||
| #[cfg(test)] | #[cfg(test)] | ||||||
| mod test { | mod test { | ||||||
|     use crate::{model::tripdetails::TripDetailsToAdd, testdb}; |     use crate::{model::planned::tripdetails::TripDetailsToAdd, testdb}; | ||||||
| 
 | 
 | ||||||
|     use super::TripDetails; |     use super::TripDetails; | ||||||
|     use sqlx::SqlitePool; |     use sqlx::SqlitePool; | ||||||
| @@ -212,11 +350,10 @@ mod test { | |||||||
|                     notes: None, |                     notes: None, | ||||||
|                     allow_guests: false, |                     allow_guests: false, | ||||||
|                     trip_type: None, |                     trip_type: None, | ||||||
|                     always_show: false |  | ||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|             .await, |             .await, | ||||||
|             3, |             4, | ||||||
|         ); |         ); | ||||||
|         assert_eq!( |         assert_eq!( | ||||||
|             TripDetails::create( |             TripDetails::create( | ||||||
| @@ -228,11 +365,10 @@ mod test { | |||||||
|                     notes: None, |                     notes: None, | ||||||
|                     allow_guests: false, |                     allow_guests: false, | ||||||
|                     trip_type: None, |                     trip_type: None, | ||||||
|                     always_show: false |  | ||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|             .await, |             .await, | ||||||
|             4, |             5, | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @@ -1,10 +1,10 @@ | |||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use sqlx::{FromRow, SqlitePool}; | use sqlx::{FromRow, SqlitePool}; | ||||||
| 
 | 
 | ||||||
| #[derive(FromRow, Debug, Serialize, Deserialize, Clone)] | #[derive(FromRow, Debug, Serialize, Deserialize, Clone, PartialEq)] | ||||||
| pub struct TripType { | pub struct TripType { | ||||||
|     pub id: i64, |     pub id: i64, | ||||||
|     name: String, |     pub name: String, | ||||||
|     desc: String, |     desc: String, | ||||||
|     question: String, |     question: String, | ||||||
|     icon: String, |     icon: String, | ||||||
| @@ -1,9 +1,23 @@ | |||||||
| use sqlx::SqlitePool; | use serde::{Deserialize, Serialize}; | ||||||
|  | use sqlx::{FromRow, SqlitePool}; | ||||||
| 
 | 
 | ||||||
| use super::{tripdetails::TripDetails, user::User}; | use super::{ | ||||||
| use crate::model::tripdetails::{Action, CoxAtTrip::Yes}; |     trip::{Trip, TripWithDetails}, | ||||||
|  |     tripdetails::TripDetails, | ||||||
|  | }; | ||||||
|  | use crate::model::{ | ||||||
|  |     notification::Notification, | ||||||
|  |     planned::tripdetails::{Action, CoxAtTrip::Yes}, | ||||||
|  |     user::{SteeringUser, User}, | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| pub struct UserTrip {} | #[derive(FromRow, Debug, Serialize, Deserialize, Clone)] | ||||||
|  | pub struct UserTrip { | ||||||
|  |     pub user_id: Option<i64>, | ||||||
|  |     pub user_note: Option<String>, | ||||||
|  |     pub trip_details_id: i64, | ||||||
|  |     pub created_at: String, // TODO: switch to NaiveDateTime
 | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| impl UserTrip { | impl UserTrip { | ||||||
|     pub async fn create( |     pub async fn create( | ||||||
| @@ -11,7 +25,7 @@ impl UserTrip { | |||||||
|         user: &User, |         user: &User, | ||||||
|         trip_details: &TripDetails, |         trip_details: &TripDetails, | ||||||
|         user_note: Option<String>, |         user_note: Option<String>, | ||||||
|     ) -> Result<(), UserTripError> { |     ) -> Result<String, UserTripError> { | ||||||
|         if trip_details.is_full(db).await { |         if trip_details.is_full(db).await { | ||||||
|             return Err(UserTripError::EventAlreadyFull); |             return Err(UserTripError::EventAlreadyFull); | ||||||
|         } |         } | ||||||
| @@ -24,10 +38,14 @@ impl UserTrip { | |||||||
|             return Err(UserTripError::GuestNotAllowedForThisEvent); |             return Err(UserTripError::GuestNotAllowedForThisEvent); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  |         if !trip_details.user_sees_trip(db, user).await { | ||||||
|  |             return Err(UserTripError::NotVisibleToUser); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         //TODO: Check if user sees the event (otherwise she could forge trip_details_id)
 |         //TODO: Check if user sees the event (otherwise she could forge trip_details_id)
 | ||||||
| 
 | 
 | ||||||
|         let is_cox = trip_details.user_is_cox(db, user).await; |         let is_cox = trip_details.user_is_cox(db, user).await; | ||||||
|         if user_note.is_none() { |         let name_newly_registered_person = if user_note.is_none() { | ||||||
|             if let Yes(action) = is_cox { |             if let Yes(action) = is_cox { | ||||||
|                 match action { |                 match action { | ||||||
|                     Action::Helping => return Err(UserTripError::AlreadyRegisteredAsCox), |                     Action::Helping => return Err(UserTripError::AlreadyRegisteredAsCox), | ||||||
| @@ -47,6 +65,8 @@ impl UserTrip { | |||||||
|             .execute(db) |             .execute(db) | ||||||
|             .await |             .await | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
|  | 
 | ||||||
|  |             user.name.clone() | ||||||
|         } else { |         } else { | ||||||
|             if !trip_details.user_allowed_to_change(db, user).await { |             if !trip_details.user_allowed_to_change(db, user).await { | ||||||
|                 return Err(UserTripError::NotAllowedToAddGuest); |                 return Err(UserTripError::NotAllowedToAddGuest); | ||||||
| @@ -59,11 +79,63 @@ impl UserTrip { | |||||||
|             .execute(db) |             .execute(db) | ||||||
|             .await |             .await | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
|  | 
 | ||||||
|  |             user_note.clone().unwrap() | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         if let Some(trip) = Trip::find_by_trip_details(db, trip_details.id).await { | ||||||
|  |             if user_note.is_none() { | ||||||
|  |                 // Don't show notification if we add guest (as only we are
 | ||||||
|  |                 // allowed to do so)
 | ||||||
|  |                 let cox = User::find_by_id(db, trip.cox_id as i32).await.unwrap(); | ||||||
|  |                 Notification::create( | ||||||
|  |                     db, | ||||||
|  |                     &cox, | ||||||
|  |                     &format!( | ||||||
|  |                         "{} hat sich für deine Ausfahrt am {} registriert", | ||||||
|  |                         name_newly_registered_person, trip.day | ||||||
|  |                     ), | ||||||
|  |                     "Registrierung bei deiner Ausfahrt", | ||||||
|  |                     None, | ||||||
|  |                     None, | ||||||
|  |                 ) | ||||||
|  |                 .await; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             trip_details.check_free_spaces(db).await; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         Ok(()) |         Ok(name_newly_registered_person) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     pub async fn tripdetails(&self, db: &SqlitePool) -> TripDetails { | ||||||
|  |         TripDetails::find_by_id(db, self.trip_details_id) | ||||||
|  |             .await | ||||||
|  |             .unwrap() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub async fn find_by_userid_and_trip_detail_id( | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         user_id: i64, | ||||||
|  |         trip_detail_id: i64, | ||||||
|  |     ) -> Option<Self> { | ||||||
|  |         sqlx::query_as!(Self, "SELECT user_id, user_note, trip_details_id, created_at FROM user_trip WHERE user_id= ? AND trip_details_id = ?", user_id, trip_detail_id) | ||||||
|  |             .fetch_one(db) | ||||||
|  |             .await | ||||||
|  |             .ok() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     pub async fn self_delete(&self, db: &SqlitePool) -> Result<(), UserTripDeleteError> { | ||||||
|  |         let trip_details = self.tripdetails(db).await; | ||||||
|  |         if let Some(id) = self.user_id { | ||||||
|  |             let user = User::find_by_id(db, id as i32).await.unwrap(); | ||||||
|  |             return Self::delete(db, &user, &trip_details, self.user_note.clone()).await; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         Ok(()) // TODO: fixme
 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     //TODO: cleaner code
 | ||||||
|     pub async fn delete( |     pub async fn delete( | ||||||
|         db: &SqlitePool, |         db: &SqlitePool, | ||||||
|         user: &User, |         user: &User, | ||||||
| @@ -74,7 +146,28 @@ impl UserTrip { | |||||||
|             return Err(UserTripDeleteError::DetailsLocked); |             return Err(UserTripDeleteError::DetailsLocked); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if let Some(name) = name { |         if !trip_details.user_sees_trip(db, user).await { | ||||||
|  |             return Err(UserTripDeleteError::NotVisibleToUser); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         let mut trip_to_delete = None; | ||||||
|  |         let mut some_trip = None; | ||||||
|  |         if let Some(trip) = Trip::find_by_trip_details(db, trip_details.id).await { | ||||||
|  |             some_trip = Some(trip.clone()); | ||||||
|  |             // If trip is cancelled, and lost rower just unregistered, delete the trip
 | ||||||
|  |             if TripDetails::find_by_id(db, trip_details.id) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap() | ||||||
|  |                 .cancelled() | ||||||
|  |             { | ||||||
|  |                 let trip = TripWithDetails::from(db, trip.clone()).await; | ||||||
|  |                 if trip.rower.len() == 1 { | ||||||
|  |                     trip_to_delete = Some(trip.trip); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if let Some(name) = name.clone() { | ||||||
|             if !trip_details.user_allowed_to_change(db, user).await { |             if !trip_details.user_allowed_to_change(db, user).await { | ||||||
|                 return Err(UserTripDeleteError::NotAllowedToDeleteGuest); |                 return Err(UserTripDeleteError::NotAllowedToDeleteGuest); | ||||||
|             } |             } | ||||||
| @@ -102,6 +195,55 @@ impl UserTrip { | |||||||
|             .await |             .await | ||||||
|             .unwrap(); |             .unwrap(); | ||||||
|         } |         } | ||||||
|  | 
 | ||||||
|  |         let mut add_info = ""; | ||||||
|  |         if let Some(trip) = &trip_to_delete { | ||||||
|  |             let cox = User::find_by_id(db, trip.cox_id as i32).await.unwrap(); | ||||||
|  |             trip.delete(db, &SteeringUser::new(db, &cox).await.unwrap()) | ||||||
|  |                 .await | ||||||
|  |                 .unwrap(); | ||||||
|  |             add_info = " Das war die letzte angemeldete Person. Nachdem nun alle Bescheid wissen, wird die Ausfahrt ab sofort nicht mehr angezeigt."; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if let Some(trip) = some_trip { | ||||||
|  |             let opt_cancelled = if trip_to_delete.is_some() { | ||||||
|  |                 "abgesagten " | ||||||
|  |             } else { | ||||||
|  |                 "" | ||||||
|  |             }; | ||||||
|  |             if let Some(name) = name { | ||||||
|  |                 if !add_info.is_empty() { | ||||||
|  |                     let cox = User::find_by_id(db, trip.cox_id as i32).await.unwrap(); | ||||||
|  |                     Notification::create( | ||||||
|  |                         db, | ||||||
|  |                         &cox, | ||||||
|  |                         &format!( | ||||||
|  |                             "Du hast {} von deiner {}Ausfahrt am {} abgemeldet.{}", | ||||||
|  |                             name, opt_cancelled, trip.day, add_info | ||||||
|  |                         ), | ||||||
|  |                         "Abmeldung von deiner Ausfahrt", | ||||||
|  |                         None, | ||||||
|  |                         None, | ||||||
|  |                     ) | ||||||
|  |                     .await; | ||||||
|  |                 } | ||||||
|  |             } else { | ||||||
|  |                 let cox = User::find_by_id(db, trip.cox_id as i32).await.unwrap(); | ||||||
|  |                 Notification::create( | ||||||
|  |                     db, | ||||||
|  |                     &cox, | ||||||
|  |                     &format!( | ||||||
|  |                         "{} hat sich von deiner {}Ausfahrt am {} abgemeldet.{}", | ||||||
|  |                         user.name, opt_cancelled, trip.day, add_info | ||||||
|  |                     ), | ||||||
|  |                     "Abmeldung von deiner Ausfahrt", | ||||||
|  |                     None, | ||||||
|  |                     None, | ||||||
|  |                 ) | ||||||
|  |                 .await; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -115,6 +257,7 @@ pub enum UserTripError { | |||||||
|     CantRegisterAtOwnEvent, |     CantRegisterAtOwnEvent, | ||||||
|     GuestNotAllowedForThisEvent, |     GuestNotAllowedForThisEvent, | ||||||
|     NotAllowedToAddGuest, |     NotAllowedToAddGuest, | ||||||
|  |     NotVisibleToUser, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[derive(Debug, PartialEq)] | #[derive(Debug, PartialEq)] | ||||||
| @@ -122,14 +265,17 @@ pub enum UserTripDeleteError { | |||||||
|     DetailsLocked, |     DetailsLocked, | ||||||
|     GuestNotParticipating, |     GuestNotParticipating, | ||||||
|     NotAllowedToDeleteGuest, |     NotAllowedToDeleteGuest, | ||||||
|  |     NotVisibleToUser, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[cfg(test)] | #[cfg(test)] | ||||||
| mod test { | mod test { | ||||||
|     use crate::{ |     use crate::{ | ||||||
|         model::{ |         model::{ | ||||||
|             planned_event::PlannedEvent, trip::Trip, tripdetails::TripDetails, user::CoxUser, |             planned::{ | ||||||
|             usertrip::UserTripError, |                 event::Event, trip::Trip, tripdetails::TripDetails, usertrip::UserTripError, | ||||||
|  |             }, | ||||||
|  |             user::SteeringUser, | ||||||
|         }, |         }, | ||||||
|         testdb, |         testdb, | ||||||
|     }; |     }; | ||||||
| @@ -211,15 +357,15 @@ mod test { | |||||||
|     fn test_fail_create_is_cox_planned_event() { |     fn test_fail_create_is_cox_planned_event() { | ||||||
|         let pool = testdb!(); |         let pool = testdb!(); | ||||||
| 
 | 
 | ||||||
|         let cox = CoxUser::new( |         let cox = SteeringUser::new( | ||||||
|             &pool, |             &pool, | ||||||
|             User::find_by_name(&pool, "cox".into()).await.unwrap(), |             &User::find_by_name(&pool, "cox".into()).await.unwrap(), | ||||||
|         ) |         ) | ||||||
|         .await |         .await | ||||||
|         .unwrap(); |         .unwrap(); | ||||||
| 
 | 
 | ||||||
|         let planned_event = PlannedEvent::find_by_id(&pool, 1).await.unwrap(); |         let event = Event::find_by_id(&pool, 1).await.unwrap(); | ||||||
|         Trip::new_join(&pool, &cox, &planned_event).await.unwrap(); |         Trip::new_join(&pool, &cox, &event).await.unwrap(); | ||||||
| 
 | 
 | ||||||
|         let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap(); |         let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap(); | ||||||
|         let result = UserTrip::create(&pool, &cox, &trip_details, None) |         let result = UserTrip::create(&pool, &cox, &trip_details, None) | ||||||
| @@ -1,325 +0,0 @@ | |||||||
| use std::io::Write; |  | ||||||
|  |  | ||||||
| use chrono::NaiveDate; |  | ||||||
| use ics::{ |  | ||||||
|     properties::{DtStart, Summary}, |  | ||||||
|     Event, ICalendar, |  | ||||||
| }; |  | ||||||
| use serde::Serialize; |  | ||||||
| use sqlx::{FromRow, SqlitePool, Row}; |  | ||||||
|  |  | ||||||
| use super::{tripdetails::TripDetails, triptype::TripType, user::User}; |  | ||||||
|  |  | ||||||
| #[derive(Serialize, Clone, FromRow, Debug, PartialEq)] |  | ||||||
| pub struct PlannedEvent { |  | ||||||
|     pub id: i64, |  | ||||||
|     pub name: String, |  | ||||||
|     planned_amount_cox: i64, |  | ||||||
|     trip_details_id: i64, |  | ||||||
|     pub planned_starting_time: String, |  | ||||||
|     max_people: i64, |  | ||||||
|     pub day: String, |  | ||||||
|     pub notes: Option<String>, |  | ||||||
|     pub allow_guests: bool, |  | ||||||
|     trip_type_id: Option<i64>, |  | ||||||
|     always_show: bool, |  | ||||||
|     is_locked: bool, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[derive(Serialize, Debug)] |  | ||||||
| pub struct PlannedEventWithUserAndTriptype { |  | ||||||
|     #[serde(flatten)] |  | ||||||
|     pub planned_event: PlannedEvent, |  | ||||||
|     trip_type: Option<TripType>, |  | ||||||
|     cox_needed: bool, |  | ||||||
|     cox: Vec<Registration>, |  | ||||||
|     rower: Vec<Registration>, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| //TODO: move to appropriate place |  | ||||||
| #[derive(Serialize, Debug)] |  | ||||||
| pub struct Registration { |  | ||||||
|     pub name: String, |  | ||||||
|     pub registered_at: String, |  | ||||||
|     pub is_guest: bool, |  | ||||||
|     pub is_real_guest: bool, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| impl Registration { |  | ||||||
|     pub async fn all_rower(db: &SqlitePool, trip_details_id: i64) -> Vec<Registration> { |  | ||||||
|         sqlx::query( |  | ||||||
|             &format!( |  | ||||||
|             r#" |  | ||||||
| SELECT |  | ||||||
|     (SELECT name FROM user WHERE user_trip.user_id = user.id) as "name?",  |  | ||||||
|     user_note, |  | ||||||
|     user_id, |  | ||||||
|     (SELECT created_at FROM user WHERE user_trip.user_id = user.id) as registered_at, |  | ||||||
|     (SELECT EXISTS (SELECT 1 FROM user_role WHERE user_role.user_id = user_trip.user_id AND user_role.role_id = (SELECT id FROM role WHERE name = 'scheckbuch'))) as is_guest |  | ||||||
| FROM user_trip WHERE trip_details_id = {}  |  | ||||||
|         "#,trip_details_id), |  | ||||||
|         ) |  | ||||||
|         .fetch_all(db) |  | ||||||
|         .await |  | ||||||
|         .unwrap() |  | ||||||
|         .into_iter() |  | ||||||
|         .map(|r|  |  | ||||||
|             Registration { |  | ||||||
|             name: r.get::<Option<String>, usize>(0).or(r.get::<Option<String>, usize>(1)).unwrap(), //Ok, either name or user_note needs to be set |  | ||||||
|             registered_at: r.get::<String,usize>(3), |  | ||||||
|             is_guest: r.get::<bool, usize>(4), |  | ||||||
|             is_real_guest: r.get::<Option<i64>, usize>(2).is_none(), |  | ||||||
|         }) |  | ||||||
|         .collect() |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub async fn all_cox(db: &SqlitePool, trip_details_id: i64) -> Vec<Registration> { |  | ||||||
|         //TODO: switch to join |  | ||||||
|         sqlx::query!( |  | ||||||
|             " |  | ||||||
| SELECT |  | ||||||
|     (SELECT name FROM user WHERE cox_id = id) as name, |  | ||||||
|     (SELECT created_at FROM user WHERE cox_id = id) as registered_at |  | ||||||
| FROM trip WHERE planned_event_id = ? |  | ||||||
|         ", |  | ||||||
|             trip_details_id |  | ||||||
|         ) |  | ||||||
|         .fetch_all(db) |  | ||||||
|         .await |  | ||||||
|         .unwrap() |  | ||||||
|         .into_iter() |  | ||||||
|         .map(|r| Registration { |  | ||||||
|             name: r.name, |  | ||||||
|             registered_at: r.registered_at, |  | ||||||
|             is_guest: false, |  | ||||||
|             is_real_guest: false, |  | ||||||
|         }) |  | ||||||
|         .collect() //Okay, as PlannedEvent can only be created with proper DB backing |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| impl PlannedEvent { |  | ||||||
|     pub async fn find_by_id(db: &SqlitePool, id: i64) -> Option<Self> { |  | ||||||
|         sqlx::query_as!( |  | ||||||
|             Self, |  | ||||||
|             " |  | ||||||
| SELECT |  | ||||||
|     planned_event.id, planned_event.name, planned_amount_cox, trip_details_id, planned_starting_time, max_people, day, notes, allow_guests, trip_type_id, always_show, is_locked |  | ||||||
| FROM planned_event  |  | ||||||
| INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id |  | ||||||
| WHERE planned_event.id like ? |  | ||||||
|         ", |  | ||||||
|             id |  | ||||||
|         ) |  | ||||||
|         .fetch_one(db) |  | ||||||
|         .await |  | ||||||
|         .ok() |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub async fn get_pinned_for_day( |  | ||||||
|         db: &SqlitePool, |  | ||||||
|         day: NaiveDate, |  | ||||||
|     ) -> Vec<PlannedEventWithUserAndTriptype> { |  | ||||||
|         let mut events = Self::get_for_day(db, day).await; |  | ||||||
|         events.retain(|e| e.planned_event.always_show); |  | ||||||
|         events |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub async fn get_for_day( |  | ||||||
|         db: &SqlitePool, |  | ||||||
|         day: NaiveDate, |  | ||||||
|     ) -> Vec<PlannedEventWithUserAndTriptype> { |  | ||||||
|         let day = format!("{day}"); |  | ||||||
|         let events = sqlx::query_as!( |  | ||||||
|             PlannedEvent, |  | ||||||
|             "SELECT planned_event.id, planned_event.name, planned_amount_cox, trip_details_id, planned_starting_time, always_show, max_people, day, notes, allow_guests, trip_type_id, is_locked |  | ||||||
| FROM planned_event |  | ||||||
| INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id |  | ||||||
| WHERE day=?", |  | ||||||
|         day |  | ||||||
|         ) |  | ||||||
|         .fetch_all(db) |  | ||||||
|         .await |  | ||||||
|         .unwrap(); //TODO: fixme |  | ||||||
|  |  | ||||||
|         let mut ret = Vec::new(); |  | ||||||
|         for event in events { |  | ||||||
|             let cox = Registration::all_cox(db, event.id).await; |  | ||||||
|             let mut trip_type = None; |  | ||||||
|             if let Some(trip_type_id) = event.trip_type_id { |  | ||||||
|                 trip_type = TripType::find_by_id(db, trip_type_id).await; |  | ||||||
|             } |  | ||||||
|             ret.push(PlannedEventWithUserAndTriptype { |  | ||||||
|                 cox_needed: event.planned_amount_cox > cox.len() as i64, |  | ||||||
|                 cox, |  | ||||||
|                 rower: Registration::all_rower(db, event.trip_details_id).await, |  | ||||||
|                 planned_event: event, |  | ||||||
|                 trip_type, |  | ||||||
|             }); |  | ||||||
|         } |  | ||||||
|         ret |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub async fn all(db: &SqlitePool) -> Vec<PlannedEvent> { |  | ||||||
|         sqlx::query_as!( |  | ||||||
|             PlannedEvent, |  | ||||||
|             "SELECT planned_event.id, planned_event.name, planned_amount_cox, trip_details_id, planned_starting_time, always_show, max_people, day, notes, allow_guests, trip_type_id, is_locked |  | ||||||
| FROM planned_event |  | ||||||
| INNER JOIN trip_details ON planned_event.trip_details_id = trip_details.id", |  | ||||||
|         ) |  | ||||||
|         .fetch_all(db) |  | ||||||
|         .await |  | ||||||
|         .unwrap() //TODO: fixme |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     //TODO: add tests |  | ||||||
|     pub async fn is_rower_registered(&self, db: &SqlitePool, user: &User) -> bool { |  | ||||||
|         let is_rower = sqlx::query!( |  | ||||||
|             "SELECT count(*) as amount |  | ||||||
|             FROM user_trip |  | ||||||
|             WHERE trip_details_id = |  | ||||||
|                 (SELECT trip_details_id FROM planned_event WHERE id = ?) |  | ||||||
|             AND user_id = ?", |  | ||||||
|             self.id, |  | ||||||
|             user.id |  | ||||||
|         ) |  | ||||||
|         .fetch_one(db) |  | ||||||
|         .await |  | ||||||
|         .unwrap(); //Okay, bc planned_event can only be created with proper DB backing |  | ||||||
|         is_rower.amount > 0 |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub async fn create( |  | ||||||
|         db: &SqlitePool, |  | ||||||
|         name: &str, |  | ||||||
|         planned_amount_cox: i32, |  | ||||||
|         trip_details: TripDetails, |  | ||||||
|     ) { |  | ||||||
|         sqlx::query!( |  | ||||||
|             "INSERT INTO planned_event(name, planned_amount_cox, trip_details_id) VALUES(?, ?, ?)", |  | ||||||
|             name, |  | ||||||
|             planned_amount_cox, |  | ||||||
|             trip_details.id, |  | ||||||
|         ) |  | ||||||
|         .execute(db) |  | ||||||
|         .await |  | ||||||
|         .unwrap(); //Okay, as TripDetails can only be created with proper DB backing |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     //TODO: create unit test |  | ||||||
|     pub async fn update( |  | ||||||
|         &self, |  | ||||||
|         db: &SqlitePool, |  | ||||||
|         name: &str, |  | ||||||
|         planned_amount_cox: i32, |  | ||||||
|         max_people: i32, |  | ||||||
|         notes: Option<&str>, |  | ||||||
|         always_show: bool, |  | ||||||
|         is_locked: bool, |  | ||||||
|     ) { |  | ||||||
|         sqlx::query!( |  | ||||||
|             "UPDATE planned_event SET name = ?, planned_amount_cox = ? WHERE id = ?", |  | ||||||
|             name, |  | ||||||
|             planned_amount_cox, |  | ||||||
|             self.id |  | ||||||
|         ) |  | ||||||
|         .execute(db) |  | ||||||
|         .await |  | ||||||
|         .unwrap(); //Okay, as planned_event can only be created with proper DB backing |  | ||||||
|  |  | ||||||
|         sqlx::query!( |  | ||||||
|             "UPDATE trip_details SET max_people = ?, notes = ?, always_show = ?, is_locked = ? WHERE id = ?", |  | ||||||
|             max_people, |  | ||||||
|             notes, |  | ||||||
|             always_show, |  | ||||||
|             is_locked, |  | ||||||
|             self.trip_details_id |  | ||||||
|         ) |  | ||||||
|         .execute(db) |  | ||||||
|         .await |  | ||||||
|         .unwrap(); //Okay, as planned_event can only be created with proper DB backing |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub async fn delete(&self, db: &SqlitePool) { |  | ||||||
|         sqlx::query!("DELETE FROM planned_event WHERE id = ?", self.id) |  | ||||||
|             .execute(db) |  | ||||||
|             .await |  | ||||||
|             .unwrap(); //Okay, as PlannedEvent can only be created with proper DB backing |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub async fn get_ics_feed(db: &SqlitePool) -> String { |  | ||||||
|         let mut calendar = ICalendar::new("2.0", "ics-rs"); |  | ||||||
|  |  | ||||||
|         let events = PlannedEvent::all(db).await; |  | ||||||
|         for event in events { |  | ||||||
|             let mut vevent = Event::new(format!("{}@rudernlinz.at", event.id), "19900101T180000"); |  | ||||||
|             vevent.push(DtStart::new(format!( |  | ||||||
|                 "{}T{}00", |  | ||||||
|                 event.day.replace('-', ""), |  | ||||||
|                 event.planned_starting_time.replace(':', "") |  | ||||||
|             ))); |  | ||||||
|             vevent.push(Summary::new(event.name)); |  | ||||||
|             calendar.add_event(vevent); |  | ||||||
|         } |  | ||||||
|         let mut buf = Vec::new(); |  | ||||||
|         write!(&mut buf, "{}", calendar).unwrap(); |  | ||||||
|         String::from_utf8(buf).unwrap() |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub async fn trip_details(&self, db: &SqlitePool) -> TripDetails { |  | ||||||
|         TripDetails::find_by_id(db, self.trip_details_id) |  | ||||||
|             .await |  | ||||||
|             .unwrap() //ok, not null in db |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #[cfg(test)] |  | ||||||
| mod test { |  | ||||||
|     use crate::{model::tripdetails::TripDetails, testdb}; |  | ||||||
|  |  | ||||||
|     use super::PlannedEvent; |  | ||||||
|     use chrono::NaiveDate; |  | ||||||
|     use sqlx::SqlitePool; |  | ||||||
|  |  | ||||||
|     #[sqlx::test] |  | ||||||
|     fn test_get_day() { |  | ||||||
|         let pool = testdb!(); |  | ||||||
|  |  | ||||||
|         let res = |  | ||||||
|             PlannedEvent::get_for_day(&pool, NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()).await; |  | ||||||
|         assert_eq!(res.len(), 1); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     #[sqlx::test] |  | ||||||
|     fn test_create() { |  | ||||||
|         let pool = testdb!(); |  | ||||||
|  |  | ||||||
|         let trip_details = TripDetails::find_by_id(&pool, 1).await.unwrap(); |  | ||||||
|  |  | ||||||
|         PlannedEvent::create(&pool, "new-event".into(), 2, trip_details).await; |  | ||||||
|  |  | ||||||
|         let res = |  | ||||||
|             PlannedEvent::get_for_day(&pool, NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()).await; |  | ||||||
|         assert_eq!(res.len(), 2); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     #[sqlx::test] |  | ||||||
|     fn test_delete() { |  | ||||||
|         let pool = testdb!(); |  | ||||||
|         let planned_event = PlannedEvent::find_by_id(&pool, 1).await.unwrap(); |  | ||||||
|  |  | ||||||
|         planned_event.delete(&pool).await; |  | ||||||
|  |  | ||||||
|         let res = |  | ||||||
|             PlannedEvent::get_for_day(&pool, NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()).await; |  | ||||||
|         assert_eq!(res.len(), 0); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     #[sqlx::test] |  | ||||||
|     fn test_ics() { |  | ||||||
|         let pool = testdb!(); |  | ||||||
|  |  | ||||||
|         let actual = PlannedEvent::get_ics_feed(&pool).await; |  | ||||||
|         assert_eq!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:ics-rs\r\nBEGIN:VEVENT\r\nUID:1@rudernlinz.at\r\nDTSTAMP:19900101T180000\r\nDTSTART:19700101T100000\r\nSUMMARY:test-planned-event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", actual); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,25 +1,90 @@ | |||||||
| use serde::Serialize; | use std::{cmp::Ordering, fmt::Display, ops::DerefMut}; | ||||||
| use sqlx::{FromRow, SqlitePool}; |  | ||||||
|  |  | ||||||
| #[derive(FromRow, Serialize, Clone)] | use super::{activity::ActivityBuilder, user::AdminUser}; | ||||||
|  | use serde::{Deserialize, Serialize}; | ||||||
|  | use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; | ||||||
|  |  | ||||||
|  | #[derive(FromRow, Serialize, Clone, Deserialize, Debug)] | ||||||
| pub struct Role { | pub struct Role { | ||||||
|     pub(crate) id: i64, |     pub(crate) id: i64, | ||||||
|     pub(crate) name: String, |     pub(crate) name: String, | ||||||
|  |     pub(crate) formatted_name: Option<String>, | ||||||
|  |     pub(crate) desc: Option<String>, | ||||||
|  |     pub(crate) hide_in_lists: bool, | ||||||
|  |     pub(crate) cluster: Option<String>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Implement PartialEq to compare roles based only on id | ||||||
|  | impl PartialEq for Role { | ||||||
|  |     fn eq(&self, other: &Self) -> bool { | ||||||
|  |         self.id == other.id | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Implement Eq to indicate that equality is reflexive | ||||||
|  | impl Eq for Role {} | ||||||
|  |  | ||||||
|  | // Implement PartialOrd if you need to sort or compare roles | ||||||
|  | impl PartialOrd for Role { | ||||||
|  |     fn partial_cmp(&self, other: &Self) -> Option<Ordering> { | ||||||
|  |         Some(self.id.cmp(&other.id)) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Implement Ord if you need total ordering (for sorting) | ||||||
|  | impl Ord for Role { | ||||||
|  |     fn cmp(&self, other: &Self) -> Ordering { | ||||||
|  |         self.id.cmp(&other.id) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Display for Role { | ||||||
|  |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  |         if let Some(formatted_name) = &self.formatted_name { | ||||||
|  |             write!(f, "{}", formatted_name) | ||||||
|  |         } else { | ||||||
|  |             write!(f, "{}", self.name) | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| impl Role { | impl Role { | ||||||
|     pub async fn all(db: &SqlitePool) -> Vec<Role> { |     pub async fn all(db: &SqlitePool) -> Vec<Role> { | ||||||
|         sqlx::query_as!(Role, "SELECT id, name FROM role") |         sqlx::query_as!( | ||||||
|             .fetch_all(db) |             Role, | ||||||
|             .await |             "SELECT id, name, formatted_name, desc, hide_in_lists, cluster FROM role" | ||||||
|             .unwrap() |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn all_cluster(db: &SqlitePool, cluster: &str) -> Vec<Role> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Role, | ||||||
|  |             r#"SELECT id,  | ||||||
|  |        CASE WHEN formatted_name IS NOT NULL AND formatted_name != ''  | ||||||
|  |             THEN formatted_name  | ||||||
|  |             ELSE name  | ||||||
|  |        END AS "name!: String",  | ||||||
|  |        '' as formatted_name, | ||||||
|  |        desc,  | ||||||
|  |        hide_in_lists,  | ||||||
|  |        cluster  | ||||||
|  |     FROM role  | ||||||
|  |     WHERE cluster = ?"#, | ||||||
|  |             cluster | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     pub async fn find_by_id(db: &SqlitePool, name: i32) -> Option<Self> { |     pub async fn find_by_id(db: &SqlitePool, name: i32) -> Option<Self> { | ||||||
|         sqlx::query_as!( |         sqlx::query_as!( | ||||||
|             Self, |             Self, | ||||||
|             " |             " | ||||||
| SELECT id, name | SELECT id, name, formatted_name, desc, hide_in_lists, cluster | ||||||
| FROM role  | FROM role  | ||||||
| WHERE id like ? | WHERE id like ? | ||||||
|         ", |         ", | ||||||
| @@ -29,12 +94,26 @@ WHERE id like ? | |||||||
|         .await |         .await | ||||||
|         .ok() |         .ok() | ||||||
|     } |     } | ||||||
|  |     pub async fn find_by_id_tx(db: &mut Transaction<'_, Sqlite>, name: i32) -> Option<Self> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  | SELECT id, name, formatted_name, desc, hide_in_lists, cluster | ||||||
|  | FROM role  | ||||||
|  | WHERE id like ? | ||||||
|  |         ", | ||||||
|  |             name | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db.deref_mut()) | ||||||
|  |         .await | ||||||
|  |         .ok() | ||||||
|  |     } | ||||||
|  |  | ||||||
|     pub async fn find_by_name(db: &SqlitePool, name: &str) -> Option<Self> { |     pub async fn find_by_name(db: &SqlitePool, name: &str) -> Option<Self> { | ||||||
|         sqlx::query_as!( |         sqlx::query_as!( | ||||||
|             Self, |             Self, | ||||||
|             " |             " | ||||||
| SELECT id, name | SELECT id, name, formatted_name, desc, hide_in_lists, cluster | ||||||
| FROM role  | FROM role  | ||||||
| WHERE name like ? | WHERE name like ? | ||||||
|         ", |         ", | ||||||
| @@ -45,13 +124,65 @@ WHERE name like ? | |||||||
|         .ok() |         .ok() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     pub async fn find_by_name_tx(db: &mut Transaction<'_, Sqlite>, name: &str) -> Option<Self> { | ||||||
|  |         sqlx::query_as!( | ||||||
|  |             Self, | ||||||
|  |             " | ||||||
|  | SELECT id, name, formatted_name, desc, hide_in_lists, cluster | ||||||
|  | FROM role  | ||||||
|  | WHERE name like ? | ||||||
|  |         ", | ||||||
|  |             name | ||||||
|  |         ) | ||||||
|  |         .fetch_one(db.deref_mut()) | ||||||
|  |         .await | ||||||
|  |         .ok() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn update( | ||||||
|  |         &self, | ||||||
|  |         db: &SqlitePool, | ||||||
|  |         updated_by: &AdminUser, | ||||||
|  |         formatted_name: &str, | ||||||
|  |         desc: &str, | ||||||
|  |     ) -> Result<(), String> { | ||||||
|  |         sqlx::query!( | ||||||
|  |             "UPDATE role SET formatted_name=?, desc=? WHERE id=?", | ||||||
|  |             formatted_name, | ||||||
|  |             desc, | ||||||
|  |             self.id | ||||||
|  |         ) | ||||||
|  |         .execute(db) | ||||||
|  |         .await | ||||||
|  |         .map_err(|e| e.to_string())?; | ||||||
|  |  | ||||||
|  |         ActivityBuilder::new(&format!( | ||||||
|  |             "{updated_by} hat Rolle {self} von {self:#?} auf FORMATTED_NAME={formatted_name}, DESC={desc} aktualisiert." | ||||||
|  |         )).role(self).save(db).await; | ||||||
|  |  | ||||||
|  |         Ok(()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn names_from_role(&self, db: &SqlitePool) -> Vec<String> { | ||||||
|  |         let query = format!( | ||||||
|  |             "SELECT u.name | ||||||
|  |          FROM user u | ||||||
|  |          JOIN user_role ur ON u.id = ur.user_id | ||||||
|  |          JOIN role r ON ur.role_id = r.id | ||||||
|  |          WHERE r.id = {} AND deleted=0;", | ||||||
|  |             self.id | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         sqlx::query_scalar(&query).fetch_all(db).await.unwrap() | ||||||
|  |     } | ||||||
|  |  | ||||||
|     pub async fn mails_from_role(&self, db: &SqlitePool) -> Vec<String> { |     pub async fn mails_from_role(&self, db: &SqlitePool) -> Vec<String> { | ||||||
|         let query = format!( |         let query = format!( | ||||||
|             "SELECT u.mail |             "SELECT u.mail | ||||||
|          FROM user u |          FROM user u | ||||||
|          JOIN user_role ur ON u.id = ur.user_id |          JOIN user_role ur ON u.id = ur.user_id | ||||||
|          JOIN role r ON ur.role_id = r.id |          JOIN role r ON ur.role_id = r.id | ||||||
|          WHERE r.id = {}", |          WHERE r.id = {} AND deleted=0;", | ||||||
|             self.id |             self.id | ||||||
|         ); |         ); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -13,16 +13,23 @@ pub struct Rower { | |||||||
|  |  | ||||||
| impl Rower { | impl Rower { | ||||||
|     pub async fn for_log(db: &SqlitePool, log: &Logbook) -> Vec<User> { |     pub async fn for_log(db: &SqlitePool, log: &Logbook) -> Vec<User> { | ||||||
|  |         let mut tx = db.begin().await.unwrap(); | ||||||
|  |         let ret = Self::for_log_tx(&mut tx, log).await; | ||||||
|  |         tx.commit().await.unwrap(); | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn for_log_tx(db: &mut Transaction<'_, Sqlite>, log: &Logbook) -> Vec<User> { | ||||||
|         sqlx::query_as!( |         sqlx::query_as!( | ||||||
|             User, |             User, | ||||||
|             " |             " | ||||||
| SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, notes, phone, address, family_id | SELECT id, name, pw, deleted, last_access, dob, weight, sex, member_since_date, birthdate, mail, nickname, phone, address, family_id, user_token | ||||||
| FROM user | FROM user | ||||||
| WHERE id in (SELECT rower_id FROM rower WHERE logbook_id=?) | WHERE id in (SELECT rower_id FROM rower WHERE logbook_id=?) | ||||||
|         ", |         ", | ||||||
|             log.id |             log.id | ||||||
|         ) |         ) | ||||||
|         .fetch_all(db) |         .fetch_all(db.deref_mut()) | ||||||
|         .await |         .await | ||||||
|         .unwrap() |         .unwrap() | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,50 +1,119 @@ | |||||||
|  | use std::{collections::HashMap, ops::DerefMut}; | ||||||
|  |  | ||||||
| use crate::model::user::User; | use crate::model::user::User; | ||||||
| use chrono::Datelike; | use chrono::Datelike; | ||||||
| use serde::Serialize; | use serde::Serialize; | ||||||
| use sqlx::{FromRow, Row, SqlitePool}; | use sqlx::{FromRow, Row, Sqlite, SqlitePool, Transaction}; | ||||||
|  |  | ||||||
|  | use super::boat::Boat; | ||||||
|  |  | ||||||
|  | #[derive(Serialize, Clone)] | ||||||
|  | pub struct BoatStat { | ||||||
|  |     pot_years: Vec<i32>, | ||||||
|  |     boats: Vec<SingleBoatStat>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #[derive(Serialize, Clone)] | ||||||
|  | pub struct SingleBoatStat { | ||||||
|  |     name: String, | ||||||
|  |     cat: String, | ||||||
|  |     location: String, | ||||||
|  |     owner: String, | ||||||
|  |     years: HashMap<String, i32>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl BoatStat { | ||||||
|  |     pub async fn get(db: &SqlitePool) -> BoatStat { | ||||||
|  |         let mut years = Vec::new(); | ||||||
|  |         let mut boat_stats_map: HashMap<String, SingleBoatStat> = HashMap::new(); | ||||||
|  |  | ||||||
|  |         let rows = sqlx::query( | ||||||
|  |             " | ||||||
|  | SELECT  | ||||||
|  |     boat.id,  | ||||||
|  |     location.name AS location, | ||||||
|  |     CAST(strftime('%Y', COALESCE(arrival, 'now')) AS INTEGER) AS year,  | ||||||
|  |     CAST(SUM(COALESCE(distance_in_km, 0)) AS INTEGER) AS rowed_km | ||||||
|  | FROM  | ||||||
|  |     boat | ||||||
|  | LEFT JOIN  | ||||||
|  |     logbook ON boat.id = logbook.boat_id AND logbook.arrival IS NOT NULL | ||||||
|  | LEFT JOIN  | ||||||
|  |     location ON boat.location_id = location.id | ||||||
|  | WHERE  | ||||||
|  |     not boat.external | ||||||
|  | GROUP BY  | ||||||
|  |     boat.id, year | ||||||
|  | ORDER BY  | ||||||
|  |     boat.name, year DESC; | ||||||
|  |         ", | ||||||
|  |         ) | ||||||
|  |         .fetch_all(db) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         for row in rows { | ||||||
|  |             let id: i32 = row.get("id"); | ||||||
|  |             let boat = Boat::find_by_id(db, id).await.unwrap(); | ||||||
|  |             let owner = if let Some(owner) = boat.owner(db).await { | ||||||
|  |                 owner.name | ||||||
|  |             } else { | ||||||
|  |                 String::from("Verein") | ||||||
|  |             }; | ||||||
|  |             let name = boat.name.clone(); | ||||||
|  |             let location: String = row.get("location"); | ||||||
|  |             let year: i32 = row.get("year"); | ||||||
|  |             if year == 0 { | ||||||
|  |                 continue; // Boat still on water | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if !years.contains(&year) { | ||||||
|  |                 years.push(year); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             let year: String = format!("{year}"); | ||||||
|  |             let cat = boat.cat(); | ||||||
|  |  | ||||||
|  |             let rowed_km: i32 = row.get("rowed_km"); | ||||||
|  |  | ||||||
|  |             let boat_stat = boat_stats_map | ||||||
|  |                 .entry(name.clone()) | ||||||
|  |                 .or_insert(SingleBoatStat { | ||||||
|  |                     name, | ||||||
|  |                     location, | ||||||
|  |                     owner, | ||||||
|  |                     cat, | ||||||
|  |                     years: HashMap::new(), | ||||||
|  |                 }); | ||||||
|  |             boat_stat.years.insert(year, rowed_km); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         BoatStat { | ||||||
|  |             pot_years: years, | ||||||
|  |             boats: boat_stats_map.into_values().collect(), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| #[derive(FromRow, Serialize, Clone)] | #[derive(FromRow, Serialize, Clone)] | ||||||
| pub struct Stat { | pub struct Stat { | ||||||
|     name: String, |     name: String, | ||||||
|     rowed_km: i32, |     pub(crate) amount_trips: i32, | ||||||
|  |     pub(crate) rowed_km: i32, | ||||||
| } | } | ||||||
|  |  | ||||||
| impl Stat { | impl Stat { | ||||||
|     pub async fn boats(db: &SqlitePool, year: Option<i32>) -> Vec<Stat> { |  | ||||||
|         let year = match year { |  | ||||||
|             Some(year) => year, |  | ||||||
|             None => chrono::Utc::now().year(), |  | ||||||
|         }; |  | ||||||
|         //TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server) |  | ||||||
|         sqlx::query(&format!( |  | ||||||
|             " |  | ||||||
| SELECT (SELECT name FROM boat WHERE id=logbook.boat_id) as name, CAST(SUM(distance_in_km) AS INTEGER) AS rowed_km |  | ||||||
| FROM logbook  |  | ||||||
| WHERE arrival LIKE '{year}-%' AND name != 'Externes Boot' |  | ||||||
| GROUP BY boat_id  |  | ||||||
| ORDER BY rowed_km DESC; |  | ||||||
| ") |  | ||||||
|         ) |  | ||||||
|         .fetch_all(db) |  | ||||||
|         .await |  | ||||||
|         .unwrap() |  | ||||||
|         .into_iter() |  | ||||||
|         .map(|row| Stat { |  | ||||||
|             name: row.get("name"), |  | ||||||
|             rowed_km: row.get("rowed_km"), |  | ||||||
|         }) |  | ||||||
|         .collect() |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     pub async fn guest(db: &SqlitePool, year: Option<i32>) -> Stat { |     pub async fn guest(db: &SqlitePool, year: Option<i32>) -> Stat { | ||||||
|         let year = match year { |         let year = match year { | ||||||
|             Some(year) => year, |             Some(year) => year, | ||||||
|             None => chrono::Utc::now().year(), |             None => chrono::Local::now().year(), | ||||||
|         }; |         }; | ||||||
|         //TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server) |         //TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server) | ||||||
|         let rowed_km = sqlx::query(&format!( |         // proper guests | ||||||
|  |         let guests = sqlx::query(&format!( | ||||||
|             " |             " | ||||||
| SELECT SUM((b.amount_seats - COALESCE(m.member_count, 0)) * l.distance_in_km) as total_guest_km | SELECT SUM((b.amount_seats - COALESCE(m.member_count, 0)) * l.distance_in_km) as total_guest_km, | ||||||
|  |     SUM(b.amount_seats - COALESCE(m.member_count, 0)) AS amount_trips | ||||||
| FROM logbook l | FROM logbook l | ||||||
| JOIN boat b ON l.boat_id = b.id | JOIN boat b ON l.boat_id = b.id | ||||||
| LEFT JOIN ( | LEFT JOIN ( | ||||||
| @@ -52,58 +121,89 @@ LEFT JOIN ( | |||||||
|     FROM rower |     FROM rower | ||||||
|     GROUP BY logbook_id |     GROUP BY logbook_id | ||||||
| ) m ON l.id = m.logbook_id | ) m ON l.id = m.logbook_id | ||||||
| WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%' AND b.name != 'Externes Boot'; | WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%' AND not b.external; | ||||||
| " | " | ||||||
|         )) |         )) | ||||||
|         .fetch_one(db) |         .fetch_one(db) | ||||||
|         .await |         .await | ||||||
|         .unwrap() |         .unwrap(); | ||||||
|         .get::<i64, usize>(0) as i32; |  | ||||||
|  |  | ||||||
|         let rowed_km_guests = sqlx::query(&format!( |         let guest_km: i32 = guests.get(0); | ||||||
|  |         let guest_amount_trips: i32 = guests.get(1); | ||||||
|  |  | ||||||
|  |         // e.g. scheckbücher | ||||||
|  |         let guest_user = sqlx::query(&format!( | ||||||
|             " |             " | ||||||
| SELECT CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km  | SELECT CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km, COUNT(*) AS amount_trips | ||||||
| FROM user u | FROM user u | ||||||
| INNER JOIN rower r ON u.id = r.rower_id | INNER JOIN rower r ON u.id = r.rower_id | ||||||
| INNER JOIN logbook l ON r.logbook_id = l.id | INNER JOIN logbook l ON r.logbook_id = l.id | ||||||
| INNER JOIN user_role ur ON u.id = ur.user_id | WHERE u.id NOT IN ( | ||||||
| INNER JOIN role ro ON ur.role_id = ro.id |     SELECT ur.user_id | ||||||
| WHERE ro.name = 'scheckbuch' AND l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%'; |     FROM user_role ur | ||||||
|  |     INNER JOIN role ro ON ur.role_id = ro.id | ||||||
|  |     WHERE ro.name = 'Donau Linz' | ||||||
|  | ) | ||||||
|  | AND l.distance_in_km IS NOT NULL | ||||||
|  | AND l.arrival LIKE '{year}-%' | ||||||
|  | AND u.name != 'Externe Steuerperson'; | ||||||
| " | " | ||||||
|         )) |         )) | ||||||
|         .fetch_one(db) |         .fetch_one(db) | ||||||
|         .await |         .await | ||||||
|         .unwrap() |         .unwrap(); | ||||||
|         .get::<i64, usize>(0) as i32; |  | ||||||
|  |         let guest_user_km: i32 = guest_user.get(0); | ||||||
|  |         let guest_user_amount_trips: i32 = guest_user.get(1); | ||||||
|  |  | ||||||
|         Stat { |         Stat { | ||||||
|             name: "Gäste".into(), |             name: "Gäste".into(), | ||||||
|             rowed_km: rowed_km + rowed_km_guests, |             amount_trips: guest_amount_trips + guest_user_amount_trips, | ||||||
|  |             rowed_km: guest_km + guest_user_km, | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     pub async fn trips_people(db: &SqlitePool, year: Option<i32>) -> i32 { | ||||||
|  |         let stats = Self::people(db, year).await; | ||||||
|  |         let mut sum = 0; | ||||||
|  |         for stat in stats { | ||||||
|  |             sum += stat.amount_trips; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         sum | ||||||
|  |     } | ||||||
|  |     pub async fn sum_people(db: &SqlitePool, year: Option<i32>) -> i32 { | ||||||
|  |         let stats = Self::people(db, year).await; | ||||||
|  |         let mut sum = 0; | ||||||
|  |         for stat in stats { | ||||||
|  |             sum += stat.rowed_km; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         sum | ||||||
|  |     } | ||||||
|  |  | ||||||
|     pub async fn people(db: &SqlitePool, year: Option<i32>) -> Vec<Stat> { |     pub async fn people(db: &SqlitePool, year: Option<i32>) -> Vec<Stat> { | ||||||
|         let year = match year { |         let year = match year { | ||||||
|             Some(year) => year, |             Some(year) => year, | ||||||
|             None => chrono::Utc::now().year(), |             None => chrono::Local::now().year(), | ||||||
|         }; |         }; | ||||||
|         //TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server) |         //TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server) | ||||||
|         sqlx::query(&format!( |         sqlx::query(&format!( | ||||||
|             " |             " | ||||||
| SELECT u.name, CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km  | SELECT u.name, CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km, COUNT(*) AS amount_trips | ||||||
| FROM ( | FROM ( | ||||||
|     SELECT * FROM user  |     SELECT * FROM user  | ||||||
|     WHERE id NOT IN ( |     WHERE id IN ( | ||||||
|         SELECT user_id FROM user_role  |         SELECT user_id FROM user_role  | ||||||
|         JOIN role ON user_role.role_id = role.id  |         JOIN role ON user_role.role_id = role.id  | ||||||
|         WHERE role.name = 'scheckbuch' |         WHERE role.name = 'Donau Linz' | ||||||
|     ) |     ) | ||||||
| ) u | ) u | ||||||
| INNER JOIN rower r ON u.id = r.rower_id | INNER JOIN rower r ON u.id = r.rower_id | ||||||
| INNER JOIN logbook l ON r.logbook_id = l.id | INNER JOIN logbook l ON r.logbook_id = l.id | ||||||
| WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%' AND u.name != 'Externe Steuerperson' | WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%' AND u.name != 'Externe Steuerperson' | ||||||
| GROUP BY u.name | GROUP BY u.name | ||||||
| ORDER BY rowed_km DESC; | ORDER BY rowed_km DESC, u.name; | ||||||
| " | " | ||||||
|         )) |         )) | ||||||
|         .fetch_all(db) |         .fetch_all(db) | ||||||
| @@ -112,10 +212,85 @@ ORDER BY rowed_km DESC; | |||||||
|         .into_iter() |         .into_iter() | ||||||
|         .map(|row| Stat { |         .map(|row| Stat { | ||||||
|             name: row.get("name"), |             name: row.get("name"), | ||||||
|  |             amount_trips: row.get("amount_trips"), | ||||||
|             rowed_km: row.get("rowed_km"), |             rowed_km: row.get("rowed_km"), | ||||||
|         }) |         }) | ||||||
|         .collect() |         .collect() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     pub async fn total_km_tx(db: &mut Transaction<'_, Sqlite>, user: &User) -> Stat { | ||||||
|  |         //TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server) | ||||||
|  |         let row = sqlx::query(&format!( | ||||||
|  |             " | ||||||
|  | SELECT u.name, CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km, COUNT(*) AS amount_trips | ||||||
|  | FROM ( | ||||||
|  |     SELECT * FROM user  | ||||||
|  |     WHERE id={} | ||||||
|  | ) u | ||||||
|  | INNER JOIN rower r ON u.id = r.rower_id | ||||||
|  | INNER JOIN logbook l ON r.logbook_id = l.id | ||||||
|  | WHERE l.distance_in_km IS NOT NULL; | ||||||
|  | ", | ||||||
|  |             user.id | ||||||
|  |         )) | ||||||
|  |         .fetch_one(db.deref_mut()) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         Stat { | ||||||
|  |             name: row.get("name"), | ||||||
|  |             amount_trips: row.get("amount_trips"), | ||||||
|  |             rowed_km: row.get("rowed_km"), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn total_km(db: &SqlitePool, user: &User) -> Stat { | ||||||
|  |         let mut tx = db.begin().await.unwrap(); | ||||||
|  |         let ret = Self::total_km_tx(&mut tx, user).await; | ||||||
|  |         tx.commit().await.unwrap(); | ||||||
|  |         ret | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn person_tx( | ||||||
|  |         db: &mut Transaction<'_, Sqlite>, | ||||||
|  |         year: Option<i32>, | ||||||
|  |         user: &User, | ||||||
|  |     ) -> Stat { | ||||||
|  |         let year = match year { | ||||||
|  |             Some(year) => year, | ||||||
|  |             None => chrono::Local::now().year(), | ||||||
|  |         }; | ||||||
|  |         //TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server) | ||||||
|  |         let row = sqlx::query(&format!( | ||||||
|  |             " | ||||||
|  | SELECT u.name, CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km, COUNT(*) AS amount_trips | ||||||
|  | FROM ( | ||||||
|  |     SELECT * FROM user  | ||||||
|  |     WHERE id={} | ||||||
|  | ) u | ||||||
|  | INNER JOIN rower r ON u.id = r.rower_id | ||||||
|  | INNER JOIN logbook l ON r.logbook_id = l.id | ||||||
|  | WHERE l.distance_in_km IS NOT NULL AND l.arrival LIKE '{year}-%'; | ||||||
|  | ", | ||||||
|  |             user.id | ||||||
|  |         )) | ||||||
|  |         .fetch_one(db.deref_mut()) | ||||||
|  |         .await | ||||||
|  |         .unwrap(); | ||||||
|  |  | ||||||
|  |         Stat { | ||||||
|  |             name: row.get("name"), | ||||||
|  |             amount_trips: row.get("amount_trips"), | ||||||
|  |             rowed_km: row.get("rowed_km"), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     pub async fn person(db: &SqlitePool, year: Option<i32>, user: &User) -> Stat { | ||||||
|  |         let mut tx = db.begin().await.unwrap(); | ||||||
|  |         let ret = Self::person_tx(&mut tx, year, user).await; | ||||||
|  |         tx.commit().await.unwrap(); | ||||||
|  |         ret | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(Debug, Serialize)] | #[derive(Debug, Serialize)] | ||||||
| @@ -139,7 +314,7 @@ FROM ( | |||||||
|     LEFT JOIN  |     LEFT JOIN  | ||||||
|         rower r ON l.id = r.logbook_id |         rower r ON l.id = r.logbook_id | ||||||
|     WHERE  |     WHERE  | ||||||
|         l.shipmaster = {0} OR r.rower_id = {0} |        r.rower_id = {} | ||||||
|     GROUP BY  |     GROUP BY  | ||||||
|         departure_date |         departure_date | ||||||
| ) as subquery | ) as subquery | ||||||
|   | |||||||
							
								
								
									
										31
									
								
								src/model/trailer.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,31 @@ | |||||||
|  | use std::ops::DerefMut; | ||||||
|  |  | ||||||
|  | use rocket::serde::{Deserialize, Serialize}; | ||||||
|  | use sqlx::{FromRow, Sqlite, SqlitePool, Transaction}; | ||||||
|  |  | ||||||
|  | #[derive(FromRow, Debug, Serialize, Deserialize, Eq, Hash, PartialEq, Clone)] | ||||||
|  | pub struct Trailer { | ||||||
|  |     pub id: i64, | ||||||
|  |     pub name: String, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | impl Trailer { | ||||||
|  |     pub async fn find_by_id(db: &SqlitePool, id: i32) -> Option<Self> { | ||||||
|  |         sqlx::query_as!(Self, "SELECT id, name FROM trailer WHERE id like ?", id) | ||||||
|  |             .fetch_one(db) | ||||||
|  |             .await | ||||||
|  |             .ok() | ||||||
|  |     } | ||||||
|  |     pub async fn find_by_id_tx(db: &mut Transaction<'_, Sqlite>, id: i32) -> Option<Self> { | ||||||
|  |         sqlx::query_as!(Self, "SELECT id, name FROM trailer WHERE id like ?", id) | ||||||
|  |             .fetch_one(db.deref_mut()) | ||||||
|  |             .await | ||||||
|  |             .ok() | ||||||
|  |     } | ||||||
|  |     pub async fn all(db: &SqlitePool) -> Vec<Self> { | ||||||
|  |         sqlx::query_as!(Self, "SELECT id, name FROM trailer") | ||||||
|  |             .fetch_all(db) | ||||||
|  |             .await | ||||||
|  |             .unwrap() | ||||||
|  |     } | ||||||
|  | } | ||||||