Compare commits
1094 Commits
Author | SHA1 | Date |
---|---|---|
Emanuel Schütze | 420422e2cf | |
Emanuel Schütze | 8d20c40ca1 | |
Sean | 1b3af8cecd | |
Emanuel Schütze | bd22957740 | |
Emanuel Schütze | 74b1527bbe | |
Sean | 91a9e19f09 | |
GabrielMeyer | 932eb647ab | |
Emanuel Schütze | 0c30be5308 | |
Emanuel Schütze | ac5a975a4c | |
GabrielInTheWorld | 70d5b32bd7 | |
Sean | eab60ab31a | |
Sean | 8c7a770f9b | |
Sean | b2ae972ce0 | |
Emanuel Schütze | 8d90c6821e | |
Emanuel Schütze | 2287f3bf4f | |
Sean | 18ffcaca92 | |
Sean | 802ac1aee8 | |
Emanuel Schütze | 759e23e15b | |
Sean | e8507414d9 | |
Emanuel Schütze | c48658210b | |
Sean | 0ee14c9986 | |
Oskar Hahn | 6c01c2b99b | |
Oskar Hahn | cd7421b732 | |
Emanuel Schütze | 1b2169dd45 | |
Sean | b2ac759379 | |
Emanuel Schütze | 9d60951344 | |
Sean | 06a34a8f03 | |
Sean | 30bf5bc808 | |
Sean | 73f98ff55e | |
Sean | d643028410 | |
Sean | cbb9bd14d8 | |
Sean | bf7eba603d | |
Sean | 7022ab8cec | |
Sean | f86f8ec9b4 | |
Sean | 5bb9553cd3 | |
Sean | d4df7ebaed | |
Finn Stutzenstein | b656d354cd | |
Sean | 98e4f05b90 | |
Gernot Schulz | 301c9bd35a | |
Gernot Schulz | 7dc35dce40 | |
Gernot Schulz | 1d8856b501 | |
Gernot Schulz | 157293530c | |
Gernot Schulz | ed64e7bd13 | |
Gernot Schulz | f00c4391a2 | |
Sean | 18f2e3326d | |
Gernot Schulz | 5b28763bcc | |
Emanuel Schütze | 52f35c856d | |
Finn Stutzenstein | 1c857fb08c | |
Sean | 0a3ea54a45 | |
Emanuel Schütze | c593599e10 | |
Finn Stutzenstein | d17a050d8b | |
Finn Stutzenstein | 19a9eedf17 | |
Finn Stutzenstein | 27831154fa | |
jsangmeister | b4b0a958d5 | |
Joshua Sangmeister | 5d13f94e40 | |
Emanuel Schütze | 0322436cf5 | |
Finn Stutzenstein | 272c3de9fc | |
Emanuel Schütze | 416b2cc26f | |
Joshua Sangmeister | 3532c55924 | |
Emanuel Schütze | f1e2996c6e | |
Emanuel Schütze | c0582e4b1c | |
Sean | eed3bca25b | |
Sean | 57755ebf4b | |
Emanuel Schütze | c286e0ff76 | |
Emanuel Schütze | a14ab8c5e2 | |
Sean | 235bbf6c0d | |
Finn Stutzenstein | 8423433f66 | |
Emanuel Schütze | 382fcf4a67 | |
Oskar Hahn | 9a9ef8e039 | |
Emanuel Schütze | eeb0e54985 | |
Finn Stutzenstein | 5d434484b5 | |
Emanuel Schütze | 8d73f08072 | |
Emanuel Schütze | d8713dad11 | |
Emanuel Schütze | bf22e1b6e1 | |
Sean | 83949ad402 | |
Emanuel Schütze | 5a8a87070a | |
Finn Stutzenstein | bd3c8e09d8 | |
Emanuel Schütze | d9d07c3c9b | |
Sean | 99acdab35f | |
Emanuel Schütze | 1daee9c6d6 | |
Emanuel Schütze | a8611904cc | |
Sean | 6d63c90649 | |
Sean | 15fb19ad2f | |
Emanuel Schütze | 136e0a0569 | |
Sean | 1ed1896653 | |
Emanuel Schütze | 7a076b1d2d | |
Emanuel Schütze | 4938c34d50 | |
Sean | 8b22f5ff0e | |
Sean | 6b8ccb8c33 | |
Emanuel Schütze | 7dcdbb4ee1 | |
Emanuel Schütze | 51568b652e | |
Emanuel Schütze | 8c0aba83bc | |
Emanuel Schütze | cccfec9154 | |
Sean | 1504e33607 | |
Emanuel Schütze | d570ed2b72 | |
Emanuel Schütze | 5f8bb17cac | |
Emanuel Schütze | 654162dbce | |
Emanuel Schütze | e0da18a0e6 | |
Finn Stutzenstein | 946f07f34a | |
Emanuel Schütze | 3a2773aa5d | |
Sean | e7448aa378 | |
dependabot[bot] | 75ed60168e | |
Sean | be36a8a40f | |
Emanuel Schütze | 37782fcae8 | |
Emanuel Schütze | 5d57301fb0 | |
Emanuel Schütze | bf209d023c | |
Emanuel Schütze | f3b6ceb9af | |
Sean | e7dd5d87a2 | |
Finn Stutzenstein | 899257791d | |
Emanuel Schütze | 3eb7386a58 | |
Emanuel Schütze | c60553e376 | |
Sean | 8734e48aef | |
Emanuel Schütze | 79e5b8e337 | |
Sean | 6dc5c3bfa9 | |
Sean | d4025296fe | |
Emanuel Schütze | a7f392b997 | |
Sean | 6451cdf590 | |
Emanuel Schütze | 2eda8fea22 | |
Sean | 74b32af293 | |
Emanuel Schütze | 43d73a87f1 | |
Emanuel Schütze | 66945231f4 | |
Emanuel Schütze | 5a9767004d | |
Emanuel Schütze | a0c9f3b6da | |
Sean | f05dd8c448 | |
Emanuel Schütze | 14fe614c4d | |
Sean | 7a6b31d8f8 | |
Sean | f707615875 | |
Sean | fbb60cb0b6 | |
Sean | 861726fb9c | |
Sean | 4d4a3bb0db | |
Finn Stutzenstein | 9a7c9c19a0 | |
Finn Stutzenstein | f4c237a18e | |
Emanuel Schütze | 710e825961 | |
Emanuel Schütze | cb13c8cd47 | |
Emanuel Schütze | 83efb19562 | |
Emanuel Schütze | 964a41386d | |
Finn Stutzenstein | 1e78f2a534 | |
Sean | 3402928935 | |
Emanuel Schütze | 2a7b55f11e | |
Sean | 18c75a6d12 | |
Emanuel Schütze | 7cf22b86ab | |
Sean | 0011d63a40 | |
Sean | 08fa38a89c | |
Emanuel Schütze | 5cee662058 | |
Sean | 0b23806db6 | |
Emanuel Schütze | acf416b024 | |
Emanuel Schütze | ec2c905f7d | |
Emanuel Schütze | 74981e26c0 | |
Emanuel Schütze | 261c69387f | |
Tobias Hößl | 705c42bd11 | |
Emanuel Schütze | d86fcd80b7 | |
Emanuel Schütze | 88e870c9df | |
Emanuel Schütze | 325469bc82 | |
Emanuel Schütze | 14687bba0e | |
Emanuel Schütze | db4c593adf | |
Finn Stutzenstein | 75bd3c50e5 | |
Sean | d08752db21 | |
Sean | 85bb9f751d | |
Emanuel Schütze | d030925e14 | |
Emanuel Schütze | 22df847c78 | |
Emanuel Schütze | 672e5ca544 | |
Emanuel Schütze | 9f543697ad | |
Finn Stutzenstein | c9c90cd4a3 | |
Finn Stutzenstein | 91a15d24a8 | |
Joshua Sangmeister | 8ad008d9de | |
Finn Stutzenstein | ee8702aff1 | |
Finn Stutzenstein | 0d4673d182 | |
Finn Stutzenstein | f9d19db9e2 | |
Emanuel Schütze | 7315626e18 | |
Joshua Sangmeister | e2d4fafe6d | |
Emanuel Schütze | fafcf5d583 | |
Finn Stutzenstein | 0a2483a94b | |
Finn Stutzenstein | a8e329253c | |
Emanuel Schütze | 4f35770769 | |
Sean | 697177640b | |
Emanuel Schütze | 196e39ad15 | |
Joshua Sangmeister | ee31c1e633 | |
Emanuel Schütze | bdbb5839cc | |
Emanuel Schütze | dff5ae4a89 | |
Finn Stutzenstein | 79d9781a1b | |
Finn Stutzenstein | e3c627b504 | |
Emanuel Schütze | 963986b91d | |
Finn Stutzenstein | 2b3d1db3bf | |
Finn Stutzenstein | a1e7920b34 | |
Emanuel Schütze | feb54c52a3 | |
Emanuel Schütze | bb651b67eb | |
Emanuel Schütze | 5b58730cca | |
Oskar Hahn | 23fcc3a7d0 | |
Emanuel Schütze | d80919f0e7 | |
Joshua Sangmeister | 8891a52bdc | |
Emanuel Schütze | 26d5d81b6f | |
Joshua Sangmeister | 1e6b042d71 | |
Joshua Sangmeister | f54050a83c | |
Finn Stutzenstein | a94f00672b | |
Finn Stutzenstein | 7ca761bdb0 | |
Emanuel Schütze | 1edf4437a0 | |
Tobias Hößl | ba177a89d4 | |
Emanuel Schütze | d059afac5a | |
Emanuel Schütze | 045648eddb | |
jsangmeister | 787390c899 | |
Joshua Sangmeister | 4b13ff681e | |
Adrian Nöthlich | 6430727590 | |
Emanuel Schütze | 83549ce02b | |
Oskar Hahn | dca6143041 | |
Emanuel Schütze | 2d4419530e | |
Finn Stutzenstein | 28980afbd5 | |
Tobias Hößl | 7275aa69af | |
Gernot Schulz | 1ad0a61524 | |
Finn Stutzenstein | ca298960ae | |
Emanuel Schütze | 2f9b6aba95 | |
Sean | 49dba31d56 | |
Emanuel Schütze | f68fca8c83 | |
Emanuel Schütze | 2e43a17987 | |
Emanuel Schütze | 2e5cea512e | |
Tobias Hößl | 9c2e49692c | |
Emanuel Schütze | c9b924d79a | |
Finn Stutzenstein | 644d3b2fee | |
Finn Stutzenstein | f897bb01a3 | |
Finn Stutzenstein | 41a3447357 | |
Finn Stutzenstein | 7bbd8688a2 | |
Finn Stutzenstein | c10a0ad70d | |
Emanuel Schütze | 3595245663 | |
Emanuel Schütze | bd29777d83 | |
Emanuel Schütze | eadb0e2f0e | |
Emanuel Schütze | d1aba2ef94 | |
Emanuel Schütze | 2e1690d2d0 | |
Finn Stutzenstein | a48fe86791 | |
Finn Stutzenstein | 63132fdbc5 | |
Finn Stutzenstein | e45d83de5a | |
Finn Stutzenstein | 365d0d55ea | |
Adrian Nöthlich | 52108cd0c4 | |
Finn Stutzenstein | 520915c3f5 | |
Emanuel Schütze | b76e75ae96 | |
Emanuel Schütze | 1a538e241d | |
Martin Dickopp | a9d223121e | |
Emanuel Schütze | 4d706f648f | |
Emanuel Schütze | ba0e9b3bc6 | |
Emanuel Schütze | 92afd07b62 | |
Finn Stutzenstein | 3ba4f99876 | |
Tobias Hößl | bef322d0a4 | |
Emanuel Schütze | a11682a708 | |
Emanuel Schütze | bc9b028624 | |
Emanuel Schütze | 291402e159 | |
Emanuel Schütze | bd7fa9b3db | |
Tobias Hößl | ac50d6f8dc | |
Finn Stutzenstein | 265145f001 | |
Finn Stutzenstein | 39fb2fadec | |
Finn Stutzenstein | d62d1a687b | |
Finn Stutzenstein | d0c1879521 | |
Finn Stutzenstein | 93da435e7c | |
Emanuel Schütze | 45948c47fb | |
Finn Stutzenstein | 3504a87295 | |
Emanuel Schütze | d83b7c0ea9 | |
Emanuel Schütze | 155ade1a8c | |
Emanuel Schütze | f80ac1d9c5 | |
Emanuel Schütze | 2c7196493d | |
Emanuel Schütze | 46223328f7 | |
Finn Stutzenstein | 619a698272 | |
GabrielInTheWorld | a450a1dff5 | |
Emanuel Schütze | d73b2142b7 | |
Emanuel Schütze | 05fcf40b51 | |
Emanuel Schütze | 83ff7b938c | |
GabrielMeyer | 7314bf0999 | |
Emanuel Schütze | d3530a3657 | |
Emanuel Schütze | 3d8f3a69af | |
Emanuel Schütze | 7dcc0ad42a | |
Finn Stutzenstein | c35cacebb1 | |
Gernot Schulz | 614e0f2d5f | |
Emanuel Schütze | 4e6f0850c4 | |
Finn Stutzenstein | 33fca309c4 | |
Finn Stutzenstein | b13732f9ec | |
Finn Stutzenstein | 0a8274e6e2 | |
Finn Stutzenstein | a9045b6a1c | |
Emanuel Schütze | 2d4ece84a0 | |
Emanuel Schütze | a796b2a8b8 | |
Gernot Schulz | cd98502b1c | |
Gernot Schulz | 8d393ba17f | |
Emanuel Schütze | e75bdeb0f7 | |
Finn Stutzenstein | f8446ee609 | |
Emanuel Schütze | baad950698 | |
Finn Stutzenstein | 4929e2b6f6 | |
Emanuel Schütze | e72cebca4a | |
Emanuel Schütze | e11f0f6f25 | |
Emanuel Schütze | 9f16bfee21 | |
Felix Wolfsteller | 5152a448be | |
Emanuel Schütze | 500b773ee1 | |
Emanuel Schütze | a8fcb89f48 | |
Finn Stutzenstein | 085ada3dc4 | |
Finn Stutzenstein | ce3e2588c5 | |
Emanuel Schütze | 7e875c45db | |
GabrielMeyer | e74df38a0f | |
Emanuel Schütze | ef451afae1 | |
Sean | 4490ee91d0 | |
Emanuel Schütze | 3012fabf4f | |
Finn Stutzenstein | fe0f8d28f4 | |
Emanuel Schütze | a3a126f930 | |
Oskar Hahn | 69bf46a5ff | |
Finn Stutzenstein | 5b91ba4597 | |
Finn Stutzenstein | 35e8f84fda | |
Finn Stutzenstein | 05ec54927b | |
Finn Stutzenstein | e9c2dc90d5 | |
Emanuel Schütze | 7af39a5570 | |
Sean | eda242e83f | |
Emanuel Schütze | c1b4d3154d | |
Emanuel Schütze | 1cf2763ed6 | |
Emanuel Schütze | c0dad72eb4 | |
Emanuel Schütze | aac8ec8f2e | |
Sean | 69adc1d41c | |
Finn Stutzenstein | b0ccb1ea7e | |
Finn Stutzenstein | 7fffffb497 | |
Finn Stutzenstein | f65e8ae819 | |
Finn Stutzenstein | 8e5b1fa99d | |
Emanuel Schütze | 8e98966db2 | |
Gernot Schulz | 0ed4e27725 | |
Emanuel Schütze | 5910d2c914 | |
Emanuel Schütze | 612bf78871 | |
Emanuel Schütze | c569835ce1 | |
Gernot Schulz | a1e65e8a47 | |
Gernot Schulz | 67202a4a4b | |
Emanuel Schütze | ac0a27276c | |
Oskar Hahn | ab2a8ca419 | |
Emanuel Schütze | 180de2d3a9 | |
Oskar Hahn | d7d8dcb3c9 | |
Emanuel Schütze | d05958ca10 | |
Gernot Schulz | f7d228a600 | |
Gernot Schulz | 40dc0e08fa | |
Gernot Schulz | 470168c58c | |
Emanuel Schütze | b08948f3e5 | |
Norman Jäckel | c3de6dc870 | |
Emanuel Schütze | 09bc7f093a | |
Sean | 372f1eaa7e | |
Finn Stutzenstein | f57fe05e26 | |
Emanuel Schütze | 26744fde9f | |
Finn Stutzenstein | 1b482871ac | |
Finn Stutzenstein | 8746496d2d | |
Norman Jäckel | cc5bcf1a81 | |
Norman Jäckel | f74cf10ff3 | |
Finn Stutzenstein | 9e38ed955f | |
Sean | 058a7f71ae | |
Oskar Hahn | 799dd08e0d | |
Emanuel Schütze | 3b1b396e9a | |
Emanuel Schütze | 72b7162eeb | |
Oskar Hahn | dc58752575 | |
Finn Stutzenstein | 5b2f8409e4 | |
Finn Stutzenstein | a839294add | |
Emanuel Schütze | e5f0ebd6e5 | |
Sean | bd65b5d41c | |
Finn Stutzenstein | 94c943cdb5 | |
Sean | 04eedc7c37 | |
Finn Stutzenstein | a20641fe44 | |
Sean | 024b9c74e6 | |
Finn Stutzenstein | 1380812924 | |
Sean | cc65b756c7 | |
Sean | e3d718cad0 | |
Finn Stutzenstein | 47a2204921 | |
Sean | 878f3a7ab3 | |
Finn Stutzenstein | 676bda8cc3 | |
Martin Dickopp | 2130f4970f | |
Danilo Bürger | 11d7f7b888 | |
Gernot Schulz | 7e67e0db12 | |
Emanuel Schütze | 97950d5baa | |
Finn Stutzenstein | 8049bfa91e | |
Gernot Schulz | 0f0d750d83 | |
Finn Stutzenstein | 4f4bff9bb3 | |
Finn Stutzenstein | b2e6d2f2ac | |
Finn Stutzenstein | b200cfbd07 | |
FinnStutzenstein | e225a57f97 | |
Sean | 1145ae1460 | |
Sean | bc382df68f | |
Finn Stutzenstein | ea180246c7 | |
Sean | 0b01b5576b | |
Finn Stutzenstein | 7e763e8c07 | |
Emanuel Schütze | 010b61cce2 | |
Emanuel Schütze | 8542817129 | |
Finn Stutzenstein | b0ba30b454 | |
Finn Stutzenstein | 183c511046 | |
Emanuel Schütze | ea277adf9e | |
Emanuel Schütze | ef135837f7 | |
Emanuel Schütze | a37e2196b3 | |
Emanuel Schütze | 273debf99a | |
Emanuel Schütze | ab2fbaac79 | |
Emanuel Schütze | aeaedabb87 | |
Sean | 52c4aa6c58 | |
Emanuel Schütze | 227dfd0c26 | |
Emanuel Schütze | b179930cc8 | |
Sean | 3b062a52e7 | |
Emanuel Schütze | 266c129e04 | |
Sean | f07cc4e176 | |
Emanuel Schütze | 222a2ea581 | |
Emanuel Schütze | 057e03a82c | |
Emanuel Schütze | 1ebad842de | |
Sean | cb73f52345 | |
Sean | 53be648c23 | |
Sean | 2a224cb3b5 | |
Emanuel Schütze | acd33b8207 | |
Sean | e2cabbaf62 | |
Emanuel Schütze | 19df8184d0 | |
Emanuel Schütze | 0a80a73f2e | |
Sean | b9f36f1cea | |
Emanuel Schütze | 5d5a5b3e39 | |
Finn Stutzenstein | ef42a2293d | |
Sean | ac63a04666 | |
Sean | 78cfa4875e | |
Jochen Winzer | 689bfcac61 | |
Jochen Winzer | ec36d4d64e | |
Emanuel Schütze | 5cc464b250 | |
Sean | 92cf811921 | |
Sean | 1af78df328 | |
Sean | 9c738b5d8e | |
Emanuel Schütze | 9ddb3a9179 | |
Sean | 10614ca57b | |
Finn Stutzenstein | 182759e794 | |
Finn Stutzenstein | 78f0b29921 | |
Emanuel Schütze | 2943c969ab | |
Emanuel Schütze | ea4ec53fb1 | |
Sean | 944685696a | |
Emanuel Schütze | 5629c73b4b | |
Sean | d0ed5448e8 | |
Emanuel Schütze | 00b148edbd | |
Sean | 5e1b5b5658 | |
Emanuel Schütze | 96d464bcfa | |
Emanuel Schütze | 85de17611f | |
Sean | a410083349 | |
mathiashro | bdd44f78eb | |
Sean | ce2f71a9da | |
Sean | 90ac27ff43 | |
Finn Stutzenstein | 652b727386 | |
Finn Stutzenstein | e1183fff60 | |
Sean | 629ad4ec1f | |
Sean | 00066806d6 | |
Emanuel Schütze | 96f96f09ee | |
Emanuel Schütze | 48c09ed4c5 | |
Emanuel Schütze | c4f37999f3 | |
Emanuel Schütze | ad907de958 | |
Emanuel Schütze | 8b94829a2c | |
Emanuel Schütze | fbed661dfb | |
Emanuel Schütze | b318bfda99 | |
Emanuel Schütze | 5577bac7c9 | |
Finn Stutzenstein | c7405c36d8 | |
Joshua Sangmeister | fca688a1f7 | |
Sean | 26ac618ddf | |
Emanuel Schütze | c323eabd6f | |
Sean | a51103b7b7 | |
Sean | d6467d5bbf | |
Emanuel Schütze | 00bb266098 | |
Finn Stutzenstein | 0a67c24138 | |
Finn Stutzenstein | 667a841051 | |
Finn Stutzenstein | fd7a4cb64b | |
Finn Stutzenstein | 4f24a38da8 | |
Emanuel Schütze | 03cb8592fe | |
Emanuel Schütze | 1f302b466a | |
Sean | 65c7d3491c | |
Emanuel Schütze | b611642ecb | |
Sean | 1eee3bc56d | |
Sean | b5cb694fc7 | |
Finn Stutzenstein | f609e6362f | |
Emanuel Schütze | 36506a7383 | |
Finn Stutzenstein | 07a003717d | |
Emanuel Schütze | ab230fe7a9 | |
jsangmeister | 26e414e3d1 | |
Joshua Sangmeister | f3809fc8a9 | |
Sean | bed9b3a958 | |
Sean | 5b2fe01965 | |
Finn Stutzenstein | 38534d4e01 | |
Norman Jäckel | 6c35e225a5 | |
Bernhard E. Reiter | 6abaeb2155 | |
Emanuel Schütze | d446382f70 | |
Emanuel Schütze | 34070843c2 | |
Finn Stutzenstein | 01206cb7c6 | |
Sean | 2736917c7e | |
Finn Stutzenstein | 72dc55558f | |
Sean | 9ce8fe8233 | |
Sean | dd3dbea482 | |
Sean | a188abed48 | |
Sean | ade2d4b977 | |
Sean | be0deefdce | |
Emanuel Schütze | 7b0f8e3c25 | |
Emanuel Schütze | 88b25acd0a | |
Emanuel Schütze | 22f9108b49 | |
Emanuel Schütze | 019c097c26 | |
Emanuel Schütze | f9796027ef | |
Sean | 2364ed66ff | |
Emanuel Schütze | 9fcb6cdcba | |
Emanuel Schütze | 5b84bddc2a | |
Emanuel Schütze | def6e8d59d | |
Emanuel Schütze | a49ed17b45 | |
Emanuel Schütze | 7d97cede2d | |
Sean | 17e5d42d17 | |
Emanuel Schütze | 4c7bf0a203 | |
Emanuel Schütze | 85852d158a | |
Emanuel Schütze | beb59cee73 | |
Jochen Winzer | d89c7cfdb0 | |
Joshua Sangmeister | 883463ea87 | |
Finn Stutzenstein | 0c66afc34a | |
Jochen Winzer | 513f1477af | |
Jochen Winzer | 837af97d57 | |
Sean | 6bc2c104b1 | |
Sean | d4b92a2b4e | |
Sean | 7ad3b78eb2 | |
Jochen Winzer | 5ef1869a10 | |
Sean | ccc48e6b3f | |
Finn Stutzenstein | 866acfe7f5 | |
Finn Stutzenstein | 6943c3d18f | |
Sean | b391ed0dfe | |
Finn Stutzenstein | 06044e81c0 | |
Finn Stutzenstein | eacccd8f5c | |
Finn Stutzenstein | 61c5f77d29 | |
simonla82 | 5502e5337a | |
Finn Stutzenstein | 0956153ea4 | |
Finn Stutzenstein | 266f9b73e9 | |
Finn Stutzenstein | d4577ed8aa | |
Finn Stutzenstein | 582215042d | |
Finn Stutzenstein | 1dd86a29be | |
Finn Stutzenstein | 961a2da888 | |
Manfred Löbling | 2b5abf72a4 | |
Finn Stutzenstein | 7277a1bb01 | |
Max Bachhuber | b214a69136 | |
Finn Stutzenstein | b864d67cda | |
Gernot Schulz | 2305ca9d21 | |
Finn Stutzenstein | ca56b4f8b4 | |
Finn Stutzenstein | d2043f508c | |
Finn Stutzenstein | d317e032e7 | |
Finn Stutzenstein | 6c60834f37 | |
Jochen Winzer | 4fef8ed4dc | |
Finn Stutzenstein | b9f78f501d | |
Finn Stutzenstein | f809db0430 | |
Finn Stutzenstein | 1707c1f4fd | |
Finn Stutzenstein | 12e6090fa7 | |
Finn Stutzenstein | d739d401c4 | |
Emanuel Schütze | b050a87bb2 | |
Emanuel Schütze | e77b2518d5 | |
Emanuel Schütze | 21990aa568 | |
Emanuel Schütze | 58db337a40 | |
Sean | 70ea4f3658 | |
Emanuel Schütze | 25d83b4419 | |
Sean | 22a318bde2 | |
Finn Stutzenstein | fd2fd8d73a | |
Sean Engelhardt | 823a87c164 | |
Sean | 2162f2b049 | |
Emanuel Schütze | 28be46cf5a | |
Finn Stutzenstein | 435bb59472 | |
Sean | de474e9eae | |
Emanuel Schütze | 706c1d9e36 | |
Emanuel Schütze | e6fc32b9b4 | |
Emanuel Schütze | 2bb0134cd8 | |
Emanuel Schütze | be3fafd907 | |
Sean | daaf404756 | |
Sean | d22e0bf2f6 | |
Sean | ed1c3eaa7a | |
Sean | 9607f05454 | |
Sean | 2ca157bb7c | |
Sean | 25878f297f | |
Finn Stutzenstein | 8d2a7f1b12 | |
Manfred | a94ce67c22 | |
Finn Stutzenstein | deddd68121 | |
Sean | 28bac117be | |
Sean | 98a8de3c2d | |
Manfred Löbling | 49a3bcd930 | |
Sean | 8c28b03ffc | |
Joshua Sangmeister | 3ac8569712 | |
Finn Stutzenstein | c0fb65316c | |
Finn Stutzenstein | 90e13a0f8e | |
topelrapha | 88994efac3 | |
Finn Stutzenstein | ed2c298928 | |
Sean | 677a93e2ca | |
Sean | 991c08d57d | |
Sean | adcf98a69b | |
Sean | 6606e46f68 | |
Emanuel Schütze | e1d4a4152a | |
Sean | 42dd397fae | |
Emanuel Schütze | 3b2fbe8915 | |
Emanuel Schütze | 69299808b6 | |
Emanuel Schütze | cf4573cb54 | |
Emanuel Schütze | b392ac83aa | |
Sean Engelhardt | 7f53636b7b | |
Emanuel Schütze | 40c2a7fae4 | |
Sean Engelhardt | bc540180dd | |
Sean | ec13ab56e8 | |
Emanuel Schütze | acbddd3c53 | |
Sean | 6007799f1d | |
Emanuel Schütze | 2687d1abba | |
Emanuel Schütze | c4a2b02f5d | |
Emanuel Schütze | e8e39b1e89 | |
Sean | a42205e47f | |
Emanuel Schütze | 855db8241b | |
Emanuel Schütze | 688b1b276d | |
Emanuel Schütze | 3d7bfe652c | |
Tobias Hößl | 1a0e017f80 | |
Sean | df2e26c3ed | |
Sean | 4712707d6b | |
Emanuel Schütze | 909a7539c5 | |
Sean | 51512fd589 | |
Sean | 34f23b3d0e | |
Joshua Sangmeister | 8d92353047 | |
Emanuel Schütze | 04477d9ebd | |
Sean | bc333a6b51 | |
Sean | 594777960b | |
Emanuel Schütze | 2759f8ce2b | |
Emanuel Schütze | b555de8510 | |
Sean | b596bf0ca5 | |
Finn Stutzenstein | d893f3dbe5 | |
Emanuel Schütze | 9148d97f7a | |
Gernot Schulz | ed9e50a1b4 | |
Sean | eb98289b84 | |
Sean | f3fe98436e | |
Finn Stutzenstein | 5b63809b12 | |
Gernot Schulz | a408ee62ee | |
Finn Stutzenstein | 7446effe0f | |
Emanuel Schütze | fb27f8ce8a | |
Emanuel Schütze | 792f0e5d06 | |
Emanuel Schütze | 392c32fd92 | |
Sean | 28878a0b12 | |
Sean | 059ace3a11 | |
Emanuel Schütze | 06974b559e | |
Emanuel Schütze | 7a1e7c298d | |
Sean | ef87f05454 | |
Finn Stutzenstein | a329031942 | |
Finn Stutzenstein | 0367398cb5 | |
Finn Stutzenstein | 325c5ea1f4 | |
Emanuel Schütze | 9a4f8e1781 | |
Emanuel Schütze | 38af3d3b8a | |
Emanuel Schütze | 4960a8f115 | |
Sean | 0d16b487d5 | |
Sean | 756fdc9c66 | |
Tobias Hößl | fcdfad1c2e | |
Sean | 5070069910 | |
Sean | a9c1578ebb | |
Emanuel Schütze | aed17360e6 | |
Emanuel Schütze | d8f62a05ba | |
Emanuel Schütze | 1b6b70c080 | |
Sean | 7bf8e880fd | |
Sean | 7af65f790e | |
Sean | 0933bb6abd | |
Finn Stutzenstein | 216e4f00a3 | |
Gernot Schulz | 8faa2ad38f | |
Finn Stutzenstein | 9ddf9ddb8c | |
Gernot Schulz | 251296f42f | |
Gernot Schulz | 9a2d3a3760 | |
Gernot Schulz | 3cb3ef2974 | |
Gernot Schulz | 2b7e4d3d19 | |
Gernot Schulz | d1640bc98d | |
Gernot Schulz | 1c0724341c | |
Gernot Schulz | 418480bff5 | |
Gernot Schulz | 9c9f268fbf | |
Gernot Schulz | f694e9b2c4 | |
Gernot Schulz | 774fa4c204 | |
Gernot Schulz | 13db5687cb | |
Gernot Schulz | 63c4bc3ff7 | |
Gernot Schulz | 4f194a8794 | |
Gernot Schulz | d48794ae8a | |
Gernot Schulz | 683aed56bb | |
Finn Stutzenstein | 030378b48a | |
FinnStutzenstein | 2bcab5d098 | |
Finn Stutzenstein | 2c85bb28f1 | |
Sean | 2b55388870 | |
Sean | fbf424e570 | |
Finn Stutzenstein | 2e8e32454e | |
Finn Stutzenstein | 389a244615 | |
Emanuel Schütze | a46d8ec7ad | |
Sean | b59c69e086 | |
Emanuel Schütze | 2b3766b758 | |
Sean | c0f5c7b548 | |
Emanuel Schütze | c6abbb629e | |
Emanuel Schütze | a40657e153 | |
Sean | e75573e139 | |
Emanuel Schütze | 5f5f704057 | |
Emanuel Schütze | b726801747 | |
Emanuel Schütze | a48592af50 | |
Emanuel Schütze | 72a53c5cd0 | |
Finn Stutzenstein | d682d0d134 | |
Sean | bfe72497cd | |
Finn Stutzenstein | c6bc5978e2 | |
Sean | 65ee468c21 | |
Sean | 09a10c7e92 | |
Finn Stutzenstein | ccc3e38427 | |
Manu | 8d25f6ae15 | |
Emanuel Schütze | 23ae32a758 | |
Emanuel Schütze | fbbcd6fa94 | |
Sean | 2c17d7b7aa | |
Sean | e268903536 | |
Emanuel Schütze | 6e2e1ebe7a | |
Emanuel Schütze | 30e8f7d87f | |
Emanuel Schütze | 9aefb122e6 | |
Emanuel Schütze | 5618c04416 | |
Emanuel Schütze | ee344032b7 | |
Emanuel Schütze | 6e80ff5f00 | |
Emanuel Schütze | 47113f14fc | |
Sean | 7d912d82de | |
Emanuel Schütze | ebf8325ded | |
Emanuel Schütze | 03acae26ff | |
Emanuel Schütze | 271ccdd46a | |
Sean | 109fea791d | |
Sean | c2bd7c16a9 | |
Sean | 7ded2cd8a1 | |
Emanuel Schütze | 85a22ed99c | |
Sean | e2597002e2 | |
Emanuel Schütze | 01ce1409d3 | |
Emanuel Schütze | 74e3ea119e | |
Emanuel Schütze | 9e55cb1480 | |
Emanuel Schütze | 20175a1a6b | |
Sean | 719d1d1cf1 | |
Sean | 2835e746e8 | |
Emanuel Schütze | a7703a5557 | |
Emanuel Schütze | da4092768e | |
Sean | 32775b0a2a | |
Sean | 011c23093f | |
Emanuel Schütze | 3063a9e9fc | |
Sean | 656fcccee1 | |
Emanuel Schütze | e35b658731 | |
Emanuel Schütze | d76d74e225 | |
Finn Stutzenstein | 9eeb287425 | |
Sean | 5666749e62 | |
Finn Stutzenstein | eeb97c44fd | |
Emanuel Schütze | fa1347f611 | |
Emanuel Schütze | 278b33c2d7 | |
ApolloLV | 1cb8ef2d14 | |
Emanuel Schütze | ba3c5e07f7 | |
Emanuel Schütze | 55f1d02fcc | |
Emanuel Schütze | 378d091dbd | |
Emanuel Schütze | cb8f219163 | |
Emanuel Schütze | 66757b04ae | |
Sean | 346413fbb0 | |
Finn Stutzenstein | cb190331f3 | |
Sean | 23ee6a2951 | |
Sean | f59ce9ef3b | |
Sean | f5654f3a8c | |
FinnStutzenstein | 4a96aa31c1 | |
Emanuel Schütze | fab51091b1 | |
Emanuel Schütze | c1d63b320d | |
Sean | 988ee0fe93 | |
Sean | 3d252060c9 | |
Emanuel Schütze | 6898458695 | |
Sean | c2a1b62c8b | |
Sean | bb10c25974 | |
Sean | fde745530e | |
Emanuel Schütze | 9a47cff7fa | |
Sean | 22a374a150 | |
Emanuel Schütze | f70953f454 | |
Emanuel Schütze | 435f555559 | |
Sean | 9cf602f0c1 | |
Emanuel Schütze | 2fd4e70b0c | |
Emanuel Schütze | 81b021ab47 | |
FinnStutzenstein | fd371b87e4 | |
Emanuel Schütze | e20c93d445 | |
Sean | 55f65576f0 | |
Emanuel Schütze | d558c293b2 | |
Joshua Sangmeister | 44f1d1e819 | |
Sean | 677595fe5b | |
Sean | 912a528f8a | |
Sean | 9feaa59ebb | |
Emanuel Schütze | b712af2d6d | |
Emanuel Schütze | 81c2df3458 | |
FinnStutzenstein | 6a59e678a9 | |
Sean | 00e644292d | |
Emanuel Schütze | b43151fd59 | |
Sean | fbbc4389fb | |
Emanuel Schütze | d53e85b853 | |
Emanuel Schütze | 68c77fe52c | |
Sean | bc1373b696 | |
Sean | b9fbf4209b | |
Sean | ec2ec08333 | |
Sean | 958f0fb786 | |
Emanuel Schütze | ac4cb39105 | |
Emanuel Schütze | b5bc855dfe | |
Sean | 1f876ec6dd | |
Emanuel Schütze | c1605929e9 | |
Emanuel Schütze | 2ea95937d7 | |
Emanuel Schütze | a80915397d | |
Sean | f06f2dee9f | |
Sean | 33ba8c4628 | |
FinnStutzenstein | dc7dfc1936 | |
Sean | 7d3280707d | |
Sean | 13cbece9d9 | |
Sean | 5ed9c88ae4 | |
Finn Stutzenstein | 5239e40858 | |
FinnStutzenstein | 081f13e2ff | |
Emanuel Schütze | 438b3558bf | |
Emanuel Schütze | ff4324117e | |
Emanuel Schütze | f590994875 | |
Emanuel Schütze | 2cdb3f4ef3 | |
Emanuel Schütze | e3c1d5432b | |
Sean | 9387a3f394 | |
Sean | 1853028cf0 | |
Sean | 56b47214bc | |
Emanuel Schütze | 43b13e314e | |
Sean | 0d9738b72d | |
Emanuel Schütze | 47795b57d1 | |
FinnStutzenstein | 7d455b34f5 | |
Emanuel Schütze | fbb0be6fb4 | |
Emanuel Schütze | acf499f6e1 | |
Emanuel Schütze | 79e3780a26 | |
Joshua Sangmeister | e653021eff | |
Emanuel Schütze | aeb893a8d9 | |
Emanuel Schütze | 82efbe76bd | |
Emanuel Schütze | ff9125fb9f | |
Sean | d4f211e344 | |
Emanuel Schütze | 4673c741e9 | |
Emanuel Schütze | e1345cb808 | |
Joshua Sangmeister | bf35c55956 | |
Sean | 6efdc9a3dd | |
Tobias Hößl | cadef6d42e | |
Tobias Hößl | bc3b8be78d | |
Tobias Hößl | 18bc495bd8 | |
Emanuel Schütze | 8451cd2d88 | |
Emanuel Schütze | 5072e66a7e | |
Emanuel Schütze | 3109337004 | |
Sean | 3ca4714812 | |
FinnStutzenstein | 429473dcf9 | |
FinnStutzenstein | c186a575f6 | |
Emanuel Schütze | c4f482b70c | |
Emanuel Schütze | 0275df6ab2 | |
Sean | dced8fbcc7 | |
Joshua Sangmeister | f4907e6604 | |
Emanuel Schütze | d7408b40f9 | |
Finn Stutzenstein | e215a23b80 | |
Joshua Sangmeister | a31fa7dda6 | |
Emanuel Schütze | 7665634d42 | |
Finn Stutzenstein | 9c7b9b0920 | |
FinnStutzenstein | 0eee839736 | |
Joshua Sangmeister | a84bfccd07 | |
FinnStutzenstein | 600b9c148b | |
FinnStutzenstein | d8b21c5fb5 | |
Emanuel Schütze | dcf5d5316c | |
FinnStutzenstein | fba043fedf | |
Emanuel Schütze | 762d1f9912 | |
Raphael Topel | 60621bf4d0 | |
FinnStutzenstein | bf88cea200 | |
FinnStutzenstein | 23842fd496 | |
Sean | 4ac7b1eb4b | |
FinnStutzenstein | 17049cc0f3 | |
Emanuel Schütze | fd026e165f | |
Joshua Sangmeister | e52697ad7e | |
Emanuel Schütze | 0c93c44f0d | |
Emanuel Schütze | 4b95398ac1 | |
Emanuel Schütze | 37c3ac5aff | |
Joshua Sangmeister | 3f03f27cdb | |
Emanuel Schütze | f694e2355d | |
Sean | 3820e09b89 | |
Emanuel Schütze | 1ca3196a75 | |
Emanuel Schütze | ee6076f168 | |
Sean | b6bb1fe767 | |
Sean | 7609a0c3db | |
Emanuel Schütze | b090e46b66 | |
Emanuel Schütze | ca039860f7 | |
Sean | fca4154bb5 | |
Emanuel Schütze | 621d0f4e1a | |
Emanuel Schütze | d1b6ed8d29 | |
Emanuel Schütze | 8058a4d695 | |
Sean | 853bc31e21 | |
Joshua Sangmeister | fa63ef0307 | |
Sean | fef3cf41bb | |
FinnStutzenstein | 34d85c996c | |
Emanuel Schütze | b7b27d2e88 | |
Joshua Sangmeister | b0bf4990f8 | |
Emanuel Schütze | 0ee70b7434 | |
Emanuel Schütze | 9938a68865 | |
Sean | 3e19840b08 | |
Sean | 7a31cff612 | |
GabrielMeyer | e7de593b54 | |
Sean | 602d1c8e7b | |
Emanuel Schütze | c5dd2ea261 | |
Paul Wolf | 8796eeeb62 | |
Tobias Hößl | 25839ea709 | |
Emanuel Schütze | ea830f53b0 | |
FinnStutzenstein | c643a233ae | |
Emanuel Schütze | 5aa895bda2 | |
FinnStutzenstein | 2910701422 | |
Sean | 1e2395c1e6 | |
Sean | fede11b59f | |
Emanuel Schütze | 77cf3e2785 | |
Sean | 4e624384e7 | |
Emanuel Schütze | f9cd3ebd89 | |
Emanuel Schütze | 6a6e90067a | |
Sean | 1a653c3fa7 | |
Tobias Hößl | b51787129b | |
Emanuel Schütze | e0069f734a | |
Emanuel Schütze | f415fd0554 | |
Emanuel Schütze | c6836ff6c5 | |
GabrielMeyer | 4a24da12da | |
Emanuel Schütze | 3842f66877 | |
Sean | 38ee6bb2f1 | |
Emanuel Schütze | a47285c0ff | |
Sean | 1439444b2e | |
Emanuel Schütze | cce76118c3 | |
FinnStutzenstein | aa1a2cec89 | |
Emanuel Schütze | 46d0bbd8f5 | |
FinnStutzenstein | b78372f8a3 | |
Emanuel Schütze | fd9b8b1c5c | |
Emanuel Schütze | 7a25a2496d | |
Emanuel Schütze | ddfe7d0c5a | |
FinnStutzenstein | 152401a9a3 | |
Emanuel Schütze | 2057150076 | |
Emanuel Schütze | cb52347354 | |
Emanuel Schütze | 3169e4f30b | |
FinnStutzenstein | 4221351223 | |
Emanuel Schütze | 0c6da9799c | |
Emanuel Schütze | a71e36c861 | |
GabrielMeyer | 41b9065807 | |
Emanuel Schütze | 527f947143 | |
FinnStutzenstein | c8faa982ac | |
FinnStutzenstein | 38486463bc | |
Finn Stutzenstein | 6a488eb78e | |
Emanuel Schütze | 0aef3f79ce | |
Sean | 97c2299aec | |
Sean | e702843f07 | |
Emanuel Schütze | 0f3d07f151 | |
Sean | aa097ee689 | |
Sean | f7a97cf886 | |
Emanuel Schütze | 25f8f42c92 | |
Sarah | 523eb96f9d | |
Emanuel Schütze | 2c548d2dfb | |
Sean | 91d4b3c7af | |
Emanuel Schütze | d210496146 | |
Emanuel Schütze | 35ce596706 | |
Tobias Hößl | f007e07544 | |
Sean | 70aadcdd28 | |
Sean | 9ffbb39e95 | |
GabrielMeyer | 170aa1c8f0 | |
jsangmeister | ad4ed3443a | |
Joshua Sangmeister | 42fbe93314 | |
Emanuel Schütze | 6cdf9a5582 | |
Emanuel Schütze | 75ebf5bc77 | |
Emanuel Schütze | c26ef8c0bb | |
Joshua Sangmeister | 6eae497abe | |
Finn Stutzenstein | 1570b5b806 | |
Emanuel Schütze | 537eeadce4 | |
Joshua Sangmeister | 96ee1c0af3 | |
Joshua Sangmeister | 99416e3043 | |
Emanuel Schütze | 0f8167e39c | |
Joshua Sangmeister | 9864ff3847 | |
Emanuel Schütze | a7518ed5b6 | |
Sean | 5b7bbfd0bb | |
Emanuel Schütze | b7566fcc69 | |
Sean | 82c6929a8d | |
GabrielMeyer | 35a67017a3 | |
Finn Stutzenstein | 4841343c02 | |
FinnStutzenstein | 7a97aa1b79 | |
Emanuel Schütze | 12bc926b44 | |
Emanuel Schütze | 53b4b1c1f9 | |
Emanuel Schütze | cc372cfba5 | |
Joshua Sangmeister | b7b8620153 | |
FinnStutzenstein | 7882ea1a25 | |
Sean | 04a7ce22fd | |
Sean | 820a47123a | |
Emanuel Schütze | 42af962248 | |
Sean | 7b5f2648af | |
Sean | a1e2c49815 | |
Sean Engelhardt | e1acf6e9d6 | |
Finn Stutzenstein | 83d57e9da7 | |
FinnStutzenstein | bb2f958eb5 | |
Emanuel Schütze | 7b0a2d8ec2 | |
FinnStutzenstein | b2d05f81fe | |
Emanuel Schütze | 4419e76223 | |
Emanuel Schütze | 1e3c83babc | |
FinnStutzenstein | 3be28ec50a | |
Emanuel Schütze | baa1787189 | |
Emanuel Schütze | 8119507b8a | |
Sean | 39ccfe3147 | |
Emanuel Schütze | 106816a733 | |
Emanuel Schütze | c257baa14b | |
Emanuel Schütze | 04c625b3d5 | |
Sean | d646691961 | |
Emanuel Schütze | aaea4ec2e9 | |
Emanuel Schütze | 5b878f4814 | |
Sean | 5bdbe4778a | |
Sean | fbff4de431 | |
Sean | af6c5faac8 | |
Sean | 14de67a09d | |
Emanuel Schütze | 6f7c6036c2 | |
Emanuel Schütze | 19af02a315 | |
Emanuel Schütze | d50899c407 | |
Sean | 73fc936306 | |
Sean | c2406fcc03 | |
Sean | 557824f5f1 | |
Emanuel Schütze | 91be76a263 | |
Emanuel Schütze | eadc09dc56 | |
Emanuel Schütze | c43e180494 | |
Sean | 6fddddd9f4 | |
FinnStutzenstein | cf50295ca4 | |
Sean | 7af2f70494 | |
Emanuel Schütze | cd3435064c | |
FinnStutzenstein | 123df7660f | |
Sean | 2fb372ead9 | |
Sean | 7d86f62e2d | |
Sean | d92622410f | |
Sean | 99c3afb417 | |
Emanuel Schütze | 23a105bdb8 | |
Sean | bf0eadebb7 | |
Sean | fe71322199 | |
Sean | 5bf3dfadff | |
Emanuel Schütze | 5617b02804 | |
Emanuel Schütze | 5a6d2d2e42 | |
Sean | 661fd55c67 | |
Sean | 072ec937a1 | |
Emanuel Schütze | b873dc156b | |
Emanuel Schütze | 4acadd33ca | |
Sean | f0e396b3a4 | |
FinnStutzenstein | 73eff81edd | |
Emanuel Schütze | 54dd97399e | |
Sean | ee07e8f0ce | |
Sean | d12e052030 | |
Sean | 0ab4532ac8 | |
Finn Stutzenstein | 58483d7024 | |
Sean | 3c9f6ed278 | |
FinnStutzenstein | 64f2720b1a | |
Emanuel Schütze | d15c9892ed | |
Sean Engelhardt | ee4c6aa0bf | |
FinnStutzenstein | a05662a0f8 | |
Sean Engelhardt | 29a9a09bc6 | |
Sean Engelhardt | 3c36441967 | |
Sean Engelhardt | 8fe5a0c9f4 | |
Sean Engelhardt | 61b7731073 | |
FinnStutzenstein | e2feeb4b65 | |
Sean Engelhardt | 53b9ce73f2 | |
Joshua Sangmeister | 9d7028ea5f | |
Joshua Sangmeister | 72678770bb | |
Joshua Sangmeister | 82c8ade0ba | |
Emanuel Schütze | 2d13519c35 | |
Joshua Sangmeister | e72bcc1eaf | |
Sean Engelhardt | 97a5bb4aa6 | |
Joshua Sangmeister | 7598fc5367 | |
Joshua Sangmeister | b48ca8c434 | |
Sean Engelhardt | 6ba0d0c5e6 | |
FinnStutzenstein | 0b37c5a857 | |
Joshua Sangmeister | d4599a435b | |
Sean Engelhardt | 93dc78c7d6 | |
Sean Engelhardt | 6044c63c28 | |
Sean | 524a97cdcc | |
GabrielMeyer | 6c1317e25f | |
GabrielMeyer | 294b75c320 | |
GabrielMeyer | 09b0d19de0 | |
Joshua Sangmeister | df1047fc76 | |
Joshua Sangmeister | bc54a6eb46 | |
Joshua Sangmeister | 1de73d5701 | |
GabrielMeyer | a0c3a28456 | |
GabrielMeyer | c46369c6a7 | |
Joshua Sangmeister | b16afaa285 | |
FinnStutzenstein | e2585fb757 | |
Sean Engelhardt | 84a39ccb62 | |
Joshua Sangmeister | 682db96b7c | |
Sean | 604df9d48b | |
FinnStutzenstein | 7ab5346198 | |
Joshua Sangmeister | e67ca77ad1 | |
GabrielMeyer | fff1f15b6c | |
GabrielMeyer | 96aa3b0084 | |
jsangmeister | 72ff1b1f09 | |
FinnStutzenstein | fafb81daca | |
FinnStutzenstein | b50cf42543 | |
jsangmeister | 90b04366b5 | |
GabrielMeyer | 8d77c0495b | |
jsangmeister | 1b761d31c0 | |
jsangmeister | 09ef3c5071 | |
FinnStutzenstein | 046a152ec5 | |
jsangmeister | 6605934a33 | |
FinnStutzenstein | 1246dd54ad | |
jsangmeister | 5fa8341614 | |
FinnStutzenstein | ce171980e8 | |
FinnStutzenstein | ced40cab74 | |
Emanuel Schütze | 4d4697eee0 | |
Joshua Sangmeister | aa46922c8b | |
Emanuel Schütze | ec17376e8e | |
Emanuel Schütze | 35d9fd9d8e | |
Joshua Sangmeister | 7acf2157fa | |
Joshua Sangmeister | 70fc5a69ab | |
Emanuel Schütze | 3ad8944b9c | |
Emanuel Schütze | 847482bb5f | |
FinnStutzenstein | 219103129d | |
GabrielMeyer | 13de88c136 | |
Sean | 98146a29c7 | |
Finn Stutzenstein | 758e059f9b | |
FinnStutzenstein | 7204d59d66 | |
FinnStutzenstein | 76bd184ff4 | |
Emanuel Schütze | fbe5ea2056 | |
Sean Engelhardt | 2236f63fe9 | |
Emanuel Schütze | ec79f70648 | |
Emanuel Schütze | 0267b0cb42 | |
Emanuel Schütze | 2ac01a5ea3 | |
Sean | a51720e18b | |
Sean Engelhardt | 27e8301131 | |
Sean Engelhardt | 407a430419 | |
Emanuel Schütze | a6bdaedff1 | |
Sean | 59795f32e3 | |
Sean Engelhardt | a161bca028 | |
Sean Engelhardt | 6f114d0072 | |
Sean | 8012bfbfc0 | |
Sean | d311042806 | |
Sean Engelhardt | faf8004280 | |
Sean Engelhardt | c2ad39a2c5 | |
Emanuel Schütze | 7a23139f5e | |
Emanuel Schütze | b9e40717de | |
Sean Engelhardt | 5f8e64140a | |
Emanuel Schütze | a2d561f667 | |
Emanuel Schütze | b3c98dd207 | |
FinnStutzenstein | a35fa105ed |
|
@ -0,0 +1,134 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
test-server:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
USING_COVERAGE: "3.6,3.8"
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.6", "3.7", "3.8"]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
|
||||
- name: install python dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install --upgrade --requirement requirements.txt
|
||||
pip freeze
|
||||
|
||||
- name: lint with flake8
|
||||
run: flake8 openslides tests
|
||||
|
||||
- name: lint with isort
|
||||
run: isort --check-only --diff --recursive openslides tests
|
||||
|
||||
- name: lint with black
|
||||
run: black --check --diff openslides tests
|
||||
|
||||
- name: test using mypy
|
||||
run: mypy openslides/ tests/
|
||||
|
||||
- name: test using pytest
|
||||
run: pytest --cov --cov-fail-under=73
|
||||
|
||||
install-client-dependencies:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./client
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
id: node-module-cache
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
key: node_modules-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
node_modules-
|
||||
- uses: actions/setup-node@v2-beta
|
||||
with:
|
||||
node-version: "14"
|
||||
|
||||
- name: install client dependencies
|
||||
if: steps.node-module-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
check-client-code:
|
||||
needs: install-client-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./client
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
key: node_modules-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: check code using linter
|
||||
run: npm run lint-check
|
||||
|
||||
- name: check code using prettify
|
||||
run: npm run prettify-check
|
||||
|
||||
test-client:
|
||||
needs: install-client-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./client
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
key: node_modules-${{ hashFiles('**/package-lock.json') }}
|
||||
- name: test client
|
||||
run: npm run test-silently
|
||||
|
||||
build-client-debug:
|
||||
needs: install-client-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./client
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
key: node_modules-${{ hashFiles('**/package-lock.json') }}
|
||||
- name: build client debug
|
||||
run: npm run build-debug
|
||||
|
||||
build-client-prod:
|
||||
needs: install-client-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./client
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
key: node_modules-${{ hashFiles('**/package-lock.json') }}
|
||||
- name: build client prod
|
||||
run: npm run build
|
|
@ -1,55 +1,9 @@
|
|||
# General
|
||||
## General
|
||||
*.pyc
|
||||
*.swp
|
||||
*.swo
|
||||
*.log
|
||||
*~
|
||||
|
||||
# Virtual Environment
|
||||
.virtualenv*/*
|
||||
.venv/*
|
||||
|
||||
# Javascript tools and libraries
|
||||
node_modules/*
|
||||
bower_components/*
|
||||
|
||||
# Local user data (settings, database, media, search index, static files)
|
||||
personal_data/*
|
||||
openslides/static/*
|
||||
collected-static/*
|
||||
|
||||
# Package building/IDE
|
||||
docs/_build/*
|
||||
*.egg-info
|
||||
build/*
|
||||
dist/*
|
||||
debug/*
|
||||
.DS_Store
|
||||
.idea
|
||||
|
||||
# Unit test and coverage reports
|
||||
.coverage
|
||||
tests/file/*
|
||||
tests/db.sqlite3.test
|
||||
.pytest_cache
|
||||
|
||||
# Plugin development
|
||||
openslides_*
|
||||
|
||||
# Mypy cache for typechecking
|
||||
.mypy_cache
|
||||
|
||||
# OpenSlides 3 Client
|
||||
|
||||
# compiled output
|
||||
client/dist
|
||||
client/tmp
|
||||
client/out-tsc
|
||||
client/documentation
|
||||
|
||||
# dependencies
|
||||
client/node_modules
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
|
@ -58,13 +12,58 @@ client/node_modules
|
|||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
*.code-workspace
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# Virtual Environment
|
||||
.virtualenv*
|
||||
.venv
|
||||
|
||||
# misc
|
||||
## Compatibility
|
||||
# OS4-Submodules and aux-directories
|
||||
/openslides-*/
|
||||
/docker/keys/
|
||||
/docker/secrets/auth_*_key
|
||||
/docs/
|
||||
# OS3+-Submodules
|
||||
/autoupdate/
|
||||
# Plugin development
|
||||
openslides_*
|
||||
# Old OS3 stuff
|
||||
/tests/
|
||||
|
||||
## Server
|
||||
# Local user data (settings, database, media, search index, static files)
|
||||
personal_data/*
|
||||
server/personal_data/*
|
||||
server/openslides/static/*
|
||||
# Unit test and coverage reports
|
||||
.coverage
|
||||
server/tests/file/*
|
||||
server/tests/db.sqlite3.test
|
||||
.pytest_cache
|
||||
# Package building
|
||||
*.egg-info
|
||||
# Mypy cache for typechecking
|
||||
.mypy_cache
|
||||
|
||||
## OpenSlides 3 Client
|
||||
# Javascript tools and libraries
|
||||
**/node_modules/*
|
||||
**/bower_components/*
|
||||
# compiled output
|
||||
client/dist
|
||||
client/static
|
||||
client/tmp
|
||||
client/out-tsc
|
||||
# docs
|
||||
client/documentation
|
||||
Compodoc
|
||||
Compodocmodules
|
||||
CHANGELOG.md
|
||||
# build artifacts
|
||||
client/.sass-cache
|
||||
client/connect.lock
|
||||
client/coverage
|
||||
|
@ -78,6 +77,11 @@ package-lock.json
|
|||
client/package-lock.json
|
||||
cypress.json
|
||||
|
||||
# System Files
|
||||
client/.DS_Store
|
||||
client/Thumbs.db
|
||||
## Deployment
|
||||
# Docker build artifacts
|
||||
docker/docker-compose.yml
|
||||
docker/docker-stack.yml
|
||||
*-version.txt
|
||||
*.pem
|
||||
# secrets
|
||||
docker/secrets/*.env
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "autoupdate"]
|
||||
path = autoupdate
|
||||
url = https://github.com/OpenSlides/openslides3-autoupdate-service.git
|
136
.travis.yml
136
.travis.yml
|
@ -1,136 +0,0 @@
|
|||
dist: xenial
|
||||
sudo: true
|
||||
|
||||
cache:
|
||||
- directories:
|
||||
- $HOME/.cache/pip
|
||||
- client/node_modules
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- stage: "Dependencies"
|
||||
name: "Installing dependencies for python"
|
||||
language: python
|
||||
python:
|
||||
- "3.6"
|
||||
cache:
|
||||
pip: true
|
||||
install:
|
||||
- python --version
|
||||
- pip install --upgrade setuptools pip
|
||||
- pip install --upgrade --requirement requirements/development.txt
|
||||
- pip install --upgrade .[big_mode]
|
||||
- pip freeze
|
||||
script: skip
|
||||
|
||||
- name: "Installing npm dependencies"
|
||||
language: node_js
|
||||
node_js: "10.9"
|
||||
cache:
|
||||
- directories:
|
||||
- "client/node_modules"
|
||||
before_install:
|
||||
- npm install -g @angular/cli@~8.0.6
|
||||
- ng --version
|
||||
- cd client
|
||||
install:
|
||||
- npm install
|
||||
script: skip
|
||||
- stage: "Run tests"
|
||||
name: "Client: Testing"
|
||||
language: node_js
|
||||
node_js: "10.9"
|
||||
apt:
|
||||
sources:
|
||||
- google-chrome
|
||||
packages:
|
||||
- google-chrome-stable
|
||||
services:
|
||||
- xvfb
|
||||
install:
|
||||
- cd client
|
||||
- export CHROME_BIN=/usr/bin/google-chrome
|
||||
- export DISPLAY=:99.0
|
||||
script:
|
||||
- npm run test-silently
|
||||
|
||||
- name: "Client: Production Build (ES5)"
|
||||
language: node_js
|
||||
node_js: "10.9"
|
||||
install:
|
||||
- cd client
|
||||
- sed -i '/\"target\"/c\\"target\":\"es5\",' tsconfig.json
|
||||
script:
|
||||
- npm run build
|
||||
|
||||
- name: "Client: Production Build (ES2015)"
|
||||
language: node_js
|
||||
node_js: "10.9"
|
||||
install:
|
||||
- cd client
|
||||
- echo "Firefox ESR" > browserslist
|
||||
script:
|
||||
- npm run build
|
||||
|
||||
- name: "Client: Build"
|
||||
language: node_js
|
||||
node_js: "10.9"
|
||||
script:
|
||||
- cd client
|
||||
- npm run build-debug
|
||||
|
||||
- name: "Server: Tests Python 3.6"
|
||||
language: python
|
||||
python:
|
||||
- "3.6"
|
||||
script:
|
||||
- mypy openslides/ tests/
|
||||
- pytest --cov --cov-fail-under=73
|
||||
|
||||
- name: "Server: Tests Python 3.7"
|
||||
language: python
|
||||
python:
|
||||
- "3.7"
|
||||
script:
|
||||
- flake8 openslides tests
|
||||
- isort --check-only --diff --recursive openslides tests
|
||||
- black --check --diff --target-version py36 openslides tests
|
||||
- mypy openslides/ tests/
|
||||
- pytest --cov --cov-fail-under=73
|
||||
|
||||
- name: "Server: Tests Python 3.8"
|
||||
language: python
|
||||
python:
|
||||
- "3.8"
|
||||
script:
|
||||
- flake8 openslides tests
|
||||
- isort --check-only --diff --recursive openslides tests
|
||||
- black --check --diff --target-version py36 openslides tests
|
||||
- mypy openslides/ tests/
|
||||
- pytest --cov --cov-fail-under=73
|
||||
|
||||
- name: "Client: Linting"
|
||||
language: node_js
|
||||
node_js: "10.9"
|
||||
script:
|
||||
- cd client
|
||||
- npm run lint-check
|
||||
|
||||
- name: "Client: Code Formatting Check"
|
||||
language: node_js
|
||||
node_js: "10.9"
|
||||
script:
|
||||
- cd client
|
||||
- npm list --depth=0 || cat --help
|
||||
- npm run prettify-check
|
||||
|
||||
- name: "Server: Tests Startup Routine Python 3.7"
|
||||
language: python
|
||||
python:
|
||||
- "3.7"
|
||||
script:
|
||||
- set -e
|
||||
- python manage.py createsettings
|
||||
- python manage.py migrate
|
||||
- python manage.py runserver --noreload & (sleep 15 && kill $(ps aux | grep 'manage.py runserver' | head -n -1 | awk '{print $2}'))
|
||||
- set +e
|
|
@ -0,0 +1,64 @@
|
|||
Advanced configuration
|
||||
======================
|
||||
|
||||
Docker Swarm Mode
|
||||
-----------------
|
||||
|
||||
OpenSlides may also be deployed in Swarm mode. Distributing instances over
|
||||
multiple nodes may increase performance and offer failure resistance.
|
||||
|
||||
An example configuration file, ``docker-stack.yml.m4``, is provided. Unlike
|
||||
the Docker Compose setup, this configuration will most likely need to be
|
||||
customized, especially its placement constraints and database-related
|
||||
preferences.
|
||||
|
||||
Before deploying an instance on Swarm, please see `Database Configuration`_ and
|
||||
`Backups`_, and review your ``docker-stack.yml``
|
||||
|
||||
|
||||
Database Configuration
|
||||
----------------------
|
||||
|
||||
It is fairly easy to get an OpenSlides instance up an running; however, for
|
||||
production setups it is strongly advised to review the database configuration.
|
||||
|
||||
By default, the primary database cluster will archive all WAL files in its
|
||||
volume. Regularly pruning old data is left up to the host system, i.e., you.
|
||||
Alternatively, you may disable WAL archiving by setting
|
||||
``PGNODE_WAL_ARCHIVING=off`` in ``.env`` before starting the instance.
|
||||
|
||||
The provided ``docker-stack.yml.m4`` file includes additional database
|
||||
services which can act as hot standby clusters with automatic failover
|
||||
functionality. To take advantage of this setup, the database services need to
|
||||
be configured with proper placement constraints. Before relying on this setup,
|
||||
please familiarize yourself with `repmgr <https://repmgr.org/>`_.
|
||||
|
||||
|
||||
Backups
|
||||
-------
|
||||
|
||||
All important data is stored in the database. Additionally, the project
|
||||
directory should be included in backups to ensure a smooth recovery.
|
||||
|
||||
The primary database usually runs in the ``pgnode1`` service (but see `Database
|
||||
Configuration`_ above).
|
||||
|
||||
In some cases, it may be sufficient to generate SQL dumps with ``pg_dump``
|
||||
through ``docker exec`` to create backups. However, for proper incremental
|
||||
backups, the host system can backup the cluster's data directory and WAL
|
||||
archives.
|
||||
|
||||
The cluster's data directory is available as a volume on the host system.
|
||||
Additionally, the database archives its WAL files in the same volume by
|
||||
default. This way, the host system can include the database volume in its
|
||||
regular filesystem-based backup routine and create efficient database backups
|
||||
suitable for point-in-time recovery.
|
||||
|
||||
The `former management repository
|
||||
<https://github.com/OpenSlides/openslides-docker-compose/>`_ provides the
|
||||
script `openslides-pg-mgr.sh` which can enable Postgres' backup mode in all
|
||||
OpenSlides database containers.
|
||||
|
||||
In Swarm mode, the primary database cluster may get placed on a number of
|
||||
nodes. It is, therefore, crucial to restrict the placement of database
|
||||
services to nodes on which appropriate backups have been configured.
|
3
AUTHORS
3
AUTHORS
|
@ -31,3 +31,6 @@ Authors of OpenSlides in chronological order of first contribution:
|
|||
Fadi Abbud <fmfn13@hotmail.com>
|
||||
Gabriel Meyer <meyergabriel@live.de>
|
||||
Joshua Sangmeister <joshua.sangmeister@gmail.com>
|
||||
Gernot Schulz <gernot@intevtion.de>
|
||||
Raphael Topel <info@rtopel.de>
|
||||
Martin Dickopp <martin@zero-based.org>
|
||||
|
|
237
CHANGELOG.rst
237
CHANGELOG.rst
|
@ -4,9 +4,244 @@
|
|||
|
||||
https://openslides.com
|
||||
|
||||
Version 3.4 (2021-10-07)
|
||||
========================
|
||||
`Milestone <https://github.com/OpenSlides/OpenSlides/milestones/3.4>`_
|
||||
|
||||
General:
|
||||
- New feature: group chat module [#5876 #5902 #5929 #6072 #6100]
|
||||
- New external autoupdate service for better performance [#5533, #5817]
|
||||
- Improved loading indicator (spinner) [#5847, #5897, #5981, #6129]
|
||||
- Fixed persistent offline bar on successful reconnection [#5828]
|
||||
- Added own session redis [#5850]
|
||||
- Use caddy as new proxy [#5851]
|
||||
- Removed pip support [#5852]
|
||||
- Updated to Angular12 [#6146]
|
||||
- Handled more timeout errors [#5936]
|
||||
- Fixed umlauts using full text search [#6040]
|
||||
- Added point-of-order amount in speaker statistics [#6078]
|
||||
- Refined autopilot interaction [#6084]
|
||||
- Write changes of logo and font definitions to configs [#6118]
|
||||
- Fixes the usage of groups in the settings [#6222]
|
||||
- Added export for OpenSlides4 [#6120]
|
||||
|
||||
eVoting:
|
||||
- Added history information for start/stop voting [#5857]
|
||||
- Speed up stopping a poll [#6054]
|
||||
- Added prompt dialog for stop voting [#6070]
|
||||
- Allow list of speakers manager to see voting progress [#6037]
|
||||
- Lock poll to prevent race conditions [#6006]
|
||||
- Show "unpublished" for finished polls in autopilot [#6074]
|
||||
- Clear all votes after poll reset [#6113]
|
||||
- New 100% base: All entitled users
|
||||
- Prevent multiple entries in entitled_users_at_stop [#5997]
|
||||
- Removed voted_ids [#5918]
|
||||
- Fixed vote pending state [#6127]
|
||||
- Fixed vote delegation update error [#6024]
|
||||
- Fixed entitled user calculation and display of voting banner in case of vote delegations [#6031]
|
||||
|
||||
Jitsi/Livestream:
|
||||
- New feature: virtual applause [#5811]
|
||||
- New feature: helpdesk jitsi room [#5832]
|
||||
- Added support for YouTube and Nanocosmos livestream player [#5770, #6228]
|
||||
- Completely restructure Jitsi/livestream components [#5961]
|
||||
- Updated Jitsi iFrame and API [#6103]
|
||||
- Hide chat in Jitsi iFrame [#6177]
|
||||
|
||||
Agenda:
|
||||
- New feature: Mark speakers for pro/contra. New note for point of order. [#6023]
|
||||
- New config option: List of speakers is initially closed [#5910]
|
||||
- New multiselect action: open/close list of speakers in agenda [#6087]
|
||||
- Added speaker information (pro/contra/point-of-order) also for active/finished speakers [#6073]
|
||||
- Improved point-of-order dialog [#6075]
|
||||
- Fixed point of order sorting/weighting [#5882, #5913]
|
||||
- Fixed showSubtitle config. Reordered agenda config. [#5924]
|
||||
- Fixed point of order creation permission (for agenda.can_be_speaker only) [#5927]
|
||||
|
||||
Motions:
|
||||
- Added warning when editing motion with existing amendments [#5957]
|
||||
- Added additional special characters for better diff handling [#5987, #6173]
|
||||
- Change recommendation extension can now be set with can_manage_metadata [#6001]
|
||||
- Allow formatting-only-changes without breaking the inline diff [#5992]
|
||||
- Fixed paragraph based amendments in iOS [#5888]
|
||||
- Fixed handling of inconsistent states in amendments [#5920]
|
||||
- Fixed wrong diff view for amendments in list items [#6047]
|
||||
- Fixed top navigation between motions [#6086]
|
||||
- Fixed an error where the workflow would break agenda [#6185]
|
||||
- Clean HTML before pasting in tinymce [#6212]
|
||||
- Added supporters to CSV import [#6186]
|
||||
- Enhance amendments change recommendation list in motion detail view [#6009]
|
||||
- Better hyphenation for motion detail [#6076]
|
||||
|
||||
Elections:
|
||||
- New feature: minimum amount of votes [#5719]
|
||||
- Fixed user cannot see candidate names [#6049]
|
||||
- Fixed order of assignment options by weight [#5928]
|
||||
- Disabled general approval/rejection depending on poll method [#5979]
|
||||
- Added general approval/rejection/abstain to analog polls [#6050]
|
||||
- Number candidates in voting result table [#6051, #6081]
|
||||
- Remove chart for analog polls with a majority [#6130]
|
||||
- Fixed projection of analog polls & prevent percent base 'entitled' for analog polls [#6134]
|
||||
|
||||
Users:
|
||||
- Improve client-side password generation [#5791, #5822]
|
||||
- Fix vote_delegated_from_user_ids on user update [#5800]
|
||||
- Fix csv importing users with groups [#5823]
|
||||
- Update users on can_see_extra_data permission change [#5935]
|
||||
- Add login errors for inactive users [#5967]
|
||||
- Adding attribute matchers for group assignments to SAML [#6017]
|
||||
- Validate the from email for invalid characters [#6025]
|
||||
- Hide pw generate button while editing a user [#6171]
|
||||
|
||||
Mediafiles:
|
||||
- New settings for the mediafile database tablename [#6026]
|
||||
- Fixed updating logo and font configs when deleting a mediafile [#6123]
|
||||
- Fixed missing zip extension when filename has a dot [#6172]
|
||||
|
||||
Projector:
|
||||
- Don't show diff view on motion slides if no change recommendations exist [#5940]
|
||||
- Hide cursor in full screen projector [#6066]
|
||||
|
||||
|
||||
Version 3.3 (2020-12-18)
|
||||
========================
|
||||
`Milestone <https://github.com/OpenSlides/OpenSlides/milestones/3.3>`_
|
||||
|
||||
General:
|
||||
- Fixed HTML attribute validation (XSS in all user-editable HTML fields) [#5714]
|
||||
- Improved system libraries (u.g. updated to Angular 10 and TinyMCE editor 5.4)
|
||||
- Improved repository structure (moved server code into subdirectory)
|
||||
- Improved docker setup (config and build scripts)
|
||||
- Improved search-value-selectors (e.g. virtual scrolling, multiselect)
|
||||
- Improved management commands to use cache
|
||||
- Improved reporting of SMTP exception
|
||||
- Replaced travis with github actions
|
||||
- Allowd demo mode in settings.py
|
||||
- Fixed Firefox indexedDB state change issue [#5544]
|
||||
- Fixed saml issues and improved saml config commands
|
||||
- Various cleanups and improvements to usability, performance and translation.
|
||||
|
||||
eVoting:
|
||||
- New feature for delegation of vote
|
||||
- New election method 'No per candidate'
|
||||
- Show progress bar also in autopilot (for managers only)
|
||||
- Show last voting result always in autopilot (keep current projection and list of speakers)
|
||||
- Show information for candidates and election method for each created ballot
|
||||
- Improved layout of create voting dialog
|
||||
- Improved user export with username and vote weight
|
||||
- Prevent empty ballot paper
|
||||
|
||||
Jitsi/Livestream:
|
||||
- New config option for livestream poster url (if livestream gets 404 error)
|
||||
- New config option for auto conntecting next x speakers to Jitsi
|
||||
- New config options to open microphone/camera by entering Jitsi
|
||||
- Improved browser permission check for microphone/camera before entering Jitsi
|
||||
- Improved switching between livestream and jitsi
|
||||
- Added Picture-in-Picture (PIP) option to livestream player
|
||||
- Updated jitsi-meet lib
|
||||
|
||||
Agenda:
|
||||
- New autopilot mode to show always current agenda item/motion/list-of-speakers/voting/projector
|
||||
- New "point of order" button in list of speakers
|
||||
|
||||
Motions:
|
||||
- New config option for default workflow for amendments
|
||||
- Improved performance for amendments (esp. for diff view in list and main motion view)
|
||||
- Improved motion detail view and PDF to hide motion preamble in final state
|
||||
- Improved motion export dialog to select supporters
|
||||
- Improved navigation between amendments and main motions
|
||||
- Show change recommendations of amendments also in main motion
|
||||
- Fixed PDF issues
|
||||
|
||||
Elections:
|
||||
- Improved handling for new candidates (quick create of new candidates)
|
||||
|
||||
Users:
|
||||
- Improved user import preview by virtual srolling
|
||||
- Show vote weight totals in user list
|
||||
- Show vote delegations to users on ownPage
|
||||
|
||||
Mediafiles:
|
||||
- New possibility to download full directory as zip
|
||||
|
||||
Projector:
|
||||
- Always include change recommendations in motion slide
|
||||
- Added amendments to projection defaults
|
||||
|
||||
|
||||
Version 3.2 (2020-07-15)
|
||||
========================
|
||||
`Milestone <https://github.com/OpenSlides/OpenSlides/milestones/3.2>`_
|
||||
|
||||
General:
|
||||
- New electronic voting integrated for motions and elections [#5255].
|
||||
- New WebRTC based voice and video conferences using Jitsi-Meet (requires external Jitsi-Meet Server) [#5309, #5371, #5394, #5430, #5437, #5442, #5452, #5453].
|
||||
- Improved system libraries (upgraded to Angular 9 which uses the Ivy rendering engine) [#5234].
|
||||
- Improved the load of autoupdate system [#5109, #5375].
|
||||
- Improved relations (i.e discovery of Motion - User - Motions). [#5091, #5180, #5389].
|
||||
- Improved server validation of HTML in the OpenSlides config [#5168].
|
||||
- Improved UI, UX, stability and theming [#5228, #5238, #5262, #5270, #5272, #5274, #5278, #5410, #5429].
|
||||
- Improved themes (new: default dark, red light, green dark and solarized) and better support for dark themes [#5416, #5431, #5451].
|
||||
- Improved HTML validation for welcome page and agenda topics to allow more tags (e.g. div, video) and attributes/styles [#5314].
|
||||
- Improved permission checking system in client [#5359].
|
||||
- Improved browser support by catching unsuported browsers on login page [#5403, #5446].
|
||||
- Improved SAML support [#5405, #5418, #5432].
|
||||
- Fixed wrong relative urls in TinyMCE [#5349].
|
||||
- Fixed PDF generation if a left footer image was set [#5443].
|
||||
- Removed the "check update for other clients" button [#5277].
|
||||
- Various cleanups and improvements to usability, performance and translation.
|
||||
|
||||
Agenda:
|
||||
- New tags for agenda items [#5370].
|
||||
- New possibility to duplicate selected topic items [#5433].
|
||||
- New 'create user' button in list of speakers if user was not found in the search box [#5307].
|
||||
- New list of speakers statistic section on legal notice page [#5347].
|
||||
- New "first contribution" hint for speakers [#5330].
|
||||
- Improved showing comments in agenda list (as separate line) and projector queue [#5293].
|
||||
- Improved line height of agenda slide [#5419].
|
||||
- Fixed agenda PDF where the agenda item number was printed twice [#5417, #5454].
|
||||
- Fixed negative speakers duration [#5447, #5448].
|
||||
|
||||
Motions:
|
||||
- New electronic voting feature for motions [#5255].
|
||||
- New possibility to create paragraph based amendments of paragraph based amendments [#5173].
|
||||
- New option for page breaks in motion PDF export [#5191].
|
||||
- New option to show all changes of amendments in main motion (clientside) [#5348].
|
||||
- New 'done' indicator for motion block if all motions reached their final state [#5246].
|
||||
- Improved PDF table of content (hide the recommendation if state is final) [#5192].
|
||||
- Improved creating "final print version" (modified final version) also for motions without change recommendations [#5193, #5209].
|
||||
- Improved voting results with nice charts.
|
||||
- Improved navigation between amendments (reflects sorting of amendment list if the option "show amendments together with motions" is disabled) [#5245].
|
||||
- Improved workflow manager for small devices [#5280].
|
||||
- Improved sorting motions by category (sorts the list by category weight instead of the identifier) [#5308, #5310].
|
||||
- Improved preselection and fallback behavior for motions with various change recommendation settings [#5366]
|
||||
- Improved CSV/XLSX export (moved motion id as last column) for easier import via CSV [#5425].
|
||||
- Fixed error by removing recommendation string in workflow manager [#5271].
|
||||
- Fixed bug where TinyMCE changes would not update a motions save button [#5402].
|
||||
|
||||
Elections:
|
||||
- New electronic voting feature for elections [#5255].
|
||||
- Improved voting results with nice charts.
|
||||
- Fixed some permission errors [#5194].
|
||||
|
||||
Users:
|
||||
- New option to activate vote weight [#5305].
|
||||
- New option to allow users to set themselves as present [#5283, #5317, #5319].
|
||||
- Improved the permission "can see extra data" (only the fields email, comment, is_active, last_email_send are allowed) [#5423].
|
||||
|
||||
Mediafiles:
|
||||
- External servers can be used to store media files [#5153, #5230].
|
||||
|
||||
Projector:
|
||||
- New projector indicator for the currently projected element in all list view tables (visible for users without projector manage permission) [#5321].
|
||||
- Improved "current list of speakers" reference for new projectors [#5273].
|
||||
- Improved motion slide to hide submitter box if empty [#5367].
|
||||
- New (configurable) monospace font for the countdown [#5378, #5408].
|
||||
|
||||
|
||||
Version 3.1 (2019-12-13)
|
||||
========================
|
||||
`Milestone <https://github.com/OpenSlides/OpenSlides/milestones/3.0>`_
|
||||
`Milestone <https://github.com/OpenSlides/OpenSlides/milestones/3.1>`_
|
||||
|
||||
General:
|
||||
- Improved loading time of OpenSlides [#5061, 5087, #5110, #5146 - Breaks IE11].
|
||||
|
|
312
DEVELOPMENT.rst
312
DEVELOPMENT.rst
|
@ -2,280 +2,92 @@
|
|||
OpenSlides Development
|
||||
========================
|
||||
|
||||
This instruction helps you to setup a development environment for OpenSlides.
|
||||
Check requirements
|
||||
''''''''''''''''''
|
||||
|
||||
- ``docker``
|
||||
- ``docker-compose``
|
||||
- ``git``
|
||||
- ``make``
|
||||
|
||||
**Note about migrating from development setups before version 3.4**: You must set the
|
||||
``OPENSLIDES_USER_DATA_DIR`` variable in your ``server/personal_data/var/settings.py``
|
||||
to ``'/app/personal_data/var'``. Another way is to just delete this file. It is
|
||||
recreated with the right paths afterwards.
|
||||
|
||||
|
||||
Installation and start of the development version
|
||||
=================================================
|
||||
|
||||
1. Installation on GNU/Linux or Mac OS X
|
||||
----------------------------------------
|
||||
|
||||
a. Check requirements
|
||||
'''''''''''''''''''''
|
||||
|
||||
Make sure that you have installed `Python (>= 3.6) <https://www.python.org/>`_,
|
||||
`Node.js (>=10.x) <https://nodejs.org/>`_ and `Git <http://git-scm.com/>`_ on
|
||||
your system. You also need build-essential packages and header files and a
|
||||
static library for Python.
|
||||
|
||||
For Debian based systems (Ubuntu, etc) run::
|
||||
|
||||
$ sudo apt-get install git nodejs npm build-essential python3-dev
|
||||
|
||||
|
||||
b. Get OpenSlides source code
|
||||
'''''''''''''''''''''''''''''
|
||||
Get OpenSlides source code
|
||||
''''''''''''''''''''''''''
|
||||
|
||||
Clone current master version from `OpenSlides GitHub repository
|
||||
<https://github.com/OpenSlides/OpenSlides/>`_::
|
||||
|
||||
$ git clone https://github.com/OpenSlides/OpenSlides.git
|
||||
$ cd OpenSlides
|
||||
git clone https://github.com/OpenSlides/OpenSlides.git --recurse-submodules
|
||||
cd OpenSlides
|
||||
|
||||
When updating the repository, submodules must be updated explicitly, too::
|
||||
|
||||
git submodule update
|
||||
|
||||
Start the development setup
|
||||
'''''''''''''''''''''''''''
|
||||
|
||||
Use `make` to start the setup::
|
||||
|
||||
make run-dev
|
||||
|
||||
All your data (database, config, mediafiles) is stored in ``server/personal_data/var``.
|
||||
To stop the setup press Ctrl+C. To clean up the docker containers run::
|
||||
|
||||
make stop-dev
|
||||
|
||||
Running the test cases
|
||||
''''''''''''''''''''''
|
||||
|
||||
For all services in submodules check out the documentation there.
|
||||
|
||||
|
||||
c. Setup a virtual Python environment (optional)
|
||||
''''''''''''''''''''''''''''''''''''''''''''''''
|
||||
Server tests and scripts
|
||||
------------------------
|
||||
|
||||
See step 1. b. in the installation section in the `README.rst
|
||||
<https://github.com/OpenSlides/OpenSlides/blob/master/README.rst>`_.
|
||||
You need to have python (>=3.8) and python-venv installed. Change your workdirectory to the server::
|
||||
|
||||
cd server
|
||||
|
||||
d. Finish the server
|
||||
''''''''''''''''''''
|
||||
Setup an python virtual environment. If you have already done it, you can skip this step::
|
||||
|
||||
Install all required Python packages::
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -U -r requirements.txt
|
||||
|
||||
$ pip install --requirement requirements.txt
|
||||
Make sure you are using the correct python version (e.g. try with explicit minor version: ``python3.8``). Activate it::
|
||||
|
||||
Create a settings file, run migrations and start the server::
|
||||
source .venv/bin/activate
|
||||
|
||||
$ python manage.py createsettings
|
||||
$ python manage.py migrate
|
||||
$ python manage.py runserver
|
||||
To deactivate it type ``deactivate``. Running all tests and linters::
|
||||
|
||||
To get help on the command line options run::
|
||||
black openslides/ tests/
|
||||
flake8 openslides/ tests/
|
||||
mypy openslides/ tests/
|
||||
isort -rc openslides/ tests/
|
||||
pytest tests/
|
||||
|
||||
$ python manage.py --help
|
||||
Client tests
|
||||
------------
|
||||
|
||||
Later you might want to restart the server with one of the following commands.
|
||||
You need `node` and `npm` installed. Change to the client's directory. For the first time, install all dependencies::
|
||||
|
||||
To start OpenSlides with this command and to avoid opening new browser windows
|
||||
run::
|
||||
cd client/
|
||||
npm install
|
||||
|
||||
$ python manage.py start --no-browser
|
||||
Run client tests::
|
||||
|
||||
When debugging something email related change the email backend to console::
|
||||
|
||||
$ python manage.py start --debug-email
|
||||
|
||||
|
||||
e. Debugging the server
|
||||
'''''''''''''''''''''''
|
||||
|
||||
If you wish to have even further debugging, enable `django-extensions
|
||||
<https://django-extensions.readthedocs.io/>`_ in the ``settings.py`` by adding
|
||||
``django_extensions`` to the list of ``INSTALLED_PLLUGINS``. Make sure, you
|
||||
install the following packages::
|
||||
|
||||
$ pip install Werkzeug pyparsing pydot django-extensions
|
||||
|
||||
You can start the enhanced debugging-server via::
|
||||
|
||||
$ python manage.py runserver_plus
|
||||
|
||||
|
||||
f. Setup and start the client
|
||||
'''''''''''''''''''''''''''''
|
||||
|
||||
Go in the client's directory in a second command-line interface::
|
||||
|
||||
$ cd client/
|
||||
|
||||
Install all dependencies and start the development server::
|
||||
|
||||
$ npm install
|
||||
$ npm start
|
||||
|
||||
Now the client is available under ``localhost:4200``.
|
||||
|
||||
If you want to provide the client statically, you can build it via::
|
||||
|
||||
$ npm run build
|
||||
|
||||
The build client files are availible from the root directory in
|
||||
``openslides/static`` and can be provided via NGINX.
|
||||
|
||||
|
||||
2. Installation on Windows
|
||||
--------------------------
|
||||
|
||||
Follow the instructions above (Installation on GNU/Linux or Mac OS X) but care
|
||||
of the following variations.
|
||||
|
||||
To get Python download and run the latest `Python 3.7 32-bit (x86) executable
|
||||
installer <https://www.python.org/downloads/windows/>`_. Note that the 32-bit
|
||||
installer is required even on a 64-bit Windows system. If you use the 64-bit
|
||||
installer, step d. of the instruction might fail unless you installed some
|
||||
packages manually.
|
||||
|
||||
In some cases you have to install `MS Visual C++ 2015 build tools
|
||||
<https://www.microsoft.com/en-us/download/details.aspx?id=48159>`_ before you
|
||||
install the required python packages for OpenSlides (unfortunately Twisted
|
||||
needs it).
|
||||
|
||||
To setup and activate the virtual environment in step c. use::
|
||||
|
||||
> .virtualenv\Scripts\activate.bat
|
||||
|
||||
All other commands are the same as for GNU/Linux and Mac OS X.
|
||||
|
||||
|
||||
3. Running the test cases
|
||||
-------------------------
|
||||
|
||||
a. Running server tests
|
||||
'''''''''''''''''''''''
|
||||
|
||||
To run some server tests see `.travis.yml
|
||||
<https://github.com/OpenSlides/OpenSlides/blob/master/.travis.yml>`_.
|
||||
|
||||
You can generate an class-structure image when having `django_extensions`
|
||||
enabled (see above)::
|
||||
|
||||
$ python manage.py graph_models -a -g -o my_project_visualized.png
|
||||
|
||||
|
||||
b. Client tests and commands
|
||||
''''''''''''''''''''''''''''
|
||||
|
||||
Change to the client's directory to run every client related command. Run
|
||||
client tests::
|
||||
|
||||
$ npm test
|
||||
npm test
|
||||
|
||||
Fix the code format and lint it with::
|
||||
|
||||
$ npm run prettify-write
|
||||
$ npm run lint
|
||||
npm run cleanup
|
||||
|
||||
To extract translations run::
|
||||
|
||||
$ npm run extract
|
||||
|
||||
When updating, adding or changing used packages from npm, please update the
|
||||
README.md using following command::
|
||||
|
||||
$ npm run licenses
|
||||
|
||||
|
||||
OpenSlides in big mode
|
||||
======================
|
||||
|
||||
To install OpenSlides for big assemblies (in 'big mode') you have to setup some
|
||||
additional components and configurations. In the 'big mode' you should use a webserver
|
||||
like NGINX to serve the static and media files as proxy server in front of your OpenSlides
|
||||
interface server. You should also use a database like PostgreSQL. Use Redis as channels backend,
|
||||
cache backend and session engine. Finally you should use gunicorn with uvicorn as interface server.
|
||||
|
||||
|
||||
1. Install and configure PostgreSQL and Redis
|
||||
---------------------------------------------
|
||||
|
||||
Install `PostgreSQL <https://www.postgresql.org/>`_ and `Redis
|
||||
<https://redis.io/>`_. For Ubuntu 18.04 e. g. run::
|
||||
|
||||
$ sudo apt-get install postgresql libpq-dev redis-server
|
||||
|
||||
Be sure that database and redis server is running. For Ubuntu 18.04 e. g. this
|
||||
was done automatically if you used the package manager.
|
||||
|
||||
Then add database user and database. For Ubuntu 18.04 e. g. run::
|
||||
|
||||
$ sudo -u postgres createuser --pwprompt --createdb openslides
|
||||
$ sudo -u postgres createdb --owner=openslides openslides
|
||||
|
||||
|
||||
2. Change OpenSlides settings
|
||||
-----------------------------
|
||||
|
||||
Create OpenSlides settings file if it does not exist::
|
||||
|
||||
$ python manage.py createsettings
|
||||
|
||||
Change OpenSlides settings file (usually called settings.py): Setup
|
||||
`DATABASES` entry as mentioned in the settings file. Set `use_redis` to
|
||||
`True`.
|
||||
|
||||
Populate your new database::
|
||||
|
||||
$ python manage.py migrate
|
||||
|
||||
|
||||
3. Run OpenSlides
|
||||
-----------------
|
||||
|
||||
To start Daphne run::
|
||||
|
||||
$ export DJANGO_SETTINGS_MODULE=settings
|
||||
$ export PYTHONPATH=personal_data/var/
|
||||
$ daphne -b 0.0.0.0 -p 8000 openslides.asgi:application
|
||||
|
||||
The last line may be interchangeable with gunicorn and uvicorn as protocol
|
||||
server::
|
||||
|
||||
$ gunicorn -w 4 -b 0.0.0.0:8000 -k uvicorn.workers.UvicornWorker openslides.asgi:application
|
||||
|
||||
|
||||
4. Use NGINX (optional)
|
||||
-----------------------
|
||||
|
||||
When using NGINX as a proxy for delivering static files the performance of the
|
||||
setup will increase.
|
||||
|
||||
This is an example ``nginx.conf`` configuration for Daphne listing on port
|
||||
8000::
|
||||
|
||||
worker_processes 1;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root $YOUR_OS_ROOT_FOLDER/openslides/static;
|
||||
index index.html index.htm;
|
||||
include /etc/nginx/mime.types;
|
||||
|
||||
client_max_body_size 100M;
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 1000;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location /apps {
|
||||
proxy_pass http://localhost:8000;
|
||||
}
|
||||
location /media {
|
||||
proxy_pass http://localhost:8000;
|
||||
}
|
||||
location /rest {
|
||||
proxy_pass http://localhost:8000;
|
||||
}
|
||||
location /ws {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
npm run extract
|
||||
|
|
30
Dockerfile
30
Dockerfile
|
@ -1,30 +0,0 @@
|
|||
FROM python:3.7-slim
|
||||
|
||||
RUN mkdir /app
|
||||
|
||||
RUN apt -y update && \
|
||||
apt -y upgrade && \
|
||||
apt install -y libpq-dev curl wget xz-utils bzip2 git gcc gnupg2 make g++
|
||||
RUN curl -sL https://deb.nodesource.com/setup_11.x | bash -
|
||||
RUN apt -y install nodejs
|
||||
RUN npm install -g @angular/cli@latest
|
||||
RUN useradd -m openslides
|
||||
RUN chown -R openslides /app
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN rm -rf /app/.virtualenv* && \
|
||||
rm -rf /app/client/node_modules
|
||||
RUN chown -R openslides /app
|
||||
|
||||
# Installing python dependencies
|
||||
RUN pip install -r requirements.txt
|
||||
RUN rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# installing client
|
||||
USER openslides
|
||||
RUN ng config -g cli.warnings.versionMismatch false && \
|
||||
cd client && \
|
||||
npm install
|
||||
RUN cd client && \
|
||||
npm run build && \
|
||||
./node_modules/.bin/compodoc -t -p src/tsconfig.app.json -n 'OpenSlides Documentation' -d ../openslides/static/doc -e html
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2011-2019 Authors of OpenSlides, see AUTHORS
|
||||
Copyright (c) 2011-2021 Authors of OpenSlides, see AUTHORS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
include AUTHORS
|
||||
include CHANGELOG
|
||||
include LICENSE
|
||||
include README.rst
|
||||
include SETTINGS.rst
|
||||
include requirements/production.txt
|
||||
include requirements/big_mode.txt
|
||||
recursive-include openslides *.*
|
||||
exclude openslides/__pycache__/*
|
|
@ -0,0 +1,23 @@
|
|||
build-dev:
|
||||
make -C caddy build-dev
|
||||
git submodule foreach 'make build-dev'
|
||||
docker-compose -f docker/docker-compose.dev.yml build
|
||||
|
||||
run-dev: | build-dev
|
||||
USER_ID=$$(id -u $${USER}) GROUP_ID=$$(id -g $${USER}) docker-compose -f docker/docker-compose.dev.yml up
|
||||
|
||||
stop-dev:
|
||||
docker-compose -f docker/docker-compose.dev.yml down
|
||||
|
||||
server-shell:
|
||||
docker-compose -f docker/docker-compose.dev.yml run --entrypoint="" server docker/wait-for-dev-dependencies.sh
|
||||
USER_ID=$$(id -u $${USER}) GROUP_ID=$$(id -g $${USER}) docker-compose -f docker/docker-compose.dev.yml run --entrypoint="" server bash
|
||||
docker-compose -f docker/docker-compose.dev.yml down
|
||||
|
||||
reload-proxy:
|
||||
docker-compose -f docker/docker-compose.dev.yml exec -w /etc/caddy proxy caddy reload
|
||||
|
||||
clear-cache:
|
||||
docker-compose -f docker/docker-compose.dev.yml exec redis redis-cli flushall
|
||||
docker-compose -f docker/docker-compose.dev.yml restart autoupdate
|
||||
docker-compose -f docker/docker-compose.dev.yml restart server
|
276
README.rst
276
README.rst
|
@ -5,159 +5,141 @@
|
|||
What is OpenSlides?
|
||||
===================
|
||||
|
||||
OpenSlides is a free, web based presentation and assembly system for
|
||||
managing and projecting agenda, motions and elections of an assembly. See
|
||||
OpenSlides is a free, web-based presentation and assembly system for
|
||||
managing and projecting agenda, motions, and elections of assemblies. See
|
||||
https://openslides.com for more information.
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
The OpenSlides server runs everywhere where Python is running (for example on
|
||||
GNU/Linux, Mac or Windows). For the OpenSlides client a current web browser is required.
|
||||
The main deployment method is using Git, Docker and Docker Compose. You only need
|
||||
to have these tools installed and no further dependencies. If you want a simpler
|
||||
setup or are interested in developing, please refer to `development
|
||||
instructions <DEVELOPMENT.rst>`_.
|
||||
|
||||
Get OpenSlides
|
||||
--------------
|
||||
|
||||
First, you have to clone this repository::
|
||||
|
||||
git clone https://github.com/OpenSlides/OpenSlides.git --recurse-submodules
|
||||
cd OpenSlides/
|
||||
|
||||
**Note about migrating from version 3.3 or earlier**: With OpenSlides 3.4 submodules
|
||||
and a Docker setup were introduced. If you ran into problems try to delete your
|
||||
``settings.py``. If you have an old checkout you need to check out the current master
|
||||
first and initialize all submodules::
|
||||
|
||||
git submodule update --init
|
||||
|
||||
Setup Docker Compose
|
||||
--------------------
|
||||
|
||||
You need to build the Docker images and have to setup some configuration. First,
|
||||
configure HTTPS by checking the `Using HTTPS`_ section. In this section are
|
||||
reasons why HTTPS is required for large deployments.
|
||||
|
||||
Go to ``docker`` subdirectory::
|
||||
|
||||
cd docker
|
||||
|
||||
Then build all images with this script::
|
||||
|
||||
./build.sh all
|
||||
|
||||
You must define a Django secret key in ``secrets/django.env``, for example::
|
||||
|
||||
printf "DJANGO_SECRET_KEY='%s'\n" \
|
||||
"$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 64)" > secrets/django.env
|
||||
|
||||
We also strongly recommend that you set a secure admin password but it is not
|
||||
strictly required. If you do not set an admin password, the default login
|
||||
credentials will be displayed on the login page. Setting the admin password::
|
||||
|
||||
cp secrets/adminsecret.env.example secrets/adminsecret.env
|
||||
vi secrets/adminsecret.env
|
||||
|
||||
Afterwards, generate the configuration file::
|
||||
|
||||
m4 docker-compose.yml.m4 > docker-compose.yml
|
||||
|
||||
You can configure OpenSlides using the `.env` file. See `More settings`_. Another
|
||||
hint: If you choose to deploy the default configuration, a https certificate is
|
||||
needed, so make sure you have set it up beforehand.
|
||||
|
||||
Finally, you can start the instance using ``docker-compose``::
|
||||
|
||||
docker-compose up
|
||||
|
||||
OpenSlides is accessible on https://localhost/ (or https, if configured).
|
||||
|
||||
Use can also use daemonized instance::
|
||||
|
||||
docker-compose up -d
|
||||
docker-compose logs
|
||||
docker-compose down
|
||||
|
||||
Using HTTPS
|
||||
-----------
|
||||
|
||||
The main reason (next to obviously security ones) HTTPS is required originates
|
||||
from the need of HTTP/2. OpenSlides uses streaming responses to asynchronously
|
||||
send data to the client. With HTTP/1.1 one TCP-Connection per request is opened.
|
||||
Browsers limit the amount of concurrent connections
|
||||
(`reference <https://docs.pushtechnology.com/cloud/latest/manual/html/designguide/solution/support/connection_limitations.html>`_),
|
||||
so you are limited in opening tabs. HTTPS/2 just uses one connection per browser
|
||||
and eliminates these restrictions. The main point to use HTTPS is that browsers
|
||||
only use HTTP/2 if HTTPS is enabled.
|
||||
|
||||
Setting up HTTPS
|
||||
""""""""""""""""
|
||||
|
||||
Use common providers for retrieving a certificate and private key for your
|
||||
deployment. Place the certificate and private key in ``caddy/certs/cert.pem``
|
||||
and ``caddy/certs/key.pem``. To use a self-signed localhost certificate, you can
|
||||
execute ``caddy/make-localhost-cert.sh``.
|
||||
|
||||
The certificate and key are put into the docker image into ``/certs/``, so
|
||||
setting up these files needs to be done before calling ``./build.sh``. When you
|
||||
update the files, you must run ``./build.sh proxy`` again. If you want to have a
|
||||
more flexible setup without the files in the image, you can also mount the
|
||||
folder or the certificate and key into the running containers if you wish to do
|
||||
so.
|
||||
|
||||
If both files are not present, OpenSlides will be configured to run with HTTP
|
||||
only. When mounting the files make sure, that they are present during the
|
||||
container startup.
|
||||
|
||||
Caddy, the proxy used, wants the user to persist the ``/data`` directory. If you
|
||||
are going to use HTTPS add a volume in your ``docker-compose.yml`` /
|
||||
``docker-stack.yml`` persisting the ``/data`` directory.
|
||||
|
||||
More settings
|
||||
-------------
|
||||
|
||||
When generating the ``docker-compose.yml``, more settings can be adjusted in the
|
||||
``docker/.env`` file. All changes for the backend are passed into djangos ``settings.py``.
|
||||
You can find more information about most settings in the `settings documentation
|
||||
<server/SETTINGS.rst>`_. To generate the ``docker-compose.yml`` use this command::
|
||||
|
||||
cd docker
|
||||
( set -a; source .env; m4 docker-compose.yml.m4 ) > docker-compose.yml
|
||||
|
||||
For an advanced database setup refer to the `advanced configuration
|
||||
<ADVANCED.rst>`_.
|
||||
|
||||
|
||||
1. Installation on GNU/Linux or Mac OS X
|
||||
----------------------------------------
|
||||
Bugs, features and development
|
||||
================================
|
||||
|
||||
a. Check requirements
|
||||
'''''''''''''''''''''
|
||||
Feel free to open issues here on GitHub! Please use the right templates for
|
||||
bugs and features, and use them correctly. Pull requests are also welcome. For
|
||||
a general overview of the development setup refer the `development instructions
|
||||
<DEVELOPMENT.rst>`_.
|
||||
|
||||
Make sure that you have installed `Python (>= 3.6) <https://www.python.org/>`_
|
||||
on your system.
|
||||
|
||||
Additional you need build-essential packages, header files and a static
|
||||
library for Python and also the pyvenv-3 binary package for python3.
|
||||
|
||||
E.g. run on Debian/Ubuntu::
|
||||
|
||||
$ sudo apt-get install build-essential python3-dev python3-venv
|
||||
|
||||
|
||||
b. Setup a virtual Python environment (optional)
|
||||
''''''''''''''''''''''''''''''''''''''''''''''''
|
||||
|
||||
You can setup a virtual Python environment using the virtual environment
|
||||
(venv) package for Python to install OpenSlides as non-root user.
|
||||
|
||||
Create your OpenSlides directory and change to it::
|
||||
|
||||
$ mkdir OpenSlides
|
||||
$ cd OpenSlides
|
||||
|
||||
Setup and activate the virtual environment::
|
||||
|
||||
$ python3 -m venv .virtualenv
|
||||
$ source .virtualenv/bin/activate
|
||||
$ pip install --upgrade setuptools pip
|
||||
|
||||
|
||||
c. Install OpenSlides
|
||||
'''''''''''''''''''''
|
||||
|
||||
To install OpenSlides just run::
|
||||
|
||||
$ pip install openslides
|
||||
|
||||
This installs the latest stable version. To install a specific (beta)
|
||||
version use ``openslides==x.y``.
|
||||
|
||||
You can also use the package from the `OpenSlides website
|
||||
<https://openslides.com/>`_. Download latest OpenSlides release as
|
||||
compressed tar archive and run::
|
||||
|
||||
$ pip install openslides-x.y.tar.gz
|
||||
|
||||
This will install all required Python packages (see
|
||||
``requirements/production.txt``).
|
||||
|
||||
|
||||
d. Start OpenSlides
|
||||
'''''''''''''''''''
|
||||
|
||||
To start OpenSlides simply run::
|
||||
|
||||
$ openslides
|
||||
|
||||
If you run this command the first time, a new database and the admin account
|
||||
(Username: ``admin``, Password: ``admin``) will be created. Please change the
|
||||
password after first login!
|
||||
|
||||
OpenSlides will start a webserver. It will also try to open the webinterface in
|
||||
your default webbrowser. The server will try to listen on the local ip address
|
||||
on port 8000. That means that the server will be available to everyone on your
|
||||
local network (at least for commonly used network configurations).
|
||||
|
||||
If you use a virtual environment (see step b.), do not forget to activate
|
||||
the environment before restart after you closed the terminal::
|
||||
|
||||
$ source .virtualenv/bin/activate
|
||||
|
||||
To get help on the command line options run::
|
||||
|
||||
$ openslides --help
|
||||
|
||||
You can store settings, database and other personal files in a local
|
||||
subdirectory and use these files e. g. if you want to run multiple
|
||||
instances of OpenSlides::
|
||||
|
||||
$ openslides start --local-installation
|
||||
|
||||
|
||||
2. Installation on Windows
|
||||
--------------------------
|
||||
|
||||
Follow the instructions above (1. Installation on GNU/Linux or Mac OS X) but care
|
||||
of the following variations.
|
||||
|
||||
To get Python download and run the latest `Python 3.7 32-bit (x86) executable
|
||||
installer <https://www.python.org/downloads/windows/>`_. Note that the 32-bit
|
||||
installer is required even on a 64-bit Windows system. If you use the 64-bit
|
||||
installer, step 1c of the instruction might fail unless you installed some
|
||||
packages manually.
|
||||
|
||||
In some cases you have to install `MS Visual C++ 2015 build tools
|
||||
<https://www.microsoft.com/en-us/download/details.aspx?id=48159>`_ before you
|
||||
install the required python packages for OpenSlides (unfortunately Twisted
|
||||
needs it).
|
||||
|
||||
To setup and activate the virtual environment in step 1b use::
|
||||
|
||||
> .virtualenv\Scripts\activate.bat
|
||||
|
||||
All other commands are the same as for GNU/Linux and Mac OS X.
|
||||
|
||||
|
||||
3. Installation with Docker
|
||||
---------------------------
|
||||
|
||||
The installation instruction for (1) and (2) described a way to use OpenSlides in a
|
||||
'small mode' with max 10 concurrent clients. To install OpenSlides for big assemblies
|
||||
('big mode') you have to setup some additional components and configurations.
|
||||
|
||||
The easiest way to run the OpenSlides 'big mode' environment (with PostgreSQL, Redis
|
||||
and NGINX) with Docker Compose: use our docker compose suite. Follow the instruction in
|
||||
the `openslides-doccker-compose Repository <https://github.com/OpenSlides/openslides-docker-compose>`_.
|
||||
|
||||
To install and configure all components of our 'big mode' manually you can read the
|
||||
`big-mode-instruction <https://github.com/OpenSlides/OpenSlides/blob/master/DEVELOPMENT.rst#openslides-in-big-mode>`_
|
||||
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
Please consider reading the `OpenSlides configuration
|
||||
<https://github.com/OpenSlides/OpenSlides/blob/master/SETTINGS.rst>`_ page to
|
||||
find out about all configurations, especially when using OpenSlides for big
|
||||
assemblies.
|
||||
|
||||
|
||||
Development
|
||||
===========
|
||||
|
||||
To setup a development environment for OpenSlides follow the instruction of
|
||||
`DEVELOPMENT.rst
|
||||
<https://github.com/OpenSlides/OpenSlides/blob/master/DEVELOPMENT.rst>`_.
|
||||
For security relevant issues **do not** create public issues and refer to
|
||||
our `security policy <SECURITY.md>`_.
|
||||
|
||||
|
||||
Used software
|
||||
|
@ -165,14 +147,14 @@ Used software
|
|||
|
||||
OpenSlides uses the following projects or parts of them:
|
||||
|
||||
* Several Python packages (see ``requirements/production.txt`` and ``requirements/big_mode.txt``).
|
||||
* several Python packages (see ``server/requirements/production.txt``)
|
||||
|
||||
* Several JavaScript packages (see ``client/package.json``)
|
||||
* several JavaScript packages (see ``client/package.json``)
|
||||
|
||||
|
||||
License and authors
|
||||
===================
|
||||
|
||||
OpenSlides is Free/Libre Open Source Software (FLOSS), and distributed
|
||||
under the MIT License, see ``LICENSE`` file. The authors of OpenSlides are
|
||||
mentioned in the ``AUTHORS`` file.
|
||||
under the MIT License, see `LICENSE file <LICENSE>`_. The authors of OpenSlides are
|
||||
mentioned in the `AUTHORS file <AUTHORS>`_.
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Currently the recent versions (a year old at max) of the current major
|
||||
release are supported to get security fixes. For older versions we urge
|
||||
you to update to the most recent version of OpenSlides.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please let us know about the vulnerability by an email to
|
||||
[security@openslides.com](mailto:security@openslides.com). Do not create
|
||||
public issues for such issues. We will get in touch with you after reporting
|
||||
the issue.
|
125
SETTINGS.rst
125
SETTINGS.rst
|
@ -1,125 +0,0 @@
|
|||
==========================
|
||||
OpenSlides configuration
|
||||
==========================
|
||||
|
||||
First, locate your `settings.py`. Since this is a regular python file,
|
||||
experienced users can also write more advanced configurations with e.g. swithing
|
||||
between two sets of configs. This also means, that the syntax need to be correct
|
||||
for OpenSlides to start.
|
||||
|
||||
All presented settings must be written `<SETTINGS_NAME> = <value>` to follow the
|
||||
correct syntax.
|
||||
|
||||
The `settings.py` is just an extension for Django settings. Please visit the
|
||||
`Django settings documentation
|
||||
<https://docs.djangoproject.com/en/2.2/ref/settings/>`_ to get an overview about
|
||||
all existing settings.
|
||||
|
||||
|
||||
SECURITY
|
||||
========
|
||||
|
||||
For `DEBUG` and `SECRET_KEY` see the sections in the django settings
|
||||
documenataion.
|
||||
|
||||
`RESET_PASSWORD_VERBOSE_ERRORS`: Default: `True`. Set to `False` to disable.
|
||||
Controls the verbosity on errors during a reset password. If enabled, an error
|
||||
will be shown, if there does not exist a user with a given email address. So one
|
||||
can check, if a email is registered. If this is not wanted, disable verbose
|
||||
messages. An success message will always be shown.
|
||||
|
||||
`AUTH_PASSWORD_VALIDATORS`: Add custom password validators, e.g. a min-length
|
||||
validator. See `django auth docs
|
||||
<https://docs.djangoproject.com/en/2.2/topics/auth/passwords/#module-django.contrib.auth.password_validation>`_
|
||||
for mor information.
|
||||
|
||||
|
||||
Directories
|
||||
===========
|
||||
|
||||
`OPENSLIDES_USER_DATA_DIR`: The path, where all user data is saved, like static
|
||||
files, mediafiles and the default database. This path can be different to the
|
||||
location of the `settings.py`.
|
||||
|
||||
`STATICFILES_DIRS` and `STATIC_ROOT`: Managing static files. Because the clint
|
||||
is not delivered by the server anymore, these settings are obsolete.
|
||||
|
||||
`MEDIA_ROOT`: The location of mediafiles. The default is a `media` folder inside
|
||||
`OPENSLIDES_USER_DATA_DIR`, but can be altered to another path.
|
||||
|
||||
|
||||
Email
|
||||
=====
|
||||
|
||||
Please refer to the Django settings documentation. A changed email backend is
|
||||
useful for debugging to print all email the the console::
|
||||
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
|
||||
Logging
|
||||
=======
|
||||
|
||||
To setup basic logging see `logging
|
||||
<https://docs.djangoproject.com/en/2.2/topics/logging/>`_.
|
||||
We recommend to enable all OpenSlides related logging with level `INFO` per
|
||||
default::
|
||||
|
||||
LOGGING = {
|
||||
'formatters':
|
||||
'lessnoise': {
|
||||
'format': '[{levelname}] {name} {message}',
|
||||
'style': '{',
|
||||
'datefmt': '[%Y-%m-%d %H:%M:%S %z]',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'lessnoise',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'openslides': {
|
||||
'handlers': ['console'],
|
||||
'level': os.getenv('OPENSLIDES_LOG_LEVEL', 'INFO'),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
With the environment variable `OPENSLIDES_LOG_LEVEL` the level can be adjusted
|
||||
without changing the `settings.py`.
|
||||
|
||||
|
||||
Big mode and caching
|
||||
====================
|
||||
|
||||
When running multiple workers redis is required as a message broker between the
|
||||
workers. Set `use_redis = True` to enable redis and visit `OpenSLides in big
|
||||
mode
|
||||
<https://github.com/OpenSlides/OpenSlides/blob/master/DEVELOPMENT.rst#openslides-in-big-mode>`_.
|
||||
|
||||
When seting `use_redis = True`, three settings are important:
|
||||
|
||||
- Caching: `REDIS_ADDRESS` is used to provide caching with redis across all
|
||||
workers.
|
||||
- Channels: The "message queue" for the workers. Adjust the `hosts`-part to the
|
||||
redis address.
|
||||
- Sessions: All sessions are managed in redis to ensure them across all workers.
|
||||
Please adjust the `SESSION_REDIS` fields to point to the redis instance.
|
||||
|
||||
|
||||
Advanced
|
||||
========
|
||||
|
||||
`PING_INTERVAL` and `PING_TIMEOUT` are settings for the clients how frequently
|
||||
to ping the server (interval) and how big is the timeout. If a ping took longer
|
||||
than the timeout, the clients does a forced reconnect.
|
||||
|
||||
`COMPRESSION`: Enable or disables the compression when sending data. This does
|
||||
not affect the client.
|
||||
|
||||
`PRIORITIZED_GROUP_IDS`: A list of group ids. If one client is logged in and the
|
||||
operator is in one of these groups, the client disconnected and reconnects again.
|
||||
All requests urls (including websockets) are now prefixed with `/prioritize`, so
|
||||
these requests from "prioritized clients" can be routed to different servers.
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 17f8e0f506a65acaae32191c3e309d222b76e8ef
|
|
@ -0,0 +1,20 @@
|
|||
import endpoint
|
||||
import invalid_host*
|
||||
|
||||
reverse_proxy /system/* autoupdate:8002 {
|
||||
flush_interval -1
|
||||
}
|
||||
|
||||
@server {
|
||||
path /apps/*
|
||||
path /rest/*
|
||||
path /server-version.txt
|
||||
}
|
||||
reverse_proxy @server server:8000
|
||||
|
||||
reverse_proxy /media/* media:8000
|
||||
|
||||
reverse_proxy client:4200
|
||||
}
|
||||
|
||||
import redirect*
|
|
@ -0,0 +1,15 @@
|
|||
localhost:8000 {
|
||||
reverse_proxy /system/* autoupdate:8002 {
|
||||
flush_interval -1
|
||||
}
|
||||
|
||||
@server {
|
||||
path /apps/*
|
||||
path /rest/*
|
||||
path /server-version.txt
|
||||
path /media/*
|
||||
}
|
||||
reverse_proxy @server server:8000
|
||||
|
||||
reverse_proxy client:4200
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
FROM caddy:2.3.0-alpine
|
||||
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY entrypoint /entrypoint
|
||||
COPY certs /certs
|
||||
|
||||
ENTRYPOINT ["/entrypoint"]
|
||||
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
|
@ -0,0 +1,3 @@
|
|||
FROM caddy:2.3.0-alpine
|
||||
|
||||
COPY Caddyfile.dev /etc/caddy/Caddyfile
|
|
@ -0,0 +1,2 @@
|
|||
build-dev:
|
||||
docker build -t os3-proxy-dev -f Dockerfile.dev .
|
|
@ -0,0 +1,78 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
if [[ -z "$EXTERNAL_HTTP_PORT" ]] && [[ -z "$EXTERNAL_HTTPS_PORT" ]]; then
|
||||
echo "EXTERNAL_HTTP_PORT and EXTERNAL_HTTPS_PORT are not set. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f "/certs/key.pem" ]] && [[ -f "/certs/cert.pem" ]]; then
|
||||
certs_exists=1
|
||||
fi
|
||||
|
||||
if [[ -n "$EXTERNAL_HTTPS_PORT" ]] && [[ -z "$certs_exists" ]]; then
|
||||
echo "Configured https, but no certificates found. Aborting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# config: https
|
||||
if [[ -n "$EXTERNAL_HTTPS_PORT" ]] ; then
|
||||
cat <<EOF > /etc/caddy/endpoint
|
||||
https://:8001 {
|
||||
tls /certs/cert.pem /certs/key.pem
|
||||
EOF
|
||||
echo "Configured https"
|
||||
fi
|
||||
|
||||
# config: http and no https
|
||||
if [[ -n "$EXTERNAL_HTTP_PORT" ]] && [[ -z "$EXTERNAL_HTTPS_PORT" ]] ; then
|
||||
echo "http://:8000 {" > /etc/caddy/endpoint
|
||||
echo "Configured http only"
|
||||
fi
|
||||
|
||||
# config: https and additionally http -> create redirect-file
|
||||
if [[ -n "$EXTERNAL_HTTP_PORT" ]] && [[ -n "$EXTERNAL_HTTPS_PORT" ]] ; then
|
||||
cat <<EOF > /etc/caddy/redirect
|
||||
http://:8000 {
|
||||
redir https://$INSTANCE_DOMAIN{uri}
|
||||
}
|
||||
EOF
|
||||
echo "Configured http to https redirect"
|
||||
fi
|
||||
|
||||
# Add allowed hosts from $ALLOWED_HOSTS
|
||||
# If the variable is empty, all hosts are allowed.
|
||||
# The hosts are ORed, so the request is valid, if one host matches.
|
||||
# Example: ALLOWED_HOSTS="localhost:8000 127.0.0.1:8000"
|
||||
#
|
||||
# @invalid-host {
|
||||
# not {
|
||||
# header Host localhost:8000
|
||||
# header Host 127.0.0.1:8000
|
||||
# }
|
||||
# }
|
||||
# respond @invalid-host "Misdirected Request" 421 {
|
||||
# close
|
||||
# }
|
||||
if [[ ! -z "$ALLOWED_HOSTS" ]]; then
|
||||
cat <<EOF > /etc/caddy/invalid_host
|
||||
@invalid-host {
|
||||
not {
|
||||
EOF
|
||||
for host in $ALLOWED_HOSTS; do
|
||||
echo " host $host" >> /etc/caddy/invalid_host
|
||||
done
|
||||
cat <<EOF >> /etc/caddy/invalid_host
|
||||
}
|
||||
}
|
||||
respond @invalid-host "Misdirected Request" 421 {
|
||||
close
|
||||
}
|
||||
EOF
|
||||
echo "Configured allowed hosts: $ALLOWED_HOSTS"
|
||||
else
|
||||
echo "All hosts allowed"
|
||||
fi
|
||||
|
||||
exec "$@"
|
|
@ -0,0 +1,22 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [[ -f "certs/key.pem" ]] || [[ -f "certs/cert.pem" ]]; then
|
||||
echo >&2 "Error: Certificate already exists."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! type 2>&1 >/dev/null openssl ; then
|
||||
echo >&2 "Error: openssl not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating certificates..."
|
||||
echo "You will need to accept an security exception for the"
|
||||
echo "generated certificate in your browser manually."
|
||||
openssl req -x509 -newkey rsa:4096 -nodes -days 3650 \
|
||||
-subj "/C=DE/O=Selfsigned Test/CN=localhost" \
|
||||
-keyout certs/key.pem -out certs/cert.pem
|
||||
echo "done"
|
|
@ -0,0 +1,22 @@
|
|||
Owner = "OpenSlides"
|
||||
RepositoryName = "OpenSlides"
|
||||
|
||||
OutputType = "file"
|
||||
FileName = "CHANGELOG.md"
|
||||
|
||||
CurrentRef = "master"
|
||||
BaseBranch = "master"
|
||||
PreviousRef = "3.3"
|
||||
FutureCurrentRefName = "3.4"
|
||||
|
||||
ThresholdPreviousRef = 10
|
||||
ThresholdCurrentRef = 10
|
||||
|
||||
Debug = false
|
||||
DisplayLabel = false
|
||||
|
||||
# ignore everything labeld as "meta"
|
||||
LabelExcludes = ["meta"]
|
||||
LabelEnhancement = "feature"
|
||||
LabelDocumentation = "documentation"
|
||||
LabelBug = "bug"
|
|
@ -0,0 +1,7 @@
|
|||
last 1 Chrome version
|
||||
last 1 Firefox version
|
||||
last 2 Edge major versions
|
||||
last 2 Safari major versions
|
||||
last 2 iOS major versions
|
||||
Firefox ESR
|
||||
not IE 11
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "OpenSlides Client Documentation",
|
||||
"output": "../Compodoc/",
|
||||
"tsconfig": "./tsconfig.json",
|
||||
"disableCoverage": true,
|
||||
"hideGenerator": true,
|
||||
"theme": "material",
|
||||
"customLogo": "./src/assets/img/openslides-logo.svg",
|
||||
"customFavicon": "./src/assets/img/favicon.png"
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
.git
|
||||
**/node_modules
|
||||
**/.vscode
|
||||
build.sh
|
175
client/README.md
175
client/README.md
|
@ -1,31 +1,5 @@
|
|||
# OpenSlides 3 Client
|
||||
|
||||
Prototype application for OpenSlides 3.0 (Client).
|
||||
Currently under constant heavy maintenance.
|
||||
|
||||
## Development Info
|
||||
|
||||
As an Angular project, Angular CLI is highly recommended to create components and services.
|
||||
See https://angular.io/guide/quickstart for details.
|
||||
|
||||
### Contribution Info
|
||||
|
||||
Please respect the code-style defined in `.editorconf` and `.pretierrc`.
|
||||
|
||||
Code alignment should be automatically corrected by the pre-commit hooks.
|
||||
Adjust your editor to the `.editorconfig` to avoid surprises.
|
||||
See https://editorconfig.org/ for details.
|
||||
|
||||
### Pre-Commit Hooks
|
||||
|
||||
Before commiting, new code will automatically be aligned to the definitions set in the
|
||||
`.prettierrc`.
|
||||
Furthermore, new code has to pass linting.
|
||||
|
||||
Our pre-commit hooks are:
|
||||
`pretty-quick --staged` and `lint`
|
||||
See `package.json` for details.
|
||||
|
||||
### Documentation Info
|
||||
|
||||
The documentation can be generated by running `npm run compodoc`.
|
||||
|
@ -38,16 +12,6 @@ command. If no port specified, it will try to use 8080.
|
|||
Please document new code using JSDoc tags.
|
||||
See https://compodoc.app/guides/jsdoc-tags.html for details.
|
||||
|
||||
### Development server
|
||||
|
||||
Run `npm start` for a development server. Navigate to `http://localhost:4200/`.
|
||||
The app will automatically reload if you change any of the source files.
|
||||
|
||||
A running OpenSlides (2.2 or higher) instance is expected on port 8000.
|
||||
|
||||
Start OpenSlides as usual using
|
||||
`python manage.py start --no-browser --host 0.0.0.0`
|
||||
|
||||
### Translation
|
||||
|
||||
We are using ngx-translate for translation purposes.
|
||||
|
@ -59,76 +23,79 @@ Language files can be found in `/src/assets/i18n`.
|
|||
|
||||
OpenSlides uses the following software or parts of them:
|
||||
|
||||
- [@angular/animations@8.2.4](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/cdk-experimental@8.1.4](https://github.com/angular/components), License: MIT
|
||||
- [@angular/cdk@8.1.4](https://github.com/angular/components), License: MIT
|
||||
- [@angular/common@8.2.4](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/compiler@8.2.4](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/core@8.2.4](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/forms@8.2.4](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/material-moment-adapter@8.1.4](https://github.com/angular/components), License: MIT
|
||||
- [@angular/material@8.1.4](https://github.com/angular/components), License: MIT
|
||||
- [@angular/platform-browser-dynamic@8.2.4](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/platform-browser@8.2.4](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/pwa@0.803.2](https://github.com/angular/angular-cli), License: MIT
|
||||
- [@angular/router@8.2.4](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/service-worker@8.2.4](https://github.com/angular/angular), License: MIT
|
||||
- [@ngx-pwa/local-storage@8.2.1](https://github.com/cyrilletuzi/angular-async-local-storage), License: MIT
|
||||
- [@ngx-translate/core@11.0.1](https://github.com/ngx-translate/core), License: MIT
|
||||
- [@ngx-translate/http-loader@4.0.0](https://github.com/ngx-translate/http-loader), License: MIT
|
||||
- [@pebula/ngrid-material@1.0.0-rc.5](https://github.com/shlomiassaf/ngrid), License: MIT
|
||||
- [@pebula/ngrid@1.0.0-rc.5](https://github.com/shlomiassaf/ngrid), License: MIT
|
||||
- [@pebula/utils@1.0.0](https://github.com/shlomiassaf/ngrid), License: MIT
|
||||
- [@tinymce/tinymce-angular@3.3.0](https://github.com/tinymce/tinymce-angular), License: Apache-2.0
|
||||
- [acorn@7.0.0](https://github.com/acornjs/acorn), License: MIT
|
||||
- [core-js@3.2.1](https://github.com/zloirock/core-js), License: MIT
|
||||
- [css-element-queries@1.2.1](https://github.com/marcj/css-element-queries), License: MIT
|
||||
- [exceljs@1.15.0](https://github.com/exceljs/exceljs), License: MIT
|
||||
- [@angular/animations@12.1.1](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/cdk-experimental@12.1.1](https://github.com/angular/components), License: MIT
|
||||
- [@angular/cdk@12.1.1](https://github.com/angular/components), License: MIT
|
||||
- [@angular/common@12.1.1](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/compiler@12.1.1](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/core@12.1.1](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/forms@12.1.1](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/material-moment-adapter@12.1.1](https://github.com/angular/components), License: MIT
|
||||
- [@angular/material@12.1.1](https://github.com/angular/components), License: MIT
|
||||
- [@angular/platform-browser-dynamic@12.1.1](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/platform-browser@12.1.1](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/router@12.1.1](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/service-worker@12.1.1](https://github.com/angular/angular), License: MIT
|
||||
- [@ngx-pwa/local-storage@12.0.0](https://github.com/cyrilletuzi/angular-async-local-storage), License: MIT
|
||||
- [@ngx-translate/core@13.0.0](https://github.com/ngx-translate/core), License: MIT
|
||||
- [@ngx-translate/http-loader@6.0.0](https://github.com/ngx-translate/http-loader), License: MIT
|
||||
- [@pebula/ngrid-material@4.0.0-alpha.3](undefined), License: UNKNOWN
|
||||
- [@pebula/ngrid@4.0.0-alpha.3](https://github.com/shlomiassaf/ngrid), License: MIT
|
||||
- [@pebula/utils@1.0.2](undefined), License: UNKNOWN
|
||||
- [@tinymce/tinymce-angular@4.2.4](https://github.com/tinymce/tinymce-angular), License: Apache-2.0
|
||||
- [@videojs/http-streaming@2.9.1](https://github.com/videojs/http-streaming), License: Apache-2.0
|
||||
- [acorn@8.4.1](https://github.com/acornjs/acorn), License: MIT
|
||||
- [chart.js@2.9.4](https://github.com/chartjs/Chart.js), License: MIT
|
||||
- [core-js@3.15.1](https://github.com/zloirock/core-js), License: MIT
|
||||
- [css-element-queries@1.2.3](https://github.com/marcj/css-element-queries), License: MIT
|
||||
- [exceljs@4.1.1](https://github.com/exceljs/exceljs), License: MIT
|
||||
- [file-saver@2.0.2](https://github.com/eligrey/FileSaver.js), License: MIT
|
||||
- [hammerjs@2.0.8](https://github.com/hammerjs/hammer.js), License: MIT
|
||||
- [jszip@3.5.0](https://github.com/Stuk/jszip), License: (MIT OR GPL-3.0)
|
||||
- [lz4js@0.2.0](https://github.com/Benzinga/lz4js), License: ISC
|
||||
- [material-icon-font@0.1.0](https://github.com//petergng/svgFontCreator), License: ISC
|
||||
- [moment@2.24.0](https://github.com/moment/moment), License: MIT
|
||||
- [ng2-pdf-viewer@5.3.4](git+https://vadimdez@github.com/VadimDez/ng2-pdf-viewer), License: MIT
|
||||
- [ngx-file-drop@8.0.7](https://github.com/georgipeltekov/ngx-file-drop), License: MIT
|
||||
- [ngx-mat-select-search@1.8.0](https://github.com/bithost-gmbh/ngx-mat-select-search), License: MIT
|
||||
- [ngx-material-timepicker@4.0.2](https://github.com/Agranom/ngx-material-timepicker), License: MIT
|
||||
- [ngx-papaparse@4.0.2](https://github.com/alberthaff/ngx-papaparse), License: MIT
|
||||
- [pdfmake@0.1.58](https://github.com/bpampuch/pdfmake), License: MIT
|
||||
- [po2json@1.0.0-alpha](https://github.com/mikeedwards/po2json), License: GNU Library General Public License
|
||||
- [rxjs@6.5.2](https://github.com/reactivex/rxjs), License: Apache-2.0
|
||||
- [text-encoding@0.7.0](https://github.com/inexorabletash/text-encoding), License: (Unlicense OR Apache-2.0)
|
||||
- [tinymce@5.0.14](https://github.com/tinymce/tinymce-dist), License: LGPL-2.1
|
||||
- [tslib@1.10.0](https://github.com/Microsoft/tslib), License: Apache-2.0
|
||||
- [uuid@3.3.3](https://github.com/kelektiv/node-uuid), License: MIT
|
||||
- [zone.js@0.9.1](https://github.com/angular/zone.js), License: MIT
|
||||
- [@angular-devkit/build-angular@0.803.2](https://github.com/angular/angular-cli), License: MIT
|
||||
- [@angular/cli@8.3.2](https://github.com/angular/angular-cli), License: MIT
|
||||
- [@angular/compiler-cli@8.2.4](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/language-service@8.2.4](https://github.com/angular/angular), License: MIT
|
||||
- [@biesbjerg/ngx-translate-extract@3.0.5](https://github.com/biesbjerg/ngx-translate-extract), License: MIT
|
||||
- [@compodoc/compodoc@1.1.10](https://github.com/compodoc/compodoc), License: MIT
|
||||
- [@types/jasmine@3.4.0](https://github.com/DefinitelyTyped/DefinitelyTyped), License: MIT
|
||||
- [@types/jasminewd2@2.0.6](https://github.com/DefinitelyTyped/DefinitelyTyped), License: MIT
|
||||
- [@types/node@12.7.3](https://github.com/DefinitelyTyped/DefinitelyTyped), License: MIT
|
||||
- [@types/yargs@13.0.2](https://github.com/DefinitelyTyped/DefinitelyTyped), License: MIT
|
||||
- [codelyzer@5.1.0](https://github.com/mgechev/codelyzer), License: MIT
|
||||
- [husky@3.0.4](https://github.com/typicode/husky), License: MIT
|
||||
- [jasmine-core@3.4.0](https://github.com/jasmine/jasmine), License: MIT
|
||||
- [jasmine-spec-reporter@4.2.1](https://github.com/bcaudan/jasmine-spec-reporter), License: Apache-2.0
|
||||
- [material-design-icons-iconfont@6.1.0](https://github.com/jossef/material-design-icons-iconfont), License: Apache-2.0
|
||||
- [moment@2.27.0](https://github.com/moment/moment), License: MIT
|
||||
- [ng-particles@2.6.0](https://github.com/matteobruni/tsparticles), License: MIT
|
||||
- [ng2-charts@2.4.0](https://github.com/valor-software/ng2-charts), License: ISC
|
||||
- [ng2-pdf-viewer@6.4.1](git+https://vadimdez@github.com/VadimDez/ng2-pdf-viewer), License: MIT
|
||||
- [ngx-device-detector@2.0.0](undefined), License: MIT*
|
||||
- [ngx-file-drop@11.1.0](https://github.com/georgipeltekov/ngx-file-drop), License: MIT
|
||||
- [ngx-mat-select-search@3.3.0](https://github.com/bithost-gmbh/ngx-mat-select-search), License: MIT
|
||||
- [ngx-material-timepicker@5.5.3](https://github.com/Agranom/ngx-material-timepicker), License: MIT
|
||||
- [ngx-papaparse@4.0.4](https://github.com/alberthaff/ngx-papaparse), License: MIT
|
||||
- [npm-license-crawler@0.2.1](http://github.com/mwittig/npm-license-crawler), License: BSD-3-Clause
|
||||
- [pdfjs-dist@2.5.207](https://github.com/mozilla/pdfjs-dist), License: Apache-2.0
|
||||
- [pdfmake@0.1.68](https://github.com/bpampuch/pdfmake), License: MIT
|
||||
- [po2json@1.0.0-beta-2](https://github.com/mikeedwards/po2json), License: LGPL-2.0-or-later
|
||||
- [rxjs@6.6.7](https://github.com/reactivex/rxjs), License: Apache-2.0
|
||||
- [tinymce@5.7.1](https://github.com/tinymce/tinymce-dist), License: LGPL-2.1
|
||||
- [tslib@2.3.0](https://github.com/Microsoft/tslib), License: 0BSD
|
||||
- [tsparticles@1.23.0](https://github.com/matteobruni/tsparticles), License: MIT
|
||||
- [video.js@7.13.3](https://github.com/videojs/video.js), License: Apache-2.0
|
||||
- [zone.js@0.11.4](https://github.com/angular/angular), License: MIT
|
||||
- [@angular-devkit/build-angular@12.1.1](https://github.com/angular/angular-cli), License: MIT
|
||||
- [@angular-devkit/schematics@12.1.1](https://github.com/angular/angular-cli), License: MIT
|
||||
- [@angular/cli@12.1.1](https://github.com/angular/angular-cli), License: MIT
|
||||
- [@angular/compiler-cli@12.1.1](https://github.com/angular/angular), License: MIT
|
||||
- [@angular/language-service@12.1.1](https://github.com/angular/angular), License: MIT
|
||||
- [@biesbjerg/ngx-translate-extract-marker@1.0.0](https://github.com/biesbjerg/ngx-translate-extract-marker), License: MIT
|
||||
- [@biesbjerg/ngx-translate-extract@7.0.4](https://github.com/biesbjerg/ngx-translate-extract), License: MIT
|
||||
- [@compodoc/compodoc@1.0.9](https://github.com/compodoc/compodoc), License: MIT
|
||||
- [@schematics/angular@10.0.8](https://github.com/angular/angular-cli), License: MIT
|
||||
- [@types/jasmine@3.6.11](https://github.com/DefinitelyTyped/DefinitelyTyped), License: MIT
|
||||
- [@types/node@14.6.2](https://github.com/DefinitelyTyped/DefinitelyTyped), License: MIT
|
||||
- [@types/yargs@15.0.5](https://github.com/DefinitelyTyped/DefinitelyTyped), License: MIT
|
||||
- [codelyzer@6.0.0](https://github.com/mgechev/codelyzer), License: MIT
|
||||
- [husky@4.2.5](https://github.com/typicode/husky), License: MIT
|
||||
- [jasmine-core@3.8.0](https://github.com/jasmine/jasmine), License: MIT
|
||||
- [karma-chrome-launcher@3.1.0](https://github.com/karma-runner/karma-chrome-launcher), License: MIT
|
||||
- [karma-coverage-istanbul-reporter@2.1.0](https://github.com/mattlewis92/karma-coverage-istanbul-reporter), License: MIT
|
||||
- [karma-jasmine-html-reporter@1.4.2](https://github.com/dfederm/karma-jasmine-html-reporter), License: MIT
|
||||
- [karma-jasmine@2.0.1](https://github.com/karma-runner/karma-jasmine), License: MIT
|
||||
- [karma@4.3.0](https://github.com/karma-runner/karma), License: MIT
|
||||
- [npm-license-crawler@0.2.1](https://github.com/mwittig/npm-license-crawler), License: BSD-3-Clause
|
||||
- [npm-run-all@4.1.5](https://github.com/mysticatea/npm-run-all), License: MIT
|
||||
- [prettier@1.18.2](https://github.com/prettier/prettier), License: MIT
|
||||
- [protractor@5.4.2](https://github.com/angular/protractor), License: MIT
|
||||
- [karma-coverage@2.0.3](https://github.com/karma-runner/karma-coverage), License: MIT
|
||||
- [karma-jasmine-html-reporter@1.5.4](https://github.com/dfederm/karma-jasmine-html-reporter), License: MIT
|
||||
- [karma-jasmine@4.0.1](https://github.com/karma-runner/karma-jasmine), License: MIT
|
||||
- [karma@6.3.4](https://github.com/karma-runner/karma), License: MIT
|
||||
- [prettier@2.3.2](https://github.com/prettier/prettier), License: MIT
|
||||
- [protractor@7.0.0](https://github.com/angular/protractor), License: MIT
|
||||
- [resize-observer-polyfill@1.5.1](https://github.com/que-etc/resize-observer-polyfill), License: MIT
|
||||
- [source-map-explorer@2.0.1](https://github.com/danvk/source-map-explorer), License: Apache-2.0
|
||||
- [ts-node@8.3.0](https://github.com/TypeStrong/ts-node), License: MIT
|
||||
- [tslint@5.19.0](https://github.com/palantir/tslint), License: Apache-2.0
|
||||
- [ts-node@9.0.0](https://github.com/TypeStrong/ts-node), License: MIT
|
||||
- [tslint@6.1.3](https://github.com/palantir/tslint), License: Apache-2.0
|
||||
- [tsutils@3.17.1](https://github.com/ajafff/tsutils), License: MIT
|
||||
- [typescript@3.5.3](https://github.com/Microsoft/TypeScript), License: Apache-2.0
|
||||
- [webpack-bundle-analyzer@3.4.1](https://github.com/webpack-contrib/webpack-bundle-analyzer), License: MIT
|
||||
- [typescript@4.3.5](https://github.com/Microsoft/TypeScript), License: Apache-2.0
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"styleext": "scss"
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
|
@ -17,66 +17,83 @@
|
|||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "../openslides/static",
|
||||
"outputPath": "../server/openslides/static",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"aot": false,
|
||||
"assets": [
|
||||
"src/assets",
|
||||
"src/manifest.json",
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/tinymce/skins",
|
||||
"output": "/tinymce/skins/"
|
||||
"input": "node_modules/tinymce",
|
||||
"output": "/tinymce/"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/tinymce/themes",
|
||||
"output": "/tinymce/themes/"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/tinymce/plugins",
|
||||
"output": "/tinymce/plugins/"
|
||||
"glob": "pdf.worker.min.js",
|
||||
"input": "node_modules/pdfjs-dist/build/",
|
||||
"output": "/assets/js/"
|
||||
}
|
||||
],
|
||||
"styles": ["src/styles.scss"],
|
||||
"scripts": ["node_modules/tinymce/tinymce.min.js"],
|
||||
"webWorkerTsConfig": "tsconfig.worker.json"
|
||||
"scripts": [
|
||||
"node_modules/tinymce/tinymce.min.js",
|
||||
"node_modules/video.js/dist/video.min.js",
|
||||
"src/assets/jitsi/external_api.js"
|
||||
],
|
||||
"webWorkerTsConfig": "tsconfig.worker.json",
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [{
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}],
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"extractCss": true,
|
||||
"namedChunks": false,
|
||||
"aot": true,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"serviceWorker": true,
|
||||
"budgets": [{
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "5mb",
|
||||
"maximumError": "10mb"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb"
|
||||
}
|
||||
]
|
||||
},
|
||||
"es5": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb"
|
||||
}
|
||||
],
|
||||
"tsConfig": "./tsconfig-es5.app.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"development": {}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "client:build"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
|
@ -84,8 +101,12 @@
|
|||
},
|
||||
"es5": {
|
||||
"browserTarget": "client:build:es5"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "client:build:development"
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
|
@ -134,14 +155,17 @@
|
|||
"e2e": {
|
||||
"builder": "@angular-devkit/build-angular:protractor",
|
||||
"options": {
|
||||
"protractorConfig": "e2e/protractor.conf.js",
|
||||
"devServerTarget": "client:serve"
|
||||
"protractorConfig": "e2e/protractor.conf.js"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "client:serve:production"
|
||||
},
|
||||
"development": {
|
||||
"devServerTarget": "client:serve:development"
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers
|
||||
# For additional information regarding the format and rule options, please see:
|
||||
# https://github.com/browserslist/browserslist#queries
|
||||
# For IE 9-11 support, please uncomment the last line of the file and adjust as needed
|
||||
> 0.5%
|
||||
last 2 versions
|
||||
Firefox ESR
|
||||
not dead
|
||||
IE 9-11
|
|
@ -0,0 +1 @@
|
|||
../server/build.sh
|
|
@ -0,0 +1,24 @@
|
|||
FROM node:16 AS nodejs
|
||||
|
||||
RUN mkdir -p /build/app
|
||||
WORKDIR /build/app
|
||||
RUN useradd -m openslides
|
||||
RUN chown -R openslides /build/app
|
||||
|
||||
USER root
|
||||
RUN npm install -g @angular/cli@^12
|
||||
RUN ng config -g cli.warnings.versionMismatch false
|
||||
|
||||
USER openslides
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm -v
|
||||
RUN npm ci
|
||||
COPY .browserslistrc *.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build-to-dir /build/app/static
|
||||
|
||||
COPY docker/client-version.txt static/
|
||||
|
||||
FROM nginx
|
||||
COPY --from=nodejs /build/app/static /usr/share/nginx/html
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
|
@ -0,0 +1,11 @@
|
|||
FROM node:16
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
RUN npm run postinstall
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD npm start
|
|
@ -0,0 +1,33 @@
|
|||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 32000;
|
||||
}
|
||||
|
||||
http {
|
||||
server {
|
||||
listen 4200;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
include /etc/nginx/mime.types;
|
||||
|
||||
# Optimizations for OpenSlides
|
||||
client_max_body_size 100M;
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 1000;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -9,19 +9,25 @@ module.exports = function (config) {
|
|||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage-istanbul-reporter'),
|
||||
require('karma-coverage'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
clearContext: false, // leave Jasmine Spec Runner output visible in browser
|
||||
jasmine: {
|
||||
timeoutInterval: 10000
|
||||
}
|
||||
// you can add configuration options for Jasmine here
|
||||
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
||||
// for example, you can disable the random execution with `random: false`
|
||||
// or set a specific seed with `seed: 4321`
|
||||
},
|
||||
coverageIstanbulReporter: {
|
||||
dir: require('path').join(__dirname, '../coverage'),
|
||||
reports: ['html', 'lcovonly'],
|
||||
fixWebpackSourcePaths: true
|
||||
},
|
||||
jasmineHtmlReporter: {
|
||||
suppressAll: true // removes the duplicated traces
|
||||
},
|
||||
coverageReporter: {
|
||||
dir: require('path').join(__dirname, './coverage/Test'),
|
||||
subdir: '.',
|
||||
reporters: [{ type: 'html' }, { type: 'text-summary' }]
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
|
@ -36,11 +42,6 @@ module.exports = function (config) {
|
|||
}
|
||||
},
|
||||
singleRun: false,
|
||||
proxies: {
|
||||
'/apps/': 'http://localhost:8000/apps/',
|
||||
'/media/': 'http://localhost:8000/media/',
|
||||
'/rest/': 'http://localhost:8000/rest/',
|
||||
'/ws/': 'ws://localhost:8000/'
|
||||
}
|
||||
restartOnFileChange: true
|
||||
});
|
||||
};
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
"dataGroups": [
|
||||
{
|
||||
"name": "api",
|
||||
"urls": ["/rest/*", "/apps/*"],
|
||||
"urls": ["/rest/*", "/apps/*", "/system/*", "/stats"],
|
||||
"cacheConfig": {
|
||||
"maxSize": 0,
|
||||
"maxAge": "0u",
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "OpenSlides3-Client",
|
||||
"version": "3.1.1",
|
||||
"version": "3.4.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/OpenSlides/OpenSlides.git"
|
||||
|
@ -11,99 +11,107 @@
|
|||
"scripts": {
|
||||
"ng": "ng",
|
||||
"ng-high-memory": "node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json --host=0.0.0.0",
|
||||
"start-es5": "ng serve --proxy-config proxy.conf.json --host=0.0.0.0 --configuration es5",
|
||||
"build": "npm run ng-high-memory -- build --prod",
|
||||
"start": "npm run ng-high-memory -- serve --proxy-config proxy.conf.json --host=0.0.0.0",
|
||||
"start-https": "npm run ng-high-memory -- serve --ssl --ssl-cert localhost.pem --ssl-key localhost-key.pem --proxy-config proxy.conf.json --host=0.0.0.0",
|
||||
"start-es5": "npm run ng-high-memory -- serve --proxy-config proxy.conf.json --host=0.0.0.0 --configuration es5",
|
||||
"build": "npm run ng-high-memory -- build --configuration production",
|
||||
"build-to-dir": "npm run build -- --output-path",
|
||||
"postinstall": "ngcc",
|
||||
"build-debug": "npm run ng-high-memory -- build",
|
||||
"test": "ng test",
|
||||
"test": "npm run ng-high-memory -- test",
|
||||
"test-silently": "npm run test -- --watch=false --no-progress --browsers=ChromeHeadlessNoSandbox",
|
||||
"test-live": "npm run test -- --watch=true --browsers=ChromeHeadlessNoSandbox",
|
||||
"lint-check": "ng lint",
|
||||
"lint-write": "ng lint --fix",
|
||||
"e2e": "ng e2e",
|
||||
"licenses": "node src/crawler.js",
|
||||
"compodoc": "./node_modules/.bin/compodoc --hideGenerator -p tsconfig.app.json -n 'OpenSlides Documentation' -d ../Compodoc -s -o -r",
|
||||
"extract": "ngx-translate-extract -i ./src -o ./src/assets/i18n/template-en.pot --clean --sort --format pot -m _",
|
||||
"doc-serve": "./node_modules/.bin/compodoc -c .compodocrc.json -s -o -r",
|
||||
"doc-build": "./node_modules/.bin/compodoc -c .compodocrc.json",
|
||||
"extract": "ngx-translate-extract -i ./src -o ./src/assets/i18n/template-en.pot --clean --sort --format pot",
|
||||
"po2json": "./node_modules/.bin/po2json -f mf src/assets/i18n/de.po src/assets/i18n/de.json && ./node_modules/.bin/po2json -f mf src/assets/i18n/cs.po src/assets/i18n/cs.json && ./node_modules/.bin/po2json -f mf src/assets/i18n/ru.po src/assets/i18n/ru.json",
|
||||
"po2json-tempfix": "./node_modules/.bin/po2json -f mf src/assets/i18n/de.po /dev/stdout | sed -f sed_replacements > src/assets/i18n/de.json && ./node_modules/.bin/po2json -f mf src/assets/i18n/cs.po /dev/stdout | sed -f sed_replacements > src/assets/i18n/cs.json && ./node_modules/.bin/po2json -f mf src/assets/i18n/ru.po /dev/stdout | sed -f sed_replacements > src/assets/i18n/ru.json",
|
||||
"po2json-tempfix": "./node_modules/.bin/po2json -f mf src/assets/i18n/de.po /dev/stdout | sed -f sed_replacements > src/assets/i18n/de.json && ./node_modules/.bin/po2json -f mf src/assets/i18n/cs.po /dev/stdout | sed -f sed_replacements > src/assets/i18n/cs.json && ./node_modules/.bin/po2json -f mf src/assets/i18n/ru.po /dev/stdout | sed -f sed_replacements > src/assets/i18n/ru.json && ./node_modules/.bin/po2json -f mf src/assets/i18n/it.po /dev/stdout | sed -f sed_replacements > src/assets/i18n/it.json && ./node_modules/.bin/po2json -f mf src/assets/i18n/es.po /dev/stdout | sed -f sed_replacements > src/assets/i18n/es.json",
|
||||
"prettify-check": "prettier --config ./.prettierrc --list-different \"src/{app,environments}/**/*{.ts,.js,.json,.css,.scss}\"",
|
||||
"prettify-write": "prettier --config ./.prettierrc --write \"src/{app,environments}/**/*{.ts,.js,.json,.css,.scss}\"",
|
||||
"cleanup": "npm run prettify-write; npm run lint-write",
|
||||
"cleanup-win": "npm run prettify-write & npm run lint-write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "~8.2.4",
|
||||
"@angular/cdk": "~8.1.4",
|
||||
"@angular/cdk-experimental": "~8.1.4",
|
||||
"@angular/common": "~8.2.4",
|
||||
"@angular/compiler": "~8.2.4",
|
||||
"@angular/core": "~8.2.4",
|
||||
"@angular/forms": "~8.2.4",
|
||||
"@angular/material": "~8.1.4",
|
||||
"@angular/material-moment-adapter": "~8.1.4",
|
||||
"@angular/platform-browser": "~8.2.4",
|
||||
"@angular/platform-browser-dynamic": "~8.2.4",
|
||||
"@angular/pwa": "^0.803.1",
|
||||
"@angular/router": "~8.2.4",
|
||||
"@angular/service-worker": "~8.2.4",
|
||||
"@ngx-pwa/local-storage": "~8.2.1",
|
||||
"@ngx-translate/core": "~11.0.1",
|
||||
"@ngx-translate/http-loader": "^4.0.0",
|
||||
"@pebula/ngrid": "1.0.0-rc.9",
|
||||
"@pebula/ngrid-material": "1.0.0-rc.9",
|
||||
"@pebula/utils": "1.0.0",
|
||||
"@tinymce/tinymce-angular": "^3.2.0",
|
||||
"acorn": "^7.0.0",
|
||||
"core-js": "^3.2.1",
|
||||
"css-element-queries": "^1.2.1",
|
||||
"exceljs": "1.15.0",
|
||||
"@angular/animations": "~12.1.1",
|
||||
"@angular/cdk": "~12.1.1",
|
||||
"@angular/cdk-experimental": "~12.1.1",
|
||||
"@angular/common": "~12.1.1",
|
||||
"@angular/compiler": "~12.1.1",
|
||||
"@angular/core": "~12.1.1",
|
||||
"@angular/forms": "~12.1.1",
|
||||
"@angular/material": "~12.1.1",
|
||||
"@angular/material-moment-adapter": "~12.1.1",
|
||||
"@angular/platform-browser": "~12.1.1",
|
||||
"@angular/platform-browser-dynamic": "~12.1.1",
|
||||
"@angular/router": "~12.1.1",
|
||||
"@angular/service-worker": "~12.1.1",
|
||||
"@ngx-pwa/local-storage": "~12.0.0",
|
||||
"@ngx-translate/core": "~13.0.0",
|
||||
"@ngx-translate/http-loader": "^6.0.0",
|
||||
"@pebula/ngrid": "4.0.0-alpha.3",
|
||||
"@pebula/ngrid-material": "4.0.0-alpha.3",
|
||||
"@pebula/utils": "1.0.2",
|
||||
"@tinymce/tinymce-angular": "^4.2.4",
|
||||
"@videojs/http-streaming": "^2.9.1",
|
||||
"acorn": "^8.0.1",
|
||||
"chart.js": "^2.9.3",
|
||||
"core-js": "^3.6.5",
|
||||
"css-element-queries": "^1.2.3",
|
||||
"exceljs": "4.1.1",
|
||||
"file-saver": "^2.0.2",
|
||||
"hammerjs": "^2.0.8",
|
||||
"jszip": "^3.5.0",
|
||||
"lz4js": "^0.2.0",
|
||||
"material-icon-font": "git+https://github.com/petergng/materialIconFont.git",
|
||||
"moment": "^2.24.0",
|
||||
"ng2-pdf-viewer": "^5.3.4",
|
||||
"ngx-file-drop": "~8.0.7",
|
||||
"ngx-mat-select-search": "^1.8.0",
|
||||
"ngx-material-timepicker": "^4.0.2",
|
||||
"ngx-papaparse": "^4.0.2",
|
||||
"pdfmake": "^0.1.58",
|
||||
"po2json": "^1.0.0-alpha",
|
||||
"rxjs": "^6.5.2",
|
||||
"tinymce": "^5.0.14",
|
||||
"tslib": "^1.10.0",
|
||||
"uuid": "^3.3.2",
|
||||
"zone.js": "~0.9.1"
|
||||
"material-design-icons-iconfont": "6.1.0",
|
||||
"moment": "^2.27.0",
|
||||
"ng-particles": "^2.6.0",
|
||||
"ng2-charts": "^2.4.0",
|
||||
"ng2-pdf-viewer": "^6.4.1",
|
||||
"ngx-device-detector": "^2.0.0",
|
||||
"ngx-file-drop": "^11.1.0",
|
||||
"ngx-mat-select-search": "^3.3.0",
|
||||
"ngx-material-timepicker": "^5.5.3",
|
||||
"ngx-papaparse": "^4.0.4",
|
||||
"pdfjs-dist": "2.5.207",
|
||||
"pdfmake": "^0.1.68",
|
||||
"po2json": "^1.0.0-beta-2",
|
||||
"rxjs": "^6.6.2",
|
||||
"tinymce": "5.7.1",
|
||||
"tslib": "^2.0.0",
|
||||
"tsparticles": "^1.23.0",
|
||||
"video.js": "^7.8.4",
|
||||
"zone.js": "~0.11.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "~0.803.2",
|
||||
"@angular/cli": "~8.3.2",
|
||||
"@angular/compiler-cli": "~8.2.4",
|
||||
"@angular/language-service": "~8.2.4",
|
||||
"@biesbjerg/ngx-translate-extract": "^3.0.5",
|
||||
"@compodoc/compodoc": "^1.1.8",
|
||||
"@types/jasmine": "^3.3.9",
|
||||
"@types/jasminewd2": "^2.0.6",
|
||||
"@types/node": "~12.7.2",
|
||||
"@types/yargs": "^13.0.0",
|
||||
"codelyzer": "^5.0.1",
|
||||
"husky": "^3.0.4",
|
||||
"jasmine-core": "~3.4.0",
|
||||
"jasmine-spec-reporter": "~4.2.1",
|
||||
"karma": "^4.1.0",
|
||||
"@angular-devkit/build-angular": "~12.1.1",
|
||||
"@angular-devkit/schematics": "^12.1.1",
|
||||
"@angular/cli": "~12.1.1",
|
||||
"@angular/compiler-cli": "~12.1.1",
|
||||
"@angular/language-service": "~12.1.1",
|
||||
"@biesbjerg/ngx-translate-extract": "^7.0.4",
|
||||
"@biesbjerg/ngx-translate-extract-marker": "^1.0.0",
|
||||
"@compodoc/compodoc": "^1.0.9",
|
||||
"@schematics/angular": "^10.0.8",
|
||||
"@types/jasmine": "~3.6.0",
|
||||
"@types/node": "^14.6.2",
|
||||
"@types/yargs": "^15.0.4",
|
||||
"codelyzer": "^6.0.0",
|
||||
"husky": "^4.2.3",
|
||||
"jasmine-core": "~3.8.0",
|
||||
"karma": "^6.3.4",
|
||||
"karma-chrome-launcher": "~3.1.0",
|
||||
"karma-coverage-istanbul-reporter": "^2.0.5",
|
||||
"karma-jasmine": "~2.0.1",
|
||||
"karma-jasmine-html-reporter": "^1.4.0",
|
||||
"npm-license-crawler": "^0.2.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^1.19.1",
|
||||
"protractor": "^5.4.2",
|
||||
"karma-coverage": "~2.0.3",
|
||||
"karma-jasmine": "~4.0.0",
|
||||
"karma-jasmine-html-reporter": "^1.5.0",
|
||||
"prettier": "2.3.2",
|
||||
"protractor": "^7.0.0",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"source-map-explorer": "^2.0.1",
|
||||
"ts-node": "~8.3.0",
|
||||
"tslint": "~5.19.0",
|
||||
"ts-node": "~9.0.0",
|
||||
"tslint": "~6.1.3",
|
||||
"tsutils": "3.17.1",
|
||||
"typescript": "~3.5.3",
|
||||
"webpack-bundle-analyzer": "^3.3.2"
|
||||
"typescript": "~4.3.5"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,5 +15,9 @@
|
|||
"target": "ws://localhost:8000",
|
||||
"secure": false,
|
||||
"ws": true
|
||||
},
|
||||
"/system/": {
|
||||
"target": "https://localhost:8002",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { Route, RouterModule } from '@angular/router';
|
||||
|
||||
import { LoginLegalNoticeComponent } from './site/login/components/login-legal-notice/login-legal-notice.component';
|
||||
import { LoginMaskComponent } from './site/login/components/login-mask/login-mask.component';
|
||||
|
@ -7,11 +7,12 @@ import { LoginPrivacyPolicyComponent } from './site/login/components/login-priva
|
|||
import { LoginWrapperComponent } from './site/login/components/login-wrapper/login-wrapper.component';
|
||||
import { ResetPasswordConfirmComponent } from './site/login/components/reset-password-confirm/reset-password-confirm.component';
|
||||
import { ResetPasswordComponent } from './site/login/components/reset-password/reset-password.component';
|
||||
import { UnsupportedBrowserComponent } from './site/login/components/unsupported-browser/unsupported-browser.component';
|
||||
|
||||
/**
|
||||
* Global app routing
|
||||
*/
|
||||
const routes: Routes = [
|
||||
const routes: Route[] = [
|
||||
{
|
||||
path: 'login',
|
||||
component: LoginWrapperComponent,
|
||||
|
@ -20,7 +21,8 @@ const routes: Routes = [
|
|||
{ path: 'reset-password', component: ResetPasswordComponent },
|
||||
{ path: 'reset-password-confirm', component: ResetPasswordConfirmComponent },
|
||||
{ path: 'legalnotice', component: LoginLegalNoticeComponent },
|
||||
{ path: 'privacypolicy', component: LoginPrivacyPolicyComponent }
|
||||
{ path: 'privacypolicy', component: LoginPrivacyPolicyComponent },
|
||||
{ path: 'unsupported-browser', component: UnsupportedBrowserComponent }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@ -34,7 +36,7 @@ const routes: Routes = [
|
|||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes, { onSameUrlNavigation: 'reload' })],
|
||||
imports: [RouterModule.forRoot(routes, { onSameUrlNavigation: 'reload', relativeLinkResolution: 'legacy' })],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule {}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
.content {
|
||||
flex: 1;
|
||||
height: 100vh;
|
||||
}
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
import { async, fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { E2EImportsModule } from './../e2e-imports.module';
|
||||
import { ServertimeService } from './core/core-services/servertime.service';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
let servertimeService, translate;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [E2EImportsModule]
|
||||
}).compileComponents();
|
||||
|
||||
servertimeService = TestBed.get(ServertimeService);
|
||||
translate = TestBed.get(TranslateService);
|
||||
spyOn(servertimeService, 'startScheduler').and.stub();
|
||||
spyOn(translate, 'addLangs').and.stub();
|
||||
spyOn(translate, 'setDefaultLang').and.stub();
|
||||
spyOn(translate, 'getBrowserLang').and.stub();
|
||||
spyOn(translate, 'getLangs').and.returnValue([]);
|
||||
spyOn(translate, 'use').and.stub();
|
||||
}));
|
||||
it('should create the app', fakeAsync(() => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.debugElement.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
tick(1000);
|
||||
fixture.whenStable().then(() => {
|
||||
expect(servertimeService.startScheduler).toHaveBeenCalled();
|
||||
});
|
||||
}));
|
||||
});
|
|
@ -1,22 +1,27 @@
|
|||
import { ApplicationRef, Component } from '@angular/core';
|
||||
import { MatIconRegistry } from '@angular/material/icon';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { filter, take } from 'rxjs/operators';
|
||||
import { first, tap } from 'rxjs/operators';
|
||||
|
||||
import { ChatNotificationService } from './site/chat/services/chat-notification.service';
|
||||
import { ConfigService } from './core/ui-services/config.service';
|
||||
import { ConstantsService } from './core/core-services/constants.service';
|
||||
import { CountUsersService } from './core/ui-services/count-users.service';
|
||||
import { DataStoreUpgradeService } from './core/core-services/data-store-upgrade.service';
|
||||
import { LoadFontService } from './core/ui-services/load-font.service';
|
||||
import { LoginDataService } from './core/ui-services/login-data.service';
|
||||
import { OfflineService } from './core/core-services/offline.service';
|
||||
import { OpenSlidesStatusService } from './core/core-services/openslides-status.service';
|
||||
import { OpenSlidesService } from './core/core-services/openslides.service';
|
||||
import { OperatorService } from './core/core-services/operator.service';
|
||||
import { OverlayService } from './core/ui-services/overlay.service';
|
||||
import { PingService } from './core/core-services/ping.service';
|
||||
import { PrioritizeService } from './core/core-services/prioritize.service';
|
||||
import { RoutingStateService } from './core/ui-services/routing-state.service';
|
||||
import { ServertimeService } from './core/core-services/servertime.service';
|
||||
import { ThemeService } from './core/ui-services/theme.service';
|
||||
import { VotingBannerService } from './core/ui-services/voting-banner.service';
|
||||
|
||||
declare global {
|
||||
/**
|
||||
|
@ -25,6 +30,12 @@ declare global {
|
|||
*/
|
||||
interface Array<T> {
|
||||
flatMap(o: any): any[];
|
||||
intersect(a: T[]): T[];
|
||||
mapToObject(f: (item: T) => { [key: string]: any }): { [key: string]: any };
|
||||
}
|
||||
|
||||
interface Set<T> {
|
||||
equals(other: Set<T>): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -34,8 +45,17 @@ declare global {
|
|||
interface Number {
|
||||
modulo(n: number): number;
|
||||
}
|
||||
|
||||
interface String {
|
||||
decode(): string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* May only be created once since this thing is fat
|
||||
*/
|
||||
const domParser = new DOMParser();
|
||||
|
||||
/**
|
||||
* Angular's global App Component
|
||||
*/
|
||||
|
@ -52,37 +72,34 @@ export class AppComponent {
|
|||
*
|
||||
* Handles the altering of Array.toString()
|
||||
*
|
||||
* @param translate To set the default language
|
||||
* @param operator To call the constructor of the OperatorService
|
||||
* @param loginDataService to call the constructor of the LoginDataService
|
||||
* @param constantService to call the constructor of the ConstantService
|
||||
* @param servertimeService executes the scheduler early on
|
||||
* @param themeService used to listen to theme-changes
|
||||
* @param countUsersService to call the constructor of the CountUserService
|
||||
* @param configService to call the constructor of the ConfigService
|
||||
* @param loadFontService to call the constructor of the LoadFontService
|
||||
* @param dataStoreUpgradeService
|
||||
* Most of the injected service are not used - this is ok. It is needed to definitly
|
||||
* run their constructors at app loading time
|
||||
*/
|
||||
public constructor(
|
||||
private matIconRegistry: MatIconRegistry,
|
||||
private domSanitizer: DomSanitizer,
|
||||
translate: TranslateService,
|
||||
appRef: ApplicationRef,
|
||||
servertimeService: ServertimeService,
|
||||
openslidesService: OpenSlidesService,
|
||||
openslidesStatus: OpenSlidesStatusService,
|
||||
router: Router,
|
||||
offlineService: OfflineService,
|
||||
operator: OperatorService,
|
||||
loginDataService: LoginDataService,
|
||||
constantsService: ConstantsService, // Needs to be started, so it can register itself to the WebsocketService
|
||||
constantsService: ConstantsService,
|
||||
themeService: ThemeService,
|
||||
overlayService: OverlayService,
|
||||
countUsersService: CountUsersService, // Needed to register itself.
|
||||
configService: ConfigService,
|
||||
loadFontService: LoadFontService,
|
||||
dataStoreUpgradeService: DataStoreUpgradeService, // to start it.
|
||||
prioritizeService: PrioritizeService,
|
||||
pingService: PingService,
|
||||
routingState: RoutingStateService
|
||||
routingState: RoutingStateService,
|
||||
votingBannerService: VotingBannerService, // needed for initialisation,
|
||||
chatNotificationService: ChatNotificationService
|
||||
) {
|
||||
// manually add the supported languages
|
||||
translate.addLangs(['en', 'de', 'cs', 'ru']);
|
||||
translate.addLangs(['en', 'de', 'it', 'es', 'cs', 'ru']);
|
||||
// this language will be used as a fallback when a translation isn't found in the current language
|
||||
translate.setDefaultLang('en');
|
||||
// get the browsers default language
|
||||
|
@ -91,32 +108,29 @@ export class AppComponent {
|
|||
translate.use(translate.getLangs().includes(browserLang) ? browserLang : 'en');
|
||||
|
||||
// change default JS functions
|
||||
this.overloadArrayToString();
|
||||
this.overloadFlatMap();
|
||||
this.overloadArrayFunctions();
|
||||
this.overloadSetFunctions();
|
||||
this.overloadModulo();
|
||||
this.loadCustomIcons();
|
||||
this.overloadDecodeString();
|
||||
|
||||
// Wait until the App reaches a stable state.
|
||||
// Required for the Service Worker.
|
||||
appRef.isStable
|
||||
.pipe(
|
||||
// take only the stable state
|
||||
filter(s => s),
|
||||
take(1)
|
||||
first(stable => stable),
|
||||
tap(() => console.debug('App is now stable!'))
|
||||
)
|
||||
.subscribe(() => servertimeService.startScheduler());
|
||||
.subscribe(() => {
|
||||
openslidesStatus.setStable();
|
||||
servertimeService.startScheduler();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to alter the normal Array.toString - function
|
||||
*
|
||||
* Will add a whitespace after a comma and shorten the output to
|
||||
* three strings.
|
||||
*
|
||||
* TODO: There might be a better place for overloading functions than app.component
|
||||
* TODO: Overloading can be extended to more functions.
|
||||
*/
|
||||
private overloadArrayToString(): void {
|
||||
Array.prototype.toString = function(): string {
|
||||
private overloadArrayFunctions(): void {
|
||||
Object.defineProperty(Array.prototype, 'toString', {
|
||||
value: function (): string {
|
||||
let string = '';
|
||||
const iterations = Math.min(this.length, 3);
|
||||
|
||||
|
@ -132,19 +146,83 @@ export class AppComponent {
|
|||
}
|
||||
}
|
||||
return string;
|
||||
};
|
||||
},
|
||||
enumerable: false
|
||||
});
|
||||
|
||||
Object.defineProperty(Array.prototype, 'flatMap', {
|
||||
value: function (o: any): any[] {
|
||||
const concatFunction = (x: any, y: any[]) => x.concat(y);
|
||||
const flatMapLogic = (f: any, xs: any) => xs.map(f).reduce(concatFunction, []);
|
||||
return flatMapLogic(o, this);
|
||||
},
|
||||
enumerable: false
|
||||
});
|
||||
|
||||
Object.defineProperty(Array.prototype, 'intersect', {
|
||||
value: function <T>(other: T[]): T[] {
|
||||
let a = this;
|
||||
let b = other;
|
||||
// indexOf to loop over shorter
|
||||
if (b.length > a.length) {
|
||||
[a, b] = [b, a];
|
||||
}
|
||||
return a.filter(e => b.indexOf(e) > -1);
|
||||
},
|
||||
enumerable: false
|
||||
});
|
||||
|
||||
Object.defineProperty(Array.prototype, 'mapToObject', {
|
||||
value: function <T>(f: (item: T) => { [key: string]: any }): { [key: string]: any } {
|
||||
return this.reduce((aggr, item) => {
|
||||
const res = f(item);
|
||||
for (const key in res) {
|
||||
if (res.hasOwnProperty(key)) {
|
||||
aggr[key] = res[key];
|
||||
}
|
||||
}
|
||||
return aggr;
|
||||
}, {});
|
||||
},
|
||||
enumerable: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an implementation of flatMap.
|
||||
* TODO: Remove once flatMap made its way into official JS/TS (ES 2019?)
|
||||
* Adds some functions to Set.
|
||||
*/
|
||||
private overloadFlatMap(): void {
|
||||
const concat = (x: any, y: any) => x.concat(y);
|
||||
const flatMap = (f: any, xs: any) => xs.map(f).reduce(concat, []);
|
||||
Array.prototype.flatMap = function(f: any): any[] {
|
||||
return flatMap(f, this);
|
||||
};
|
||||
private overloadSetFunctions(): void {
|
||||
Object.defineProperty(Set.prototype, 'equals', {
|
||||
value: function <T>(other: Set<T>): boolean {
|
||||
const difference = new Set(this);
|
||||
for (const elem of other) {
|
||||
if (difference.has(elem)) {
|
||||
difference.delete(elem);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return !difference.size;
|
||||
},
|
||||
enumerable: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This is not the fastest solution but the most reliable one.
|
||||
* Certain languages and TinyMCE do not follow the any predictable
|
||||
* behaviour when it comes to encoding UTF8.
|
||||
* decodeURI and decodeURIComponent were not able to successfully
|
||||
* replace any ;&*uml with something meaningfull.
|
||||
*/
|
||||
private overloadDecodeString(): void {
|
||||
Object.defineProperty(String.prototype, 'decode', {
|
||||
enumerable: false,
|
||||
value(): string {
|
||||
const doc = domParser.parseFromString(this, 'text/html');
|
||||
return doc.body.textContent || '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -152,8 +230,18 @@ export class AppComponent {
|
|||
* TODO: Remove this, if the remainder operation is changed to modulo.
|
||||
*/
|
||||
private overloadModulo(): void {
|
||||
Number.prototype.modulo = function(n: number): number {
|
||||
Object.defineProperty(Number.prototype, 'modulo', {
|
||||
value: function (n: number): number {
|
||||
return ((this % n) + n) % n;
|
||||
};
|
||||
},
|
||||
enumerable: false
|
||||
});
|
||||
}
|
||||
|
||||
private loadCustomIcons(): void {
|
||||
this.matIconRegistry.addSvgIcon(
|
||||
`clapping_hands`,
|
||||
this.domSanitizer.bypassSecurityTrustResourceUrl('../assets/svg/clapping_hands.svg')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
import { HttpClientModule, HttpClientXsrfModule } from '@angular/common/http';
|
||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||
import { APP_INITIALIZER, ErrorHandler, NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
|
||||
import { StorageModule } from '@ngx-pwa/local-storage';
|
||||
|
||||
import { AppLoadService } from './core/core-services/app-load.service';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
import { CoreModule } from './core/core.module';
|
||||
import { environment } from '../environments/environment';
|
||||
import { ErrorService } from './core/core-services/error.service';
|
||||
import { httpInterceptorProviders } from './core/core-services/http-interceptors';
|
||||
import { LoginModule } from './site/login/login.module';
|
||||
import { OpenSlidesTranslateModule } from './core/translate/openslides-translate-module';
|
||||
import { SlidesModule } from './slides/slides.module';
|
||||
|
@ -39,9 +43,14 @@ export function AppLoaderFactory(appLoadService: AppLoadService): () => Promise<
|
|||
CoreModule,
|
||||
LoginModule,
|
||||
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
|
||||
SlidesModule.forRoot()
|
||||
SlidesModule.forRoot(),
|
||||
StorageModule.forRoot({ IDBNoWrap: false })
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_INITIALIZER, useFactory: AppLoaderFactory, deps: [AppLoadService], multi: true },
|
||||
httpInterceptorProviders,
|
||||
{ provide: ErrorHandler, useClass: ErrorService }
|
||||
],
|
||||
providers: [{ provide: APP_INITIALIZER, useFactory: AppLoaderFactory, deps: [AppLoadService], multi: true }],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
@ -2,6 +2,8 @@ import { Title } from '@angular/platform-browser';
|
|||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { Permission } from './core/core-services/operator.service';
|
||||
|
||||
/**
|
||||
* Provides functionalities that will be used by most components
|
||||
* currently able to set the title with the suffix ' - OpenSlides'
|
||||
|
@ -10,6 +12,11 @@ import { TranslateService } from '@ngx-translate/core';
|
|||
* Components in the 'Side'- or 'projector' Folder are BaseComponents
|
||||
*/
|
||||
export abstract class BaseComponent {
|
||||
/**
|
||||
* To check permissions in templates using permission.[...]
|
||||
*/
|
||||
public permission = Permission;
|
||||
|
||||
/**
|
||||
* To manipulate the browser title bar, adds the Suffix "OpenSlides"
|
||||
*
|
||||
|
@ -58,7 +65,10 @@ export abstract class BaseComponent {
|
|||
mobile: {
|
||||
theme: 'mobile',
|
||||
plugins: ['autosave', 'lists', 'autolink']
|
||||
}
|
||||
},
|
||||
relative_urls: false,
|
||||
remove_script_host: true,
|
||||
paste_preprocess: this.pastePreprocess
|
||||
};
|
||||
|
||||
public constructor(protected titleService: Title, protected translate: TranslateService) {
|
||||
|
@ -66,6 +76,29 @@ export abstract class BaseComponent {
|
|||
this.tinyMceSettings.language = this.translate.currentLang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean pasted HTML.
|
||||
* If the user decides to copy-paste HTML (like from another OpenSlides motion detail)
|
||||
* - remove all classes
|
||||
* - remove data-line-number="X"
|
||||
* - remove contenteditable="false"
|
||||
*
|
||||
* Not doing so would save control sequences from diff/linenumbering into the
|
||||
* model which will open pandoras pox during PDF generation (and potentially web view)
|
||||
* @param _
|
||||
* @param args
|
||||
*/
|
||||
private pastePreprocess(_: any, args: any): void {
|
||||
const getClassesRe: RegExp = new RegExp(/\s*class\=\"[\w\W]*?\"/, 'gi');
|
||||
const getDataLineNumberRe: RegExp = new RegExp(/\s*data-line-number\=\"\d+\"/, 'gi');
|
||||
const getContentEditableRe: RegExp = new RegExp(/\s*contenteditable\=\"\w+\"/, 'gi');
|
||||
const cleanedContent = (args.content as string)
|
||||
.replace(getClassesRe, '')
|
||||
.replace(getDataLineNumberRe, '')
|
||||
.replace(getContentEditableRe, '');
|
||||
args.content = cleanedContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the title in web browser using angulars TitleService
|
||||
* @param prefix The title prefix. Should be translated here.
|
||||
|
@ -103,6 +136,5 @@ export abstract class BaseComponent {
|
|||
protected onLeaveTinyMce(event: any): void {
|
||||
console.log('tinyevent:', event.event.type);
|
||||
this.saveHint = false;
|
||||
// console.log("event: ", event.event.type);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ import { Injectable, Injector } from '@angular/core';
|
|||
import { AgendaAppConfig } from '../../site/agenda/agenda.config';
|
||||
import { AppConfig, ModelEntry, SearchableModelEntry } from '../definitions/app-config';
|
||||
import { BaseRepository } from 'app/core/repositories/base-repository';
|
||||
import { ChatAppConfig } from 'app/site/chat/chat.config';
|
||||
import { CinemaAppConfig } from 'app/site/cinema/cinema.config';
|
||||
import { HistoryAppConfig } from 'app/site/history/history.config';
|
||||
import { ProjectorAppConfig } from 'app/site/projector/projector.config';
|
||||
import { TopicsAppConfig } from 'app/site/topics/topics.config';
|
||||
|
@ -35,7 +37,9 @@ const appConfigs: AppConfig[] = [
|
|||
UsersAppConfig,
|
||||
HistoryAppConfig,
|
||||
ProjectorAppConfig,
|
||||
TopicsAppConfig
|
||||
TopicsAppConfig,
|
||||
CinemaAppConfig,
|
||||
ChatAppConfig
|
||||
];
|
||||
|
||||
/**
|
||||
|
@ -68,15 +72,10 @@ export class AppLoadService {
|
|||
let repository: BaseRepository<any, any, any> = null;
|
||||
repository = this.injector.get(entry.repository);
|
||||
repositories.push(repository);
|
||||
this.modelMapper.registerCollectionElement(
|
||||
entry.collectionString,
|
||||
entry.model,
|
||||
entry.viewModel,
|
||||
repository
|
||||
);
|
||||
this.modelMapper.registerCollectionElement(entry.model, entry.viewModel, repository);
|
||||
if (this.isSearchableModelEntry(entry)) {
|
||||
this.searchService.registerModel(
|
||||
entry.collectionString,
|
||||
entry.model.COLLECTIONSTRING,
|
||||
repository,
|
||||
entry.searchOrder,
|
||||
entry.openInNewTab
|
||||
|
@ -104,11 +103,11 @@ export class AppLoadService {
|
|||
private isSearchableModelEntry(entry: ModelEntry | SearchableModelEntry): entry is SearchableModelEntry {
|
||||
if ((<SearchableModelEntry>entry).searchOrder !== undefined) {
|
||||
// We need to double check, because Typescipt cannot check contructors. If typescript could differentiate
|
||||
// between (ModelConstructor<BaseModel>) and (new (...args: any[]) => (BaseModel & Searchable)), we would not have
|
||||
// to check if the result of the contructor (the model instance) is really a searchable.
|
||||
// between (ModelConstructor<BaseModel>) and (new (...args: any[]) => (BaseModel & Searchable)),
|
||||
// we would not have to check if the result of the contructor (the model instance) is really a searchable.
|
||||
if (!isSearchable(new entry.viewModel())) {
|
||||
throw Error(
|
||||
`Wrong configuration for ${entry.collectionString}: you gave a searchOrder, but the model is not searchable.`
|
||||
`Wrong configuration for ${entry.model.COLLECTIONSTRING}: you gave a searchOrder, but the model is not searchable.`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
|
|
|
@ -3,7 +3,7 @@ import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, Router } from '@
|
|||
|
||||
import { FallbackRoutesService } from './fallback-routes.service';
|
||||
import { OpenSlidesService } from './openslides.service';
|
||||
import { OperatorService } from './operator.service';
|
||||
import { OperatorService, Permission } from './operator.service';
|
||||
|
||||
/**
|
||||
* Classical Auth-Guard. Checks if the user has to correct permissions to enter a page, and forwards to login if not.
|
||||
|
@ -36,7 +36,7 @@ export class AuthGuard implements CanActivate, CanActivateChild {
|
|||
* @param route the route the user wants to navigate to
|
||||
*/
|
||||
public canActivate(route: ActivatedRouteSnapshot): boolean {
|
||||
const basePerm: string | string[] = route.data.basePerm;
|
||||
const basePerm: Permission | Permission[] = route.data.basePerm;
|
||||
|
||||
if (!basePerm) {
|
||||
return true;
|
||||
|
|
|
@ -8,6 +8,13 @@ import { DEFAULT_AUTH_TYPE, UserAuthType } from 'app/shared/models/users/user';
|
|||
import { DataStoreService } from './data-store.service';
|
||||
import { HttpService } from './http.service';
|
||||
import { OpenSlidesService } from './openslides.service';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
interface LoginData {
|
||||
username: string;
|
||||
password: string;
|
||||
cookies?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates an OpenSlides user with username and password
|
||||
|
@ -29,7 +36,8 @@ export class AuthService {
|
|||
private operator: OperatorService,
|
||||
private OpenSlides: OpenSlidesService,
|
||||
private router: Router,
|
||||
private DS: DataStoreService
|
||||
private DS: DataStoreService,
|
||||
private storageService: StorageService
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
@ -45,17 +53,23 @@ export class AuthService {
|
|||
earlySuccessCallback: () => void
|
||||
): Promise<void> {
|
||||
if (authType === 'default') {
|
||||
const user = {
|
||||
const data: LoginData = {
|
||||
username: username,
|
||||
password: password
|
||||
};
|
||||
const response = await this.http.post<WhoAmI>(environment.urlPrefix + '/users/login/', user);
|
||||
if (!navigator.cookieEnabled) {
|
||||
data.cookies = false;
|
||||
}
|
||||
const response = await this.http.post<WhoAmI>(environment.urlPrefix + '/users/login/', data);
|
||||
earlySuccessCallback();
|
||||
await this.OpenSlides.shutdown();
|
||||
await this.operator.setWhoAmI(response);
|
||||
await this.OpenSlides.afterLoginBootup(response.user_id);
|
||||
await this.redirectUser(response.user_id);
|
||||
} else if (authType === 'saml') {
|
||||
await this.operator.clearWhoAmIFromStorage(); // This is important:
|
||||
// Then returning to the page, we do not want to have anything cached so a
|
||||
// fresh whoami is executed.
|
||||
window.location.href = environment.urlPrefix + '/saml/?sso'; // Bye
|
||||
} else {
|
||||
throw new Error(`Unsupported auth type "${authType}"`);
|
||||
|
@ -67,7 +81,7 @@ export class AuthService {
|
|||
* if it wasn't done before.
|
||||
*/
|
||||
public async redirectUser(userId: number): Promise<void> {
|
||||
if (!this.OpenSlides.booted) {
|
||||
if (!this.OpenSlides.isBooted) {
|
||||
await this.OpenSlides.afterLoginBootup(userId);
|
||||
}
|
||||
|
||||
|
@ -103,10 +117,12 @@ export class AuthService {
|
|||
// We do nothing on failures. Reboot OpenSlides anyway.
|
||||
}
|
||||
this.router.navigate(['/']);
|
||||
await this.storageService.clear();
|
||||
await this.DS.clear();
|
||||
await this.operator.setWhoAmI(response);
|
||||
await this.OpenSlides.reboot();
|
||||
} else if (authType === 'saml') {
|
||||
await this.storageService.clear();
|
||||
await this.DS.clear();
|
||||
await this.operator.setWhoAmI(null);
|
||||
window.location.href = environment.urlPrefix + '/saml/?slo'; // Bye
|
||||
|
|
|
@ -0,0 +1,177 @@
|
|||
import { EventEmitter, Injectable } from '@angular/core';
|
||||
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
|
||||
import { AutoupdateFormat } from 'app/core/definitions/autoupdate-format';
|
||||
import { trailingThrottleTime } from 'app/core/rxjs/trailing-throttle-time';
|
||||
import { Identifiable } from 'app/shared/models/base/identifiable';
|
||||
import { ConstantsService } from './constants.service';
|
||||
import { HttpService } from './http.service';
|
||||
|
||||
interface ThrottleSettings {
|
||||
AUTOUPDATE_DELAY?: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AutoupdateThrottleService {
|
||||
private readonly _autoupdatesToInject = new Subject<AutoupdateFormat>();
|
||||
|
||||
public get autoupdatesToInject(): Observable<AutoupdateFormat> {
|
||||
return this._autoupdatesToInject.asObservable();
|
||||
}
|
||||
|
||||
private readonly receivedAutoupdate = new EventEmitter<void>();
|
||||
|
||||
private pendingAutoupdates = [];
|
||||
|
||||
private disabledUntil: number | null = null;
|
||||
|
||||
private delay = 0;
|
||||
|
||||
private maxSeenChangeId = 0;
|
||||
|
||||
private get isActive(): boolean {
|
||||
return this.delay !== 0;
|
||||
}
|
||||
|
||||
public constructor(private constantsService: ConstantsService, private httpService: HttpService) {
|
||||
this.constantsService.get<ThrottleSettings>('Settings').subscribe(settings => {
|
||||
// This is a one-shot. If the delay was set one time >0, it cannot be changed afterwards.
|
||||
// A change is more complicated since you have to unsubscribe, clean pending autoupdates,
|
||||
// subscribe again and make sure, that no autoupdate is missed.
|
||||
if (this.delay === 0 && settings.AUTOUPDATE_DELAY) {
|
||||
this.delay = 1000 * settings.AUTOUPDATE_DELAY;
|
||||
console.log(`Configured autoupdate delay: ${this.delay}ms`);
|
||||
this.receivedAutoupdate
|
||||
.pipe(trailingThrottleTime(this.delay))
|
||||
.subscribe(() => this.processPendingAutoupdates());
|
||||
} else if (this.delay === 0) {
|
||||
console.log('No autoupdate delay');
|
||||
}
|
||||
});
|
||||
|
||||
this.httpService.responseChangeIds.subscribe(changeId => this.disableUntil(changeId));
|
||||
}
|
||||
|
||||
public newAutoupdate(autoupdate: AutoupdateFormat): void {
|
||||
if (autoupdate.to_change_id > this.maxSeenChangeId) {
|
||||
this.maxSeenChangeId = autoupdate.to_change_id;
|
||||
}
|
||||
|
||||
if (!this.isActive) {
|
||||
this._autoupdatesToInject.next(autoupdate);
|
||||
} else if (this.disabledUntil !== null) {
|
||||
this._autoupdatesToInject.next(autoupdate);
|
||||
if (autoupdate.to_change_id >= this.disabledUntil) {
|
||||
this.disabledUntil = null;
|
||||
console.log('Throttling autoupdates again');
|
||||
}
|
||||
} else if (autoupdate.all_data) {
|
||||
// all_data=true (aka initial data) should be processed immediatly
|
||||
// but since there can be pending autoupdates, add it there and
|
||||
// process them now!
|
||||
this.pendingAutoupdates.push(autoupdate);
|
||||
this.processPendingAutoupdates();
|
||||
} else {
|
||||
this.pendingAutoupdates.push(autoupdate);
|
||||
this.receivedAutoupdate.emit();
|
||||
}
|
||||
}
|
||||
|
||||
public disableUntil(changeId: number): void {
|
||||
// Wait for an autoupdate with to_id >= changeId.
|
||||
if (!this.isActive) {
|
||||
return;
|
||||
}
|
||||
this.processPendingAutoupdates();
|
||||
// Checking with maxSeenChangeId is for the following race condition:
|
||||
// If the autoupdate comes before the response, it must not be throttled.
|
||||
// But flushing pending autoupdates is important since *if* the autoupdate
|
||||
// was early, it is in the pending queue.
|
||||
if (changeId <= this.maxSeenChangeId) {
|
||||
return;
|
||||
}
|
||||
console.log('Disable autoupdate until change id', changeId);
|
||||
this.disabledUntil = changeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* discard all pending autoupdates and resets the timer
|
||||
*/
|
||||
public discard(): void {
|
||||
this.pendingAutoupdates = [];
|
||||
}
|
||||
|
||||
private processPendingAutoupdates(): void {
|
||||
if (this.pendingAutoupdates.length === 0) {
|
||||
return;
|
||||
}
|
||||
const autoupdates = this.pendingAutoupdates;
|
||||
this.discard();
|
||||
|
||||
console.log(`Processing ${autoupdates.length} pending autoupdates`);
|
||||
const autoupdate = this.mergeAutoupdates(autoupdates);
|
||||
this._autoupdatesToInject.next(autoupdate);
|
||||
}
|
||||
|
||||
private mergeAutoupdates(autoupdates: AutoupdateFormat[]): AutoupdateFormat {
|
||||
const mergedAutoupdate: AutoupdateFormat = {
|
||||
changed: {},
|
||||
deleted: {},
|
||||
from_change_id: autoupdates[0].from_change_id,
|
||||
to_change_id: autoupdates[autoupdates.length - 1].to_change_id,
|
||||
all_data: false
|
||||
};
|
||||
|
||||
let lastToChangeId = null;
|
||||
for (const au of autoupdates) {
|
||||
if (lastToChangeId === null) {
|
||||
lastToChangeId = au.to_change_id;
|
||||
} else {
|
||||
if (au.from_change_id !== lastToChangeId) {
|
||||
console.warn('!!!', autoupdates, au);
|
||||
}
|
||||
lastToChangeId = au.to_change_id;
|
||||
}
|
||||
|
||||
this.applyAutoupdate(au, mergedAutoupdate);
|
||||
}
|
||||
|
||||
return mergedAutoupdate;
|
||||
}
|
||||
|
||||
private applyAutoupdate(from: AutoupdateFormat, into: AutoupdateFormat): void {
|
||||
if (from.all_data) {
|
||||
into.all_data = true;
|
||||
into.changed = from.changed;
|
||||
into.deleted = from.deleted;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const collection of Object.keys(from.deleted)) {
|
||||
for (const id of from.deleted[collection]) {
|
||||
if (into.changed[collection]) {
|
||||
into.changed[collection] = into.changed[collection].filter(obj => (obj as Identifiable).id !== id);
|
||||
}
|
||||
if (!into.deleted[collection]) {
|
||||
into.deleted[collection] = [];
|
||||
}
|
||||
into.deleted[collection].push(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const collection of Object.keys(from.changed)) {
|
||||
for (const obj of from.changed[collection]) {
|
||||
if (into.deleted[collection]) {
|
||||
into.deleted[collection] = into.deleted[collection].filter(id => id !== (obj as Identifiable).id);
|
||||
}
|
||||
if (!into.changed[collection]) {
|
||||
into.changed[collection] = [];
|
||||
}
|
||||
into.changed[collection].push(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,43 +1,16 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { AutoupdateFormat } from '../definitions/autoupdate-format';
|
||||
import { AutoupdateThrottleService } from './autoupdate-throttle.service';
|
||||
import { BaseModel } from '../../shared/models/base/base-model';
|
||||
import { CollectionStringMapperService } from './collection-string-mapper.service';
|
||||
import { CommunicationManagerService, OfflineError } from './communication-manager.service';
|
||||
import { DataStoreService, DataStoreUpdateManagerService } from './data-store.service';
|
||||
import { WEBSOCKET_ERROR_CODES, WebsocketService } from './websocket.service';
|
||||
|
||||
interface AutoupdateFormat {
|
||||
/**
|
||||
* All changed (and created) items as their full/restricted data grouped by their collection.
|
||||
*/
|
||||
changed: {
|
||||
[collectionString: string]: object[];
|
||||
};
|
||||
|
||||
/**
|
||||
* All deleted items (by id) grouped by their collection.
|
||||
*/
|
||||
deleted: {
|
||||
[collectionString: string]: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The lower change id bond for this autoupdate
|
||||
*/
|
||||
from_change_id: number;
|
||||
|
||||
/**
|
||||
* The upper change id bound for this autoupdate
|
||||
*/
|
||||
to_change_id: number;
|
||||
|
||||
/**
|
||||
* Flag, if this autoupdate contains all data. If so, the DS needs to be resetted.
|
||||
*/
|
||||
all_data: boolean;
|
||||
}
|
||||
import { HttpService } from './http.service';
|
||||
import { Mutex } from '../promises/mutex';
|
||||
|
||||
/**
|
||||
* Handles the initial update and automatic updates using the {@link WebsocketService}
|
||||
* Handles the initial update and automatic updates
|
||||
* Incoming objects, usually BaseModels, will be saved in the dataStore (`this.DS`)
|
||||
* This service usually creates all models
|
||||
*/
|
||||
|
@ -45,32 +18,52 @@ interface AutoupdateFormat {
|
|||
providedIn: 'root'
|
||||
})
|
||||
export class AutoupdateService {
|
||||
/**
|
||||
* Constructor to create the AutoupdateService. Calls the constructor of the parent class.
|
||||
* @param websocketService
|
||||
* @param DS
|
||||
* @param modelMapper
|
||||
*/
|
||||
private mutex = new Mutex();
|
||||
|
||||
private streamCloseFn: () => void | null = null;
|
||||
|
||||
private lastMessageContainedAllData = false;
|
||||
|
||||
public constructor(
|
||||
private websocketService: WebsocketService,
|
||||
private DS: DataStoreService,
|
||||
private modelMapper: CollectionStringMapperService,
|
||||
private DSUpdateManager: DataStoreUpdateManagerService
|
||||
private DSUpdateManager: DataStoreUpdateManagerService,
|
||||
private communicationManager: CommunicationManagerService,
|
||||
private autoupdateThrottle: AutoupdateThrottleService
|
||||
) {
|
||||
this.websocketService.getOberservable<AutoupdateFormat>('autoupdate').subscribe(response => {
|
||||
this.storeResponse(response);
|
||||
});
|
||||
this.communicationManager.startCommunicationEvent.subscribe(() => this.startAutoupdate());
|
||||
|
||||
// Check for too high change id-errors. If this happens, reset the DS and get fresh data.
|
||||
this.websocketService.errorResponseObservable.subscribe(error => {
|
||||
if (error.code === WEBSOCKET_ERROR_CODES.CHANGE_ID_TOO_HIGH) {
|
||||
this.doFullUpdate();
|
||||
this.autoupdateThrottle.autoupdatesToInject.subscribe(autoupdate => this.storeAutoupdate(autoupdate));
|
||||
}
|
||||
});
|
||||
|
||||
public async startAutoupdate(changeId?: number): Promise<void> {
|
||||
this.stopAutoupdate();
|
||||
|
||||
try {
|
||||
this.streamCloseFn = await this.communicationManager.subscribe<AutoupdateFormat>(
|
||||
'/system/autoupdate',
|
||||
autoupdate => {
|
||||
this.autoupdateThrottle.newAutoupdate(autoupdate);
|
||||
},
|
||||
() => ({ change_id: (changeId ? changeId : this.DS.maxChangeId).toString() })
|
||||
);
|
||||
} catch (e) {
|
||||
if (!(e instanceof OfflineError)) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public stopAutoupdate(): void {
|
||||
if (this.streamCloseFn) {
|
||||
this.streamCloseFn();
|
||||
this.streamCloseFn = null;
|
||||
}
|
||||
this.autoupdateThrottle.discard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the answer of incoming data via {@link WebsocketService}.
|
||||
* Handle the answer of incoming data, after it was throttled.
|
||||
*
|
||||
* Detects the Class of an incomming model, creates a new empty object and assigns
|
||||
* the data to it using the deserialize function. Also models that are flagged as deleted
|
||||
|
@ -78,16 +71,19 @@ export class AutoupdateService {
|
|||
*
|
||||
* Handles the change ids of all autoupdates.
|
||||
*/
|
||||
private async storeResponse(autoupdate: AutoupdateFormat): Promise<void> {
|
||||
private async storeAutoupdate(autoupdate: AutoupdateFormat): Promise<void> {
|
||||
const unlock = await this.mutex.lock();
|
||||
this.lastMessageContainedAllData = autoupdate.all_data;
|
||||
if (autoupdate.all_data) {
|
||||
await this.storeAllData(autoupdate);
|
||||
} else {
|
||||
await this.storePartialAutoupdate(autoupdate);
|
||||
}
|
||||
unlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores all data from the autoupdate. This means, that the DS is resettet and filled with just the
|
||||
* Stores all data from the autoupdate. This means, that the DS is resetted and filled with just the
|
||||
* given data from the autoupdate.
|
||||
* @param autoupdate The autoupdate
|
||||
*/
|
||||
|
@ -116,6 +112,15 @@ export class AutoupdateService {
|
|||
|
||||
// Normal autoupdate
|
||||
if (autoupdate.from_change_id <= maxChangeId + 1 && autoupdate.to_change_id > maxChangeId) {
|
||||
await this.injectAutupdateIntoDS(autoupdate, true);
|
||||
} else {
|
||||
// autoupdate fully in the future. we are missing something!
|
||||
console.log('Autoupdate in the future', maxChangeId, autoupdate.from_change_id, autoupdate.to_change_id);
|
||||
this.startAutoupdate(); // restarts it.
|
||||
}
|
||||
}
|
||||
|
||||
private async injectAutupdateIntoDS(autoupdate: AutoupdateFormat, flush: boolean): Promise<void> {
|
||||
const updateSlot = await this.DSUpdateManager.getNewUpdateSlot(this.DS);
|
||||
|
||||
// Delete the removed objects from the DataStore
|
||||
|
@ -128,13 +133,11 @@ export class AutoupdateService {
|
|||
await this.DS.add(this.mapObjectsToBaseModels(collection, autoupdate.changed[collection]));
|
||||
}
|
||||
|
||||
if (flush) {
|
||||
await this.DS.flushToStorage(autoupdate.to_change_id);
|
||||
}
|
||||
|
||||
this.DSUpdateManager.commit(updateSlot, autoupdate.to_change_id);
|
||||
} else {
|
||||
// autoupdate fully in the future. we are missing something!
|
||||
this.requestChanges();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -155,36 +158,21 @@ export class AutoupdateService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a WebSocket request to the Server with the maxChangeId of the DataStore.
|
||||
* The server should return an autoupdate with all new data.
|
||||
*/
|
||||
public requestChanges(): void {
|
||||
const changeId = this.DS.maxChangeId === 0 ? 0 : this.DS.maxChangeId + 1;
|
||||
console.log(`requesting changed objects with DS max change id ${changeId}`);
|
||||
this.websocketService.send('getElements', { change_id: changeId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a full update: Requests all data from the server and sets the DS to the fresh data.
|
||||
*/
|
||||
public async doFullUpdate(): Promise<void> {
|
||||
const oldChangeId = this.DS.maxChangeId;
|
||||
const response = await this.websocketService.sendAndGetResponse<{}, AutoupdateFormat>('getElements', {});
|
||||
|
||||
const updateSlot = await this.DSUpdateManager.getNewUpdateSlot(this.DS);
|
||||
let allModels: BaseModel[] = [];
|
||||
for (const collection of Object.keys(response.changed)) {
|
||||
if (this.modelMapper.isCollectionRegistered(collection)) {
|
||||
allModels = allModels.concat(this.mapObjectsToBaseModels(collection, response.changed[collection]));
|
||||
if (this.lastMessageContainedAllData) {
|
||||
console.log('full update requested. Skipping, last message already contained all data');
|
||||
} else {
|
||||
console.error(`Unregistered collection "${collection}". Ignore it.`);
|
||||
console.log('requesting full update.');
|
||||
// The mutex is needed, so the DS is not cleared, if there is
|
||||
// another autoupdate running.
|
||||
const unlock = await this.mutex.lock();
|
||||
this.stopAutoupdate();
|
||||
await this.DS.clear();
|
||||
this.startAutoupdate();
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
await this.DS.set(allModels, response.to_change_id);
|
||||
this.DSUpdateManager.commit(updateSlot, response.to_change_id, true);
|
||||
|
||||
console.log(`Full update done from ${oldChangeId} to ${response.to_change_id}`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,12 +47,11 @@ export class CollectionStringMapperService {
|
|||
* @param model
|
||||
*/
|
||||
public registerCollectionElement<V extends BaseViewModel<M>, M extends BaseModel>(
|
||||
collectionString: string,
|
||||
model: ModelConstructor<M>,
|
||||
viewModel: ViewModelConstructor<V>,
|
||||
repository: BaseRepository<V, M, TitleInformation>
|
||||
): void {
|
||||
this.collectionStringMapping[collectionString] = [model, viewModel, repository];
|
||||
this.collectionStringMapping[model.COLLECTIONSTRING] = [model, viewModel, repository];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,191 @@
|
|||
import { HttpParams } from '@angular/common/http';
|
||||
import { EventEmitter, Injectable } from '@angular/core';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { HttpService } from './http.service';
|
||||
import { OfflineBroadcastService, OfflineReason } from './offline-broadcast.service';
|
||||
import { OperatorService } from './operator.service';
|
||||
import { SleepPromise } from '../promises/sleep';
|
||||
import {
|
||||
CommunicationError,
|
||||
ErrorType,
|
||||
Stream,
|
||||
StreamContainer,
|
||||
StreamingCommunicationService,
|
||||
verboseErrorType
|
||||
} from './streaming-communication.service';
|
||||
|
||||
type HttpParamsGetter = () => HttpParams | { [param: string]: string | string[] };
|
||||
|
||||
const MAX_STREAM_FAILURE_RETRIES = 3;
|
||||
|
||||
export class OfflineError extends Error {
|
||||
public constructor() {
|
||||
super('');
|
||||
this.name = 'OfflineError';
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamConnectionWrapper {
|
||||
id: number;
|
||||
url: string;
|
||||
messageHandler: (message: any) => void;
|
||||
params: HttpParamsGetter;
|
||||
stream?: Stream<any>;
|
||||
hasErroredAmount: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CommunicationManagerService {
|
||||
private communicationAllowed = false;
|
||||
|
||||
private readonly _startCommunicationEvent = new EventEmitter<void>();
|
||||
|
||||
public get startCommunicationEvent(): Observable<void> {
|
||||
return this._startCommunicationEvent.asObservable();
|
||||
}
|
||||
|
||||
private readonly _stopCommunicationEvent = new EventEmitter<void>();
|
||||
|
||||
public get stopCommunicationEvent(): Observable<void> {
|
||||
return this._stopCommunicationEvent.asObservable();
|
||||
}
|
||||
|
||||
private streamContainers: { [id: number]: StreamContainer<any> } = {};
|
||||
|
||||
public constructor(
|
||||
private streamingCommunicationService: StreamingCommunicationService,
|
||||
private offlineBroadcastService: OfflineBroadcastService,
|
||||
private http: HttpService,
|
||||
private operatorService: OperatorService
|
||||
) {
|
||||
this.offlineBroadcastService.goOfflineObservable.subscribe(() => this.closeConnections());
|
||||
}
|
||||
|
||||
public async subscribe<T>(
|
||||
url: string,
|
||||
messageHandler: (message: T) => void,
|
||||
params?: HttpParamsGetter
|
||||
): Promise<() => void> {
|
||||
if (!params) {
|
||||
params = () => null;
|
||||
}
|
||||
|
||||
const streamContainer = new StreamContainer(url, messageHandler, params);
|
||||
return await this.connectWithWrapper(streamContainer);
|
||||
}
|
||||
|
||||
public startCommunication(): void {
|
||||
if (this.communicationAllowed) {
|
||||
console.error('Illegal state! Do not emit this event multiple times');
|
||||
} else {
|
||||
this.communicationAllowed = true;
|
||||
this._startCommunicationEvent.emit();
|
||||
}
|
||||
}
|
||||
|
||||
private async connectWithWrapper<T>(streamContainer: StreamContainer<T>): Promise<() => void> {
|
||||
console.log('connect', streamContainer, streamContainer.stream);
|
||||
const errorHandler = (type: ErrorType, error: CommunicationError, message: string) =>
|
||||
this.handleError(streamContainer, type, error, message);
|
||||
this.streamingCommunicationService.subscribe(streamContainer, errorHandler);
|
||||
this.streamContainers[streamContainer.id] = streamContainer;
|
||||
return () => this.close(streamContainer);
|
||||
}
|
||||
|
||||
private async handleError<T>(
|
||||
streamContainer: StreamContainer<T>,
|
||||
type: ErrorType,
|
||||
error: CommunicationError,
|
||||
message: string
|
||||
): Promise<void> {
|
||||
console.log('handle Error', streamContainer, streamContainer.stream, verboseErrorType(type), error, message);
|
||||
streamContainer.stream.close();
|
||||
streamContainer.stream = null;
|
||||
|
||||
streamContainer.hasErroredAmount++;
|
||||
if (streamContainer.hasErroredAmount > MAX_STREAM_FAILURE_RETRIES) {
|
||||
this.goOffline(streamContainer, OfflineReason.ConnectionLost);
|
||||
} else if (type === ErrorType.Client && error.type === 'auth_required') {
|
||||
this.goOffline(streamContainer, OfflineReason.WhoAmIFailed);
|
||||
} else {
|
||||
// retry it after some time:
|
||||
console.log(
|
||||
`Retry no. ${streamContainer.hasErroredAmount} of ${MAX_STREAM_FAILURE_RETRIES} for ${streamContainer.url}`
|
||||
);
|
||||
try {
|
||||
await this.delayAndCheckReconnection(streamContainer);
|
||||
await this.connectWithWrapper(streamContainer);
|
||||
} catch (e) {
|
||||
// delayAndCheckReconnection can throw an OfflineError,
|
||||
// which are just an 'abord mission' signal. Here, those errors can be ignored.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async delayAndCheckReconnection<T>(streamContainer: StreamContainer<T>): Promise<void> {
|
||||
let delay;
|
||||
if (streamContainer.hasErroredAmount === 1) {
|
||||
delay = 500; // the first error has a small delay since these error can happen normally.
|
||||
} else {
|
||||
delay = Math.floor(Math.random() * 3000 + 2000);
|
||||
}
|
||||
console.log(`retry again in ${delay} ms`);
|
||||
|
||||
await SleepPromise(delay);
|
||||
|
||||
// do not continue, if we are offline!
|
||||
if (this.offlineBroadcastService.isOffline()) {
|
||||
console.log('we are offline?');
|
||||
throw new OfflineError();
|
||||
}
|
||||
|
||||
// do not continue, if we are offline!
|
||||
if (!this.shouldRetryConnecting()) {
|
||||
console.log('operator changed, do not rety');
|
||||
throw new OfflineError(); // TODO: This error is not really good....
|
||||
}
|
||||
}
|
||||
|
||||
public closeConnections(): void {
|
||||
for (const streamWrapper of Object.values(this.streamContainers)) {
|
||||
if (streamWrapper.stream) {
|
||||
streamWrapper.stream.close();
|
||||
}
|
||||
}
|
||||
this.streamContainers = {};
|
||||
this.communicationAllowed = false;
|
||||
this._stopCommunicationEvent.emit();
|
||||
}
|
||||
|
||||
private goOffline<T>(streamContainer: StreamContainer<T>, reason: OfflineReason): void {
|
||||
delete this.streamContainers[streamContainer.id];
|
||||
this.closeConnections(); // here we close the connections early.
|
||||
this.offlineBroadcastService.goOffline(reason);
|
||||
}
|
||||
|
||||
private close(streamConnectionWrapper: StreamConnectionWrapper): void {
|
||||
if (this.streamContainers[streamConnectionWrapper.id]) {
|
||||
this.streamContainers[streamConnectionWrapper.id].stream.close();
|
||||
delete this.streamContainers[streamConnectionWrapper.id];
|
||||
}
|
||||
}
|
||||
|
||||
// Checks the operator: If we do not have a valid user,
|
||||
// do not even try to connect again..
|
||||
private shouldRetryConnecting(): boolean {
|
||||
return this.operatorService.guestsEnabled || !!this.operatorService.user;
|
||||
}
|
||||
|
||||
public async isCommunicationServiceOnline(): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.http.get<{ healthy: boolean }>('/system/health');
|
||||
return !!response.healthy;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { environment } from 'environments/environment';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
import { WebsocketService } from './websocket.service';
|
||||
import { CommunicationManagerService } from './communication-manager.service';
|
||||
import { HttpService } from './http.service';
|
||||
|
||||
/**
|
||||
* constants have a key associated with the data.
|
||||
|
@ -36,24 +38,13 @@ export class ConstantsService {
|
|||
*/
|
||||
private subjects: { [key: string]: BehaviorSubject<any> } = {};
|
||||
|
||||
/**
|
||||
* @param websocketService
|
||||
*/
|
||||
public constructor(private websocketService: WebsocketService) {
|
||||
// The hook for recieving constants.
|
||||
websocketService.getOberservable<Constants>('constants').subscribe(constants => {
|
||||
this.constants = constants;
|
||||
public constructor(communicationManager: CommunicationManagerService, private http: HttpService) {
|
||||
communicationManager.startCommunicationEvent.subscribe(async () => {
|
||||
this.constants = await this.http.get<Constants>(environment.urlPrefix + '/core/constants/');
|
||||
Object.keys(this.subjects).forEach(key => {
|
||||
this.subjects[key].next(this.constants[key]);
|
||||
});
|
||||
});
|
||||
|
||||
// We can request constants, if the websocket connection opens.
|
||||
// On retries, the `refresh()` method is called by the OpenSlidesService, so
|
||||
// here we do not need to take care about this.
|
||||
websocketService.noRetryConnectEvent.subscribe(() => {
|
||||
this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -66,14 +57,4 @@ export class ConstantsService {
|
|||
}
|
||||
return this.subjects[key].asObservable().pipe(filter(x => !!x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshed the constants
|
||||
*/
|
||||
public refresh(): Promise<void> {
|
||||
if (!this.websocketService.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.websocketService.send('constants', {});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { Observable, Subject } from 'rxjs';
|
|||
import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model';
|
||||
import { CollectionStringMapperService } from './collection-string-mapper.service';
|
||||
import { Deferred } from '../promises/deferred';
|
||||
import { OpenSlidesStatusService } from './openslides-status.service';
|
||||
import { RelationCacheService } from './relation-cache.service';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
|
@ -160,7 +161,8 @@ interface JsonStorage {
|
|||
}
|
||||
|
||||
/**
|
||||
* TODO: Avoid circular dependencies between `DataStoreUpdateManagerService` and `DataStoreService` and split them into two files
|
||||
* TODO: Avoid circular dependencies between `DataStoreUpdateManagerService` and
|
||||
* `DataStoreService` and split them into two files
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
@ -247,8 +249,17 @@ export class DataStoreUpdateManagerService {
|
|||
|
||||
slot.DS.triggerModifiedObservable();
|
||||
|
||||
// serve next slot request
|
||||
this.serveNextSlot();
|
||||
}
|
||||
|
||||
public dropUpdateSlot(): void {
|
||||
this.currentUpdateSlot = null;
|
||||
this.serveNextSlot();
|
||||
}
|
||||
|
||||
private serveNextSlot(): void {
|
||||
if (this.updateSlotRequests.length > 0) {
|
||||
console.log('Concurrent update slots');
|
||||
const request = this.updateSlotRequests.pop();
|
||||
request.resolve();
|
||||
}
|
||||
|
@ -328,7 +339,8 @@ export class DataStoreService {
|
|||
public constructor(
|
||||
private storageService: StorageService,
|
||||
private modelMapper: CollectionStringMapperService,
|
||||
private DSUpdateManager: DataStoreUpdateManagerService
|
||||
private DSUpdateManager: DataStoreUpdateManagerService,
|
||||
private statusService: OpenSlidesStatusService
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
@ -347,14 +359,21 @@ export class DataStoreService {
|
|||
|
||||
/**
|
||||
* Gets the DataStore from cache and instantiate all models out of the serialized version.
|
||||
* If something fails, the DS is cleared, so fresh data can be requrested from the server.
|
||||
*
|
||||
* @returns The max change id.
|
||||
*/
|
||||
public async initFromStorage(): Promise<number> {
|
||||
public async initFromStorage(): Promise<void> {
|
||||
// This promise will be resolved with cached datastore.
|
||||
const store = await this.storageService.get<JsonStorage>(DataStoreService.cachePrefix + 'DS');
|
||||
if (store) {
|
||||
if (!store) {
|
||||
await this.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const updateSlot = await this.DSUpdateManager.getNewUpdateSlot(this);
|
||||
|
||||
try {
|
||||
// There is a store. Deserialize it
|
||||
this.jsonStore = store;
|
||||
this.modelStore = this.deserializeJsonStore(this.jsonStore);
|
||||
|
@ -374,10 +393,10 @@ export class DataStoreService {
|
|||
});
|
||||
|
||||
this.DSUpdateManager.commit(updateSlot, maxChangeId, true);
|
||||
} else {
|
||||
} catch (e) {
|
||||
this.DSUpdateManager.dropUpdateSlot();
|
||||
await this.clear();
|
||||
}
|
||||
return this.maxChangeId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -645,7 +664,21 @@ export class DataStoreService {
|
|||
*/
|
||||
public async flushToStorage(changeId: number): Promise<void> {
|
||||
this._maxChangeId = changeId;
|
||||
try {
|
||||
await this.storageService.set(DataStoreService.cachePrefix + 'DS', this.jsonStore);
|
||||
await this.storageService.set(DataStoreService.cachePrefix + 'maxChangeId', changeId);
|
||||
} catch (e) {
|
||||
if (e?.name === 'QuotaExceededError') {
|
||||
this.statusService.setTooLessLocalStorage();
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public print(): void {
|
||||
console.log('Max change id', this.maxChangeId);
|
||||
console.log(JSON.stringify(this.jsonStore));
|
||||
console.log(JSON.parse(JSON.stringify(this.modelStore)));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import { ErrorHandler, Injectable } from '@angular/core';
|
||||
|
||||
import { OpenSlidesStatusService } from './openslides-status.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ErrorService extends ErrorHandler {
|
||||
// TODO: This service cannot be injected into other services since it is constructed twice.
|
||||
public constructor(private statusService: OpenSlidesStatusService) {
|
||||
super();
|
||||
}
|
||||
|
||||
public handleError(error: any): void {
|
||||
const errorInformation = {
|
||||
error,
|
||||
name: this.guessName(error)
|
||||
};
|
||||
this.statusService.currentError.next(errorInformation);
|
||||
super.handleError(error);
|
||||
}
|
||||
|
||||
private guessName(error: any): string | null {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.rejection?.name) {
|
||||
return error.rejection.name;
|
||||
}
|
||||
|
||||
if (error.name) {
|
||||
return error.name;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { OperatorService } from './operator.service';
|
||||
import { OperatorService, Permission } from './operator.service';
|
||||
|
||||
export interface AuthGuardFallbackEntry {
|
||||
route: string;
|
||||
weight: number;
|
||||
permission: string;
|
||||
permission: Permission;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
|
||||
import { StableInterceptorService } from './stable-interceptor.service';
|
||||
|
||||
export const httpInterceptorProviders = [
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: StableInterceptorService, multi: true }
|
||||
];
|
|
@ -0,0 +1,36 @@
|
|||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { first, mergeMap } from 'rxjs/operators';
|
||||
|
||||
import { OpenSlidesStatusService } from '../openslides-status.service';
|
||||
|
||||
/**
|
||||
* An http interceptor to make sure, that every request is made
|
||||
* only when this application is stable.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class StableInterceptorService implements HttpInterceptor {
|
||||
private readonly stableSubject = new BehaviorSubject<boolean>(false);
|
||||
|
||||
public constructor(private openslidesStatus: OpenSlidesStatusService) {
|
||||
this.update();
|
||||
}
|
||||
|
||||
public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
return this.stableSubject.pipe(
|
||||
first(stable => stable),
|
||||
mergeMap(() => {
|
||||
return next.handle(req);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async update(): Promise<void> {
|
||||
await this.openslidesStatus.stable;
|
||||
this.stableSubject.next(true);
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ describe('HttpService', () => {
|
|||
});
|
||||
// TODO: Write a working Test
|
||||
// it('should be created', () => {
|
||||
// const service: HttpService = TestBed.get(HttpService);
|
||||
// const service: HttpService = TestBed.inject(HttpService);
|
||||
// expect(service).toBeTruthy();
|
||||
// });
|
||||
});
|
||||
|
|
|
@ -2,27 +2,20 @@ import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { AutoupdateFormat } from '../definitions/autoupdate-format';
|
||||
import { AutoupdateThrottleService } from './autoupdate-throttle.service';
|
||||
import { HTTPMethod } from '../definitions/http-methods';
|
||||
import { OpenSlidesStatusService } from './openslides-status.service';
|
||||
import { formatQueryParams, QueryParams } from '../definitions/query-params';
|
||||
|
||||
/**
|
||||
* Enum for different HTTPMethods
|
||||
*/
|
||||
export enum HTTPMethod {
|
||||
GET = 'get',
|
||||
POST = 'post',
|
||||
PUT = 'put',
|
||||
PATCH = 'patch',
|
||||
DELETE = 'delete'
|
||||
}
|
||||
|
||||
export interface DetailResponse {
|
||||
export interface ErrorDetailResponse {
|
||||
detail: string | string[];
|
||||
args?: string[];
|
||||
}
|
||||
|
||||
function isDetailResponse(obj: any): obj is DetailResponse {
|
||||
function isErrorDetailResponse(obj: any): obj is ErrorDetailResponse {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj === 'object' &&
|
||||
|
@ -31,6 +24,15 @@ function isDetailResponse(obj: any): obj is DetailResponse {
|
|||
);
|
||||
}
|
||||
|
||||
interface AutoupdateResponse {
|
||||
change_id: number;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
function isAutoupdateReponse(obj: any): obj is AutoupdateResponse {
|
||||
return obj && typeof obj === 'object' && typeof (obj as AutoupdateResponse).change_id === 'number';
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing HTTP requests. Allows to send data for every method. Also (TODO) will do generic error handling.
|
||||
*/
|
||||
|
@ -43,6 +45,8 @@ export class HttpService {
|
|||
*/
|
||||
private defaultHeaders: HttpHeaders;
|
||||
|
||||
public readonly responseChangeIds = new Subject<number>();
|
||||
|
||||
/**
|
||||
* Construct a HttpService
|
||||
*
|
||||
|
@ -82,7 +86,7 @@ export class HttpService {
|
|||
): Promise<T> {
|
||||
// end early, if we are in history mode
|
||||
if (this.OSStatus.isInHistoryMode && method !== HTTPMethod.GET) {
|
||||
throw this.handleError('You cannot make changes while in history mode');
|
||||
throw this.processError('You cannot make changes while in history mode');
|
||||
}
|
||||
|
||||
// there is a current bug with the responseType.
|
||||
|
@ -108,9 +112,10 @@ export class HttpService {
|
|||
};
|
||||
|
||||
try {
|
||||
return await this.http.request<T>(method, url, options).toPromise();
|
||||
} catch (e) {
|
||||
throw this.handleError(e);
|
||||
const responseData: T = await this.http.request<T>(method, url, options).toPromise();
|
||||
return this.processResponse(responseData);
|
||||
} catch (error) {
|
||||
throw this.processError(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -120,7 +125,7 @@ export class HttpService {
|
|||
* @param e The error thrown.
|
||||
* @returns The prepared and translated message for the user
|
||||
*/
|
||||
private handleError(e: any): string {
|
||||
private processError(e: any): string {
|
||||
let error = this.translate.instant('Error') + ': ';
|
||||
// If the error is a string already, return it.
|
||||
if (typeof e === 'string') {
|
||||
|
@ -142,15 +147,20 @@ export class HttpService {
|
|||
} else if (!e.error) {
|
||||
error += this.translate.instant("The server didn't respond.");
|
||||
} else if (typeof e.error === 'object') {
|
||||
if (isDetailResponse(e.error)) {
|
||||
error += this.processDetailResponse(e.error);
|
||||
if (isErrorDetailResponse(e.error)) {
|
||||
error += this.processErrorDetailResponse(e.error);
|
||||
} else {
|
||||
error = Object.keys(e.error)
|
||||
.map(key => {
|
||||
const errorList = Object.keys(e.error).map(key => {
|
||||
const capitalizedKey = key.charAt(0).toUpperCase() + key.slice(1);
|
||||
return this.translate.instant(capitalizedKey) + ': ' + this.processDetailResponse(e.error[key]);
|
||||
})
|
||||
.join(', ');
|
||||
let detail = e.error[key];
|
||||
if (detail instanceof Array) {
|
||||
detail = detail.join(' ');
|
||||
} else {
|
||||
detail = this.processErrorDetailResponse(detail);
|
||||
}
|
||||
return `${this.translate.instant(capitalizedKey)}: ${detail}`;
|
||||
});
|
||||
error = errorList.join(', ');
|
||||
}
|
||||
} else if (e.status === 500) {
|
||||
error += this.translate.instant('A server error occured. Please contact your system administrator.');
|
||||
|
@ -169,11 +179,9 @@ export class HttpService {
|
|||
* @param str a string or a string array to join together.
|
||||
* @returns Error text(s) as single string
|
||||
*/
|
||||
private processDetailResponse(response: DetailResponse): string {
|
||||
private processErrorDetailResponse(response: ErrorDetailResponse): string {
|
||||
let message: string;
|
||||
if (response instanceof Array) {
|
||||
message = response.join(' ');
|
||||
} else if (response.detail instanceof Array) {
|
||||
if (response.detail instanceof Array) {
|
||||
message = response.detail.join(' ');
|
||||
} else {
|
||||
message = response.detail;
|
||||
|
@ -188,6 +196,14 @@ export class HttpService {
|
|||
return message;
|
||||
}
|
||||
|
||||
private processResponse<T>(responseData: T): T {
|
||||
if (isAutoupdateReponse(responseData)) {
|
||||
this.responseChangeIds.next(responseData.change_id);
|
||||
return responseData.data;
|
||||
}
|
||||
return responseData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a get on a path with a certain object
|
||||
* @param path The path to send the request to.
|
||||
|
@ -254,4 +270,26 @@ export class HttpService {
|
|||
public async delete<T>(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise<T> {
|
||||
return await this.send<T>(path, HTTPMethod.DELETE, data, queryParams, header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a binary file from the url and returns a base64 value
|
||||
*
|
||||
* @param url file url
|
||||
* @returns a promise with a base64 string
|
||||
*/
|
||||
public async downloadAsBase64(url: string): Promise<string> {
|
||||
return new Promise<string>(async (resolve, reject) => {
|
||||
const headers = new HttpHeaders();
|
||||
const file = await this.get<Blob>(url, {}, {}, headers, 'blob');
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
const resultStr: string = reader.result as string;
|
||||
resolve(resultStr.split(',')[1]);
|
||||
};
|
||||
reader.onerror = error => {
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ import { Injectable } from '@angular/core';
|
|||
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { Permission } from './operator.service';
|
||||
|
||||
/**
|
||||
* This represents one entry in the main menu
|
||||
*/
|
||||
|
@ -28,7 +30,7 @@ export interface MainMenuEntry {
|
|||
/**
|
||||
* The permission to see the entry.
|
||||
*/
|
||||
permission: string;
|
||||
permission: Permission;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,8 +2,9 @@ import { Injectable } from '@angular/core';
|
|||
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
|
||||
import { CommunicationManagerService, OfflineError } from './communication-manager.service';
|
||||
import { HttpService } from './http.service';
|
||||
import { OperatorService } from './operator.service';
|
||||
import { WebsocketService } from './websocket.service';
|
||||
|
||||
/**
|
||||
* Encapslates the name and content of every message regardless of being a request or response.
|
||||
|
@ -17,7 +18,12 @@ interface NotifyBase<T> {
|
|||
/**
|
||||
* The content to send.
|
||||
*/
|
||||
content: T;
|
||||
message: T;
|
||||
}
|
||||
|
||||
function isNotifyBase(obj: object): obj is NotifyResponse<any> {
|
||||
const base = obj as NotifyBase<any>;
|
||||
return !!obj && base.message !== undefined && base.name !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -26,15 +32,18 @@ interface NotifyBase<T> {
|
|||
* channel names.
|
||||
*/
|
||||
export interface NotifyRequest<T> extends NotifyBase<T> {
|
||||
channel_id: string;
|
||||
to_all?: boolean;
|
||||
|
||||
/**
|
||||
* User ids (or `true` for all users) to send this message to.
|
||||
*/
|
||||
users?: number[] | boolean;
|
||||
to_users?: number[];
|
||||
|
||||
/**
|
||||
* An array of channels to send this message to.
|
||||
*/
|
||||
replyChannels?: string[];
|
||||
to_channels?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -45,12 +54,12 @@ export interface NotifyResponse<T> extends NotifyBase<T> {
|
|||
* This is the channel name of the one, who sends this message. Can be use to directly
|
||||
* answer this message.
|
||||
*/
|
||||
senderChannelName: string;
|
||||
sender_channel_id: string;
|
||||
|
||||
/**
|
||||
* The user id of the user who sends this message. It is 0 for Anonymous.
|
||||
*/
|
||||
senderUserId: number;
|
||||
sender_user_id: number;
|
||||
|
||||
/**
|
||||
* This is validated here and is true, if the senderUserId matches the current operator's id.
|
||||
|
@ -59,6 +68,20 @@ export interface NotifyResponse<T> extends NotifyBase<T> {
|
|||
sendByThisUser: boolean;
|
||||
}
|
||||
|
||||
function isNotifyResponse(obj: object): obj is NotifyResponse<any> {
|
||||
const response = obj as NotifyResponse<any>;
|
||||
// Note: we do not test for sendByThisUser, since it is set later in our code.
|
||||
return isNotifyBase(obj) && response.sender_channel_id !== undefined && response.sender_user_id !== undefined;
|
||||
}
|
||||
|
||||
interface ChannelIdResponse {
|
||||
channel_id: string;
|
||||
}
|
||||
|
||||
function isChannelIdResponse(obj: object): obj is ChannelIdResponse {
|
||||
return !!obj && (obj as ChannelIdResponse).channel_id !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles all incoming and outgoing notify messages via {@link WebsocketService}.
|
||||
*/
|
||||
|
@ -78,18 +101,41 @@ export class NotifyService {
|
|||
[name: string]: Subject<NotifyResponse<any>>;
|
||||
} = {};
|
||||
|
||||
/**
|
||||
* Constructor to create the NotifyService. Registers itself to the WebsocketService.
|
||||
* @param websocketService
|
||||
*/
|
||||
public constructor(private websocketService: WebsocketService, private operator: OperatorService) {
|
||||
websocketService.getOberservable<NotifyResponse<any>>('notify').subscribe(notify => {
|
||||
notify.sendByThisUser = notify.senderUserId === (this.operator.user ? this.operator.user.id : 0);
|
||||
private channelId: string;
|
||||
|
||||
public constructor(
|
||||
private communicationManager: CommunicationManagerService,
|
||||
private http: HttpService,
|
||||
private operator: OperatorService
|
||||
) {
|
||||
this.communicationManager.startCommunicationEvent.subscribe(() => this.startListening());
|
||||
this.communicationManager.stopCommunicationEvent.subscribe(() => (this.channelId = null));
|
||||
}
|
||||
|
||||
private async startListening(): Promise<void> {
|
||||
try {
|
||||
await this.communicationManager.subscribe<NotifyResponse<any> | ChannelIdResponse>(
|
||||
'/system/notify',
|
||||
notify => {
|
||||
if (isChannelIdResponse(notify)) {
|
||||
this.channelId = notify.channel_id;
|
||||
} else if (isNotifyResponse(notify)) {
|
||||
notify.sendByThisUser =
|
||||
notify.sender_user_id === (this.operator.user ? this.operator.user.id : 0);
|
||||
this.notifySubject.next(notify);
|
||||
if (this.messageSubjects[notify.name]) {
|
||||
this.messageSubjects[notify.name].next(notify);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('Unknwon notify message', notify);
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
if (!(e instanceof OfflineError)) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -97,8 +143,8 @@ export class NotifyService {
|
|||
* @param name The name of the notify message
|
||||
* @param content The payload to send
|
||||
*/
|
||||
public sendToAllUsers<T>(name: string, content: T): void {
|
||||
this.send(name, content);
|
||||
public async sendToAllUsers<T>(name: string, content: T): Promise<void> {
|
||||
await this.send(name, content, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -107,8 +153,11 @@ export class NotifyService {
|
|||
* @param content The payload to send.
|
||||
* @param users Multiple user ids.
|
||||
*/
|
||||
public sendToUsers<T>(name: string, content: T, ...users: number[]): void {
|
||||
this.send(name, content, users);
|
||||
public async sendToUsers<T>(name: string, content: T, ...users: number[]): Promise<void> {
|
||||
if (users.length < 1) {
|
||||
throw new Error('You have to provide at least one user');
|
||||
}
|
||||
await this.send(name, content, false, users);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -117,35 +166,48 @@ export class NotifyService {
|
|||
* @param content The payload to send.
|
||||
* @param channels Multiple channels to send this message to.
|
||||
*/
|
||||
public sendToChannels<T>(name: string, content: T, ...channels: string[]): void {
|
||||
public async sendToChannels<T>(name: string, content: T, ...channels: string[]): Promise<void> {
|
||||
if (channels.length < 1) {
|
||||
throw new Error('You have to provide at least one channel');
|
||||
}
|
||||
this.send(name, content, null, channels);
|
||||
await this.send(name, content, false, null, channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* General send function for notify messages.
|
||||
* @param name The name of the notify message
|
||||
* @param content The payload to send.
|
||||
* @param message The payload to send.
|
||||
* @param users Either an array of IDs or `true` meaning of sending this message to all online users clients.
|
||||
* @param channels An array of channels to send this message to.
|
||||
*/
|
||||
public send<T>(name: string, content: T, users?: number[] | boolean, channels?: string[]): void {
|
||||
private async send<T>(
|
||||
name: string,
|
||||
message: T,
|
||||
toAll?: boolean,
|
||||
users?: number[],
|
||||
channels?: string[]
|
||||
): Promise<void> {
|
||||
if (!this.channelId) {
|
||||
throw new Error('No channel id!');
|
||||
}
|
||||
|
||||
const notify: NotifyRequest<T> = {
|
||||
name: name,
|
||||
content: content
|
||||
message: message,
|
||||
channel_id: this.channelId
|
||||
};
|
||||
if (typeof users === 'boolean' && users !== true) {
|
||||
throw new Error('You just can give true as a boolean to send this message to all users.');
|
||||
if (toAll === true) {
|
||||
notify.to_all = true;
|
||||
}
|
||||
if (users !== null) {
|
||||
notify.users = users;
|
||||
if (users) {
|
||||
notify.to_users = users;
|
||||
}
|
||||
if (channels !== null) {
|
||||
notify.replyChannels = channels;
|
||||
if (channels) {
|
||||
notify.to_channels = channels;
|
||||
}
|
||||
this.websocketService.send('notify', notify);
|
||||
|
||||
console.debug('send notify', notify);
|
||||
await this.http.post<unknown>('/system/notify/send', notify);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
import { EventEmitter, Injectable } from '@angular/core';
|
||||
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
|
||||
export enum OfflineReason {
|
||||
WhoAmIFailed,
|
||||
ConnectionLost
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class OfflineBroadcastService {
|
||||
public readonly isOfflineSubject = new BehaviorSubject<boolean>(false);
|
||||
public get isOfflineObservable(): Observable<boolean> {
|
||||
return this.isOfflineSubject.asObservable();
|
||||
}
|
||||
|
||||
private readonly _goOffline = new EventEmitter<OfflineReason>();
|
||||
public get goOfflineObservable(): Observable<OfflineReason> {
|
||||
return this._goOffline.asObservable();
|
||||
}
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public goOffline(reason: OfflineReason): void {
|
||||
this._goOffline.emit(reason);
|
||||
}
|
||||
|
||||
public isOffline(): boolean {
|
||||
return this.isOfflineSubject.getValue();
|
||||
}
|
||||
|
||||
public isOnline(): boolean {
|
||||
return !this.isOffline();
|
||||
}
|
||||
}
|
|
@ -1,6 +1,9 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { CommunicationManagerService } from './communication-manager.service';
|
||||
import { OfflineBroadcastService, OfflineReason } from './offline-broadcast.service';
|
||||
import { OpenSlidesService } from './openslides.service';
|
||||
import { OperatorService, WhoAmI } from './operator.service';
|
||||
|
||||
/**
|
||||
* This service handles everything connected with being offline.
|
||||
|
@ -12,44 +15,111 @@ import { BehaviorSubject, Observable } from 'rxjs';
|
|||
providedIn: 'root'
|
||||
})
|
||||
export class OfflineService {
|
||||
/**
|
||||
* BehaviorSubject to receive further status values.
|
||||
*/
|
||||
private offline = new BehaviorSubject<boolean>(false);
|
||||
private reason: OfflineReason | null;
|
||||
|
||||
/**
|
||||
* Determines of you are either in Offline mode or not connected via websocket
|
||||
*
|
||||
* @returns whether the client is offline or not connected
|
||||
*/
|
||||
public isOffline(): Observable<boolean> {
|
||||
return this.offline;
|
||||
public constructor(
|
||||
private OpenSlides: OpenSlidesService,
|
||||
private offlineBroadcastService: OfflineBroadcastService,
|
||||
private operatorService: OperatorService,
|
||||
private communicationManager: CommunicationManagerService
|
||||
) {
|
||||
this.offlineBroadcastService.goOfflineObservable.subscribe((reason: OfflineReason) => this.goOffline(reason));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the offline flag. Restores the DataStoreService to the last known configuration.
|
||||
* Helper function to set offline status
|
||||
*/
|
||||
public goOfflineBecauseFailedWhoAmI(): void {
|
||||
if (!this.offline.getValue()) {
|
||||
console.log('offline because whoami failed.');
|
||||
}
|
||||
this.offline.next(true);
|
||||
public goOffline(reason: OfflineReason): void {
|
||||
if (this.offlineBroadcastService.isOffline()) {
|
||||
return;
|
||||
}
|
||||
this.reason = reason;
|
||||
|
||||
/**
|
||||
* Sets the offline flag, because there is no connection to the server.
|
||||
*/
|
||||
public goOfflineBecauseConnectionLost(): void {
|
||||
if (!this.offline.getValue()) {
|
||||
if (reason === OfflineReason.ConnectionLost) {
|
||||
console.log('offline because connection lost.');
|
||||
} else if (reason === OfflineReason.WhoAmIFailed) {
|
||||
console.log('offline because whoami failed.');
|
||||
} else {
|
||||
console.error('No such offline reason', reason);
|
||||
}
|
||||
this.offline.next(true);
|
||||
|
||||
this.offlineBroadcastService.isOfflineSubject.next(true);
|
||||
this.checkStillOffline();
|
||||
}
|
||||
|
||||
private checkStillOffline(): void {
|
||||
const timeout = Math.floor(Math.random() * 3000 + 2000);
|
||||
console.log(`Try to go online in ${timeout} ms`);
|
||||
|
||||
setTimeout(async () => {
|
||||
let online: boolean;
|
||||
let whoami: WhoAmI | null = null;
|
||||
|
||||
if (this.reason === OfflineReason.ConnectionLost) {
|
||||
online = await this.communicationManager.isCommunicationServiceOnline();
|
||||
console.log('is communication online? ', online);
|
||||
} else if (this.reason === OfflineReason.WhoAmIFailed) {
|
||||
const result = await this.operatorService.whoAmI();
|
||||
online = result.online;
|
||||
whoami = result.whoami;
|
||||
console.log('is whoami reachable?', online);
|
||||
}
|
||||
|
||||
if (online) {
|
||||
await this.goOnline(whoami);
|
||||
// TODO: check all other reasons -> e.g. if the
|
||||
// connection was lost, the operator must be checked and the other way
|
||||
// around the comminucation must be started!!
|
||||
|
||||
// stop trying.
|
||||
} else {
|
||||
// continue trying.
|
||||
this.checkStillOffline();
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to return to online-status.
|
||||
*
|
||||
* First, we have to check, if all other sources (except this.reason) are online, too.
|
||||
* This results in definetly having a whoami response at this point.
|
||||
* If this is the case, we need to setup everything again:
|
||||
* 1) check the operator. If this allowes for an logged in state (or anonymous is OK), do
|
||||
* step 2, otherwise done.
|
||||
* 2) enable communications.
|
||||
*/
|
||||
public goOnline(): void {
|
||||
this.offline.next(false);
|
||||
private async goOnline(whoami?: WhoAmI): Promise<void> {
|
||||
console.log('go online!', this.reason, whoami);
|
||||
if (this.reason === OfflineReason.ConnectionLost) {
|
||||
// now we have to check whoami
|
||||
const result = await this.operatorService.whoAmI();
|
||||
if (!result.online) {
|
||||
console.log('whoami down.');
|
||||
this.reason = OfflineReason.WhoAmIFailed;
|
||||
this.checkStillOffline();
|
||||
return;
|
||||
}
|
||||
whoami = result.whoami;
|
||||
} else if (this.reason === OfflineReason.WhoAmIFailed) {
|
||||
const online = await this.communicationManager.isCommunicationServiceOnline();
|
||||
if (!online) {
|
||||
console.log('communication down.');
|
||||
this.reason = OfflineReason.ConnectionLost;
|
||||
this.checkStillOffline();
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.log('we are online!');
|
||||
|
||||
// Ok, we are online now!
|
||||
const isLoggedIn = await this.OpenSlides.checkWhoAmI(whoami);
|
||||
console.log('logged in:', isLoggedIn);
|
||||
if (isLoggedIn) {
|
||||
this.communicationManager.startCommunication();
|
||||
}
|
||||
console.log('done');
|
||||
|
||||
this.offlineBroadcastService.isOfflineSubject.next(false);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,15 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { History } from 'app/shared/models/core/history';
|
||||
import { BannerDefinition, BannerService } from '../ui-services/banner.service';
|
||||
import { Deferred } from '../promises/deferred';
|
||||
|
||||
export interface ErrorInformation {
|
||||
error: any;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds information about OpenSlides. This is not included into other services to
|
||||
|
@ -14,6 +23,11 @@ export class OpenSlidesStatusService {
|
|||
* in History mode, saves the history point.
|
||||
*/
|
||||
private history: History = null;
|
||||
private historyBanner: BannerDefinition = {
|
||||
type: 'history'
|
||||
};
|
||||
|
||||
private tooLessLocalStorage = false;
|
||||
|
||||
/**
|
||||
* Returns, if OpenSlides is in the history mode.
|
||||
|
@ -22,12 +36,26 @@ export class OpenSlidesStatusService {
|
|||
return !!this.history;
|
||||
}
|
||||
|
||||
public get stable(): Promise<void> {
|
||||
return this._stable;
|
||||
}
|
||||
|
||||
public isPrioritizedClient = false;
|
||||
|
||||
public readonly currentError = new BehaviorSubject<ErrorInformation | null>(null);
|
||||
|
||||
private _stable = new Deferred();
|
||||
private _bootedSubject = new BehaviorSubject<boolean>(false);
|
||||
|
||||
/**
|
||||
* Ctor, does nothing.
|
||||
*/
|
||||
public constructor() {}
|
||||
public constructor(private banner: BannerService) {}
|
||||
|
||||
public setStable(): void {
|
||||
this._stable.resolve();
|
||||
this._bootedSubject.next(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the getLocaleString function of the history object, if present.
|
||||
|
@ -44,6 +72,7 @@ export class OpenSlidesStatusService {
|
|||
*/
|
||||
public enterHistoryMode(history: History): void {
|
||||
this.history = history;
|
||||
this.banner.addBanner(this.historyBanner);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -51,5 +80,13 @@ export class OpenSlidesStatusService {
|
|||
*/
|
||||
public leaveHistoryMode(): void {
|
||||
this.history = null;
|
||||
this.banner.removeBanner(this.historyBanner);
|
||||
}
|
||||
|
||||
public setTooLessLocalStorage(): void {
|
||||
if (!this.tooLessLocalStorage) {
|
||||
this.tooLessLocalStorage = true;
|
||||
this.banner.addBanner({ type: 'tooLessLocalStorage' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,12 +3,12 @@ import { Router } from '@angular/router';
|
|||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { AutoupdateService } from './autoupdate.service';
|
||||
import { ConstantsService } from './constants.service';
|
||||
import { CommunicationManagerService } from './communication-manager.service';
|
||||
import { DataStoreService } from './data-store.service';
|
||||
import { OperatorService } from './operator.service';
|
||||
import { OfflineBroadcastService, OfflineReason } from './offline-broadcast.service';
|
||||
import { OpenSlidesStatusService } from './openslides-status.service';
|
||||
import { OperatorService, WhoAmI } from './operator.service';
|
||||
import { StorageService } from './storage.service';
|
||||
import { WebsocketService } from './websocket.service';
|
||||
|
||||
/**
|
||||
* Handles the bootup/showdown of this application.
|
||||
|
@ -35,30 +35,15 @@ export class OpenSlidesService {
|
|||
return this.booted.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor to create the OpenSlidesService. Registers itself to the WebsocketService.
|
||||
* @param storageService
|
||||
* @param operator
|
||||
* @param websocketService
|
||||
* @param router
|
||||
* @param autoupdateService
|
||||
* @param DS
|
||||
*/
|
||||
public constructor(
|
||||
private storageService: StorageService,
|
||||
private operator: OperatorService,
|
||||
private websocketService: WebsocketService,
|
||||
private openslidesStatus: OpenSlidesStatusService,
|
||||
private router: Router,
|
||||
private autoupdateService: AutoupdateService,
|
||||
private DS: DataStoreService,
|
||||
private constantsService: ConstantsService
|
||||
private communicationManager: CommunicationManagerService,
|
||||
private offlineBroadcastService: OfflineBroadcastService
|
||||
) {
|
||||
// Handler that gets called, if the websocket connection reconnects after a disconnection.
|
||||
// There might have changed something on the server, so we check the operator, if he changed.
|
||||
websocketService.retryReconnectEvent.subscribe(() => {
|
||||
this.checkOperator();
|
||||
});
|
||||
|
||||
this.bootup();
|
||||
}
|
||||
|
||||
|
@ -68,20 +53,24 @@ export class OpenSlidesService {
|
|||
*/
|
||||
public async bootup(): Promise<void> {
|
||||
// start autoupdate if the user is logged in:
|
||||
let response = await this.operator.whoAmIFromStorage();
|
||||
const needToCheckOperator = !!response;
|
||||
let whoami = await this.operator.whoAmIFromStorage();
|
||||
const needToCheckOperator = !!whoami;
|
||||
|
||||
if (!response) {
|
||||
response = await this.operator.whoAmI();
|
||||
if (!whoami) {
|
||||
const response = await this.operator.whoAmI();
|
||||
if (!response.online) {
|
||||
this.offlineBroadcastService.goOffline(OfflineReason.WhoAmIFailed);
|
||||
}
|
||||
whoami = response.whoami;
|
||||
}
|
||||
|
||||
if (!response.user && !response.guest_enabled) {
|
||||
if (!whoami.user && !whoami.guest_enabled) {
|
||||
if (!location.pathname.includes('error')) {
|
||||
this.redirectUrl = location.pathname;
|
||||
}
|
||||
this.redirectToLoginIfNotSubpage();
|
||||
} else {
|
||||
await this.afterLoginBootup(response.user_id);
|
||||
await this.afterLoginBootup(whoami.user_id);
|
||||
}
|
||||
|
||||
if (needToCheckOperator) {
|
||||
|
@ -121,7 +110,7 @@ export class OpenSlidesService {
|
|||
await this.DS.clear();
|
||||
await this.storageService.set('lastUserLoggedIn', userId);
|
||||
}
|
||||
await this.setupDataStoreAndWebSocket();
|
||||
await this.setupDataStoreAndStartCommunication();
|
||||
// Now finally booted.
|
||||
this.booted.next(true);
|
||||
}
|
||||
|
@ -129,26 +118,17 @@ export class OpenSlidesService {
|
|||
/**
|
||||
* Init DS from cache and after this start the websocket service.
|
||||
*/
|
||||
private async setupDataStoreAndWebSocket(): Promise<void> {
|
||||
let changeId = await this.DS.initFromStorage();
|
||||
if (changeId > 0) {
|
||||
changeId += 1;
|
||||
}
|
||||
// disconnect the WS connection, if there was one. This is needed
|
||||
// to update the connection parameters, namely the cookies. If the user
|
||||
// is changed, the WS needs to reconnect, so the new connection holds the new
|
||||
// user information.
|
||||
if (this.websocketService.isConnected) {
|
||||
await this.websocketService.close(); // Wait for the disconnect.
|
||||
}
|
||||
await this.websocketService.connect({ changeId: changeId }); // Request changes after changeId.
|
||||
private async setupDataStoreAndStartCommunication(): Promise<void> {
|
||||
await this.DS.initFromStorage();
|
||||
await this.openslidesStatus.stable;
|
||||
this.communicationManager.startCommunication();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts down OpenSlides. The websocket connection is closed and the operator is not set.
|
||||
* Shuts down OpenSlides.
|
||||
*/
|
||||
public async shutdown(): Promise<void> {
|
||||
await this.websocketService.close();
|
||||
this.communicationManager.closeConnections();
|
||||
this.booted.next(false);
|
||||
}
|
||||
|
||||
|
@ -170,29 +150,37 @@ export class OpenSlidesService {
|
|||
await this.bootup();
|
||||
}
|
||||
|
||||
public async checkOperator(requestChanges: boolean = true): Promise<void> {
|
||||
const response = await this.operator.whoAmI();
|
||||
if (!response.online) {
|
||||
this.offlineBroadcastService.goOffline(OfflineReason.WhoAmIFailed);
|
||||
}
|
||||
await this.checkWhoAmI(response.whoami, requestChanges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the operator is the same as it was before. Should be alled on a reconnect.
|
||||
*
|
||||
* @returns true, if the user is still logged in
|
||||
*/
|
||||
private async checkOperator(requestChanges: boolean = true): Promise<void> {
|
||||
const response = await this.operator.whoAmI();
|
||||
public async checkWhoAmI(whoami: WhoAmI, requestChanges: boolean = true): Promise<boolean> {
|
||||
let isLoggedIn = false;
|
||||
// User logged off.
|
||||
if (!response.user && !response.guest_enabled) {
|
||||
this.websocketService.cancelReconnectenRetry();
|
||||
if (!whoami.user && !whoami.guest_enabled) {
|
||||
await this.shutdown();
|
||||
this.redirectToLoginIfNotSubpage();
|
||||
} else {
|
||||
isLoggedIn = true;
|
||||
if (
|
||||
(this.operator.user && this.operator.user.id !== response.user_id) ||
|
||||
(!this.operator.user && response.user_id)
|
||||
(this.operator.user && this.operator.user.id !== whoami.user_id) ||
|
||||
(!this.operator.user && whoami.user_id)
|
||||
) {
|
||||
// user changed
|
||||
await this.DS.clear();
|
||||
await this.reboot();
|
||||
} else if (requestChanges) {
|
||||
// User is still the same, but check for missed autoupdates.
|
||||
this.autoupdateService.requestChanges();
|
||||
this.constantsService.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return isLoggedIn;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,6 @@ import { CollectionStringMapperService } from './collection-string-mapper.servic
|
|||
import { DataStoreService } from './data-store.service';
|
||||
import { Deferred } from '../promises/deferred';
|
||||
import { HttpService } from './http.service';
|
||||
import { OfflineService } from './offline.service';
|
||||
import { OnAfterAppsLoaded } from '../definitions/on-after-apps-loaded';
|
||||
import { OpenSlidesStatusService } from './openslides-status.service';
|
||||
import { StorageService } from './storage.service';
|
||||
|
@ -21,7 +20,42 @@ import { UserRepositoryService } from '../repositories/users/user-repository.ser
|
|||
* Permissions on the client are just strings. This makes clear, that
|
||||
* permissions instead of arbitrary strings should be given.
|
||||
*/
|
||||
export type Permission = string;
|
||||
export enum Permission {
|
||||
agendaCanManage = 'agenda.can_manage',
|
||||
agendaCanSee = 'agenda.can_see',
|
||||
agendaCanSeeInternalItems = 'agenda.can_see_internal_items',
|
||||
agendaCanManageListOfSpeakers = 'agenda.can_manage_list_of_speakers',
|
||||
agendaCanSeeListOfSpeakers = 'agenda.can_see_list_of_speakers',
|
||||
agendaCanBeSpeaker = 'agenda.can_be_speaker',
|
||||
assignmentsCanManage = 'assignments.can_manage',
|
||||
assignmentsCanNominateOther = 'assignments.can_nominate_other',
|
||||
assignmentsCanNominateSelf = 'assignments.can_nominate_self',
|
||||
assignmentsCanSee = 'assignments.can_see',
|
||||
coreCanManageConfig = 'core.can_manage_config',
|
||||
coreCanManageLogosAndFonts = 'core.can_manage_logos_and_fonts',
|
||||
coreCanSeeHistory = 'core.can_see_history',
|
||||
coreCanManageProjector = 'core.can_manage_projector',
|
||||
coreCanSeeFrontpage = 'core.can_see_frontpage',
|
||||
coreCanSeeProjector = 'core.can_see_projector',
|
||||
coreCanManageTags = 'core.can_manage_tags',
|
||||
coreCanSeeLiveStream = 'core.can_see_livestream',
|
||||
coreCanSeeAutopilot = 'core.can_see_autopilot',
|
||||
mediafilesCanManage = 'mediafiles.can_manage',
|
||||
mediafilesCanSee = 'mediafiles.can_see',
|
||||
motionsCanCreate = 'motions.can_create',
|
||||
motionsCanCreateAmendments = 'motions.can_create_amendments',
|
||||
motionsCanManage = 'motions.can_manage',
|
||||
motionsCanManageMetadata = 'motions.can_manage_metadata',
|
||||
motionsCanManagePolls = 'motions.can_manage_polls',
|
||||
motionsCanSee = 'motions.can_see',
|
||||
motionsCanSeeInternal = 'motions.can_see_internal',
|
||||
motionsCanSupport = 'motions.can_support',
|
||||
usersCanChangePassword = 'users.can_change_password',
|
||||
usersCanManage = 'users.can_manage',
|
||||
usersCanSeeExtraData = 'users.can_see_extra_data',
|
||||
usersCanSeeName = 'users.can_see_name',
|
||||
chatCanManage = 'chat.can_manage'
|
||||
}
|
||||
|
||||
/**
|
||||
* Response format of the WhoAmI request.
|
||||
|
@ -83,6 +117,13 @@ export class OperatorService implements OnAfterAppsLoaded {
|
|||
return this._viewUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns 0 for the anonymous
|
||||
*/
|
||||
public get userId(): number {
|
||||
return this.user?.id || 0;
|
||||
}
|
||||
|
||||
public get isAnonymous(): boolean {
|
||||
return !this.user || this.user.id === 0;
|
||||
}
|
||||
|
@ -173,7 +214,6 @@ export class OperatorService implements OnAfterAppsLoaded {
|
|||
public constructor(
|
||||
private http: HttpService,
|
||||
private DS: DataStoreService,
|
||||
private offlineService: OfflineService,
|
||||
private collectionStringMapper: CollectionStringMapperService,
|
||||
private storageService: StorageService,
|
||||
private OSStatus: OpenSlidesStatusService
|
||||
|
@ -252,6 +292,10 @@ export class OperatorService implements OnAfterAppsLoaded {
|
|||
return response;
|
||||
}
|
||||
|
||||
public async clearWhoAmIFromStorage(): Promise<void> {
|
||||
await this.storageService.remove(WHOAMI_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the operator user. Will be saved to storage
|
||||
* @param user The new operator.
|
||||
|
@ -268,18 +312,19 @@ export class OperatorService implements OnAfterAppsLoaded {
|
|||
*
|
||||
* @returns The response of the WhoAmI request.
|
||||
*/
|
||||
public async whoAmI(): Promise<WhoAmI> {
|
||||
public async whoAmI(): Promise<{ whoami: WhoAmI; online: boolean }> {
|
||||
let online = true;
|
||||
try {
|
||||
const response = await this.http.get(environment.urlPrefix + '/users/whoami/');
|
||||
if (isWhoAmI(response)) {
|
||||
await this.updateCurrentWhoAmI(response);
|
||||
} else {
|
||||
this.offlineService.goOfflineBecauseFailedWhoAmI();
|
||||
online = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.offlineService.goOfflineBecauseFailedWhoAmI();
|
||||
online = false;
|
||||
}
|
||||
return this.currentWhoAmI;
|
||||
return { whoami: this.currentWhoAmI, online };
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -390,12 +435,12 @@ export class OperatorService implements OnAfterAppsLoaded {
|
|||
} else {
|
||||
// Anonymous or users in the default group.
|
||||
if (!this.user || this.user.groups_id.length === 0) {
|
||||
const defaultGroup = this.DS.get<Group>('users/group', 1);
|
||||
const defaultGroup: Group = this.DS.get<Group>('users/group', 1);
|
||||
if (defaultGroup && defaultGroup.permissions instanceof Array) {
|
||||
this.permissions = defaultGroup.permissions;
|
||||
}
|
||||
} else {
|
||||
const permissionSet = new Set<string>();
|
||||
const permissionSet = new Set<Permission>();
|
||||
this.DS.getMany(Group, this.user.groups_id).forEach(group => {
|
||||
group.permissions.forEach(permission => {
|
||||
permissionSet.add(permission);
|
||||
|
@ -416,6 +461,13 @@ export class OperatorService implements OnAfterAppsLoaded {
|
|||
this.operatorSubject.next(this.user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the operators presence to isPresent
|
||||
*/
|
||||
public async setPresence(isPresent: boolean): Promise<void> {
|
||||
await this.http.post(environment.urlPrefix + '/users/setpresence/', isPresent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a default WhoAmI response
|
||||
*/
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
import { inject, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { E2EImportsModule } from '../../../e2e-imports.module';
|
||||
import { PingService } from './ping.service';
|
||||
|
||||
describe('PingService', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [E2EImportsModule],
|
||||
providers: [PingService]
|
||||
});
|
||||
});
|
||||
|
||||
it('should be created', inject([PingService], (service: PingService) => {
|
||||
expect(service).toBeTruthy();
|
||||
}));
|
||||
});
|
|
@ -1,86 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { ConstantsService } from './constants.service';
|
||||
import { Deferred } from '../promises/deferred';
|
||||
import { TimeoutPromise } from '../promises/timeout-promise';
|
||||
import { WebsocketService } from './websocket.service';
|
||||
|
||||
interface OpenSlidesSettings {
|
||||
PING_INTERVAL?: number;
|
||||
PING_TIMEOUT?: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PingService {
|
||||
/**
|
||||
* The interval.
|
||||
*/
|
||||
private pingInterval: any;
|
||||
|
||||
private intervalTime = 30000;
|
||||
|
||||
private timeoutTime = 5000;
|
||||
|
||||
private lastLatency: number | null = null;
|
||||
|
||||
public constructor(private websocketService: WebsocketService, private constantsService: ConstantsService) {
|
||||
this.setup();
|
||||
}
|
||||
|
||||
private async setup(): Promise<void> {
|
||||
const gotConstants = new Deferred();
|
||||
|
||||
this.constantsService.get<OpenSlidesSettings>('Settings').subscribe(settings => {
|
||||
this.intervalTime = settings.PING_INTERVAL || 30000;
|
||||
this.timeoutTime = settings.PING_TIMEOUT || 5000;
|
||||
gotConstants.resolve();
|
||||
});
|
||||
await gotConstants;
|
||||
|
||||
// Connects the ping-pong mechanism to the opening and closing of the connection.
|
||||
this.websocketService.closeEvent.subscribe(() => this.stopPing());
|
||||
this.websocketService.generalConnectEvent.subscribe(() => this.startPing());
|
||||
if (this.websocketService.isConnected) {
|
||||
this.startPing();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the ping-mechanism
|
||||
*/
|
||||
private startPing(): void {
|
||||
if (this.pingInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pingInterval = setInterval(async () => {
|
||||
const start = performance.now();
|
||||
try {
|
||||
await TimeoutPromise(
|
||||
this.websocketService.sendAndGetResponse('ping', this.lastLatency),
|
||||
this.timeoutTime
|
||||
);
|
||||
this.lastLatency = performance.now() - start;
|
||||
if (this.lastLatency > 1000) {
|
||||
console.warn(`Ping took ${this.lastLatency / 1000} seconds.`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`The server didn't respond to ping within ${this.timeoutTime / 1000} seconds.`);
|
||||
this.stopPing();
|
||||
this.websocketService.simulateAbnormalClose();
|
||||
}
|
||||
}, this.intervalTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the ping interval
|
||||
*/
|
||||
private stopPing(): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
import { inject, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { E2EImportsModule } from '../../../e2e-imports.module';
|
||||
import { PrioritizeService } from './prioritize.service';
|
||||
|
||||
describe('PrioritizeService', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [E2EImportsModule],
|
||||
providers: [PrioritizeService]
|
||||
});
|
||||
});
|
||||
|
||||
it('should be created', inject([PrioritizeService], (service: PrioritizeService) => {
|
||||
expect(service).toBeTruthy();
|
||||
}));
|
||||
});
|
|
@ -1,46 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { ConstantsService } from './constants.service';
|
||||
import { DataStoreService } from './data-store.service';
|
||||
import { OpenSlidesStatusService } from './openslides-status.service';
|
||||
import { OperatorService } from './operator.service';
|
||||
import { WebsocketService } from './websocket.service';
|
||||
|
||||
interface OpenSlidesSettings {
|
||||
PRIORITIZED_GROUP_IDS?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cares about prioritizing a client. Checks, if the operator is in one of
|
||||
* some prioritized groups. These group ids come from the server. If the prio-
|
||||
* ritization changes, the websocket connection gets reconnected.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PrioritizeService {
|
||||
private prioritizedGroupIds: number[] = [];
|
||||
|
||||
public constructor(
|
||||
constantsService: ConstantsService,
|
||||
private websocketService: WebsocketService,
|
||||
private DS: DataStoreService,
|
||||
private openSlidesStatusService: OpenSlidesStatusService,
|
||||
private operator: OperatorService
|
||||
) {
|
||||
constantsService.get<OpenSlidesSettings>('Settings').subscribe(settings => {
|
||||
this.prioritizedGroupIds = settings.PRIORITIZED_GROUP_IDS || [];
|
||||
this.checkPrioritization();
|
||||
});
|
||||
operator.getUserObservable().subscribe(() => this.checkPrioritization());
|
||||
}
|
||||
|
||||
private checkPrioritization(): void {
|
||||
const opPrioritized = this.operator.isInGroupIdsNonAdminCheck(...this.prioritizedGroupIds);
|
||||
if (this.openSlidesStatusService.isPrioritizedClient !== opPrioritized) {
|
||||
console.log('Alter prioritization:', opPrioritized);
|
||||
this.openSlidesStatusService.isPrioritizedClient = opPrioritized;
|
||||
this.websocketService.reconnect({ changeId: this.DS.maxChangeId });
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,8 +3,8 @@ import { Injectable } from '@angular/core';
|
|||
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
||||
import { auditTime } from 'rxjs/operators';
|
||||
|
||||
import { WebsocketService } from 'app/core/core-services/websocket.service';
|
||||
import { Projector, ProjectorElement } from 'app/shared/models/core/projector';
|
||||
import { CommunicationManagerService, OfflineError } from './communication-manager.service';
|
||||
|
||||
export interface SlideData<T = { error?: string }, P extends ProjectorElement = ProjectorElement> {
|
||||
data: T;
|
||||
|
@ -15,13 +15,13 @@ export interface SlideData<T = { error?: string }, P extends ProjectorElement =
|
|||
export type ProjectorData = SlideData[];
|
||||
|
||||
interface AllProjectorData {
|
||||
[id: number]: ProjectorData | { error: string };
|
||||
[id: number]: ProjectorData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Received data from server.
|
||||
*/
|
||||
interface ProjectorWebsocketMessage {
|
||||
interface ProjectorDataMessage {
|
||||
/**
|
||||
* The `change_id` of the current update.
|
||||
*/
|
||||
|
@ -63,38 +63,63 @@ export class ProjectorDataService {
|
|||
*/
|
||||
private currentChangeId = 0;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param websocketService
|
||||
*/
|
||||
public constructor(private websocketService: WebsocketService) {
|
||||
// Dispatch projector data.
|
||||
this.websocketService.getOberservable('projector').subscribe((update: ProjectorWebsocketMessage) => {
|
||||
if (this.currentChangeId > update.change_id) {
|
||||
return;
|
||||
}
|
||||
Object.keys(update.data).forEach(_id => {
|
||||
const id = parseInt(_id, 10);
|
||||
if (this.currentProjectorData[id]) {
|
||||
this.currentProjectorData[id].next(update.data[id] as ProjectorData);
|
||||
}
|
||||
});
|
||||
this.currentChangeId = update.change_id;
|
||||
});
|
||||
private streamCloseFn: () => void | null = null;
|
||||
|
||||
// The service need to re-register, if the websocket connection was lost.
|
||||
this.websocketService.generalConnectEvent.subscribe(() => this.updateProjectorDataSubscription());
|
||||
public constructor(private communicationManager: CommunicationManagerService) {
|
||||
this.communicationManager.startCommunicationEvent.subscribe(() => this.updateProjectorDataSubscription());
|
||||
|
||||
// With a bit of debounce, update the needed projectors.
|
||||
this.updateProjectorDataDebounceSubject.pipe(auditTime(10)).subscribe(() => {
|
||||
const allActiveProjectorIds = Object.keys(this.openProjectorInstances)
|
||||
.map(id => parseInt(id, 10))
|
||||
.filter(id => this.openProjectorInstances[id] > 0);
|
||||
this.websocketService.send('listenToProjectors', { projector_ids: allActiveProjectorIds });
|
||||
this.requestProjectors(allActiveProjectorIds);
|
||||
});
|
||||
}
|
||||
|
||||
public async requestProjectors(allActiveProjectorIds: number[]): Promise<void> {
|
||||
this.cancelCurrentServerSubscription();
|
||||
|
||||
if (allActiveProjectorIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.streamCloseFn = await this.communicationManager.subscribe<ProjectorDataMessage>(
|
||||
'/system/projector',
|
||||
message => {
|
||||
this.handleMesage(message);
|
||||
},
|
||||
() => ({ projector_ids: allActiveProjectorIds.join(',') })
|
||||
);
|
||||
} catch (e) {
|
||||
if (!(e instanceof OfflineError)) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public cancelCurrentServerSubscription(): void {
|
||||
if (this.streamCloseFn) {
|
||||
this.streamCloseFn();
|
||||
this.streamCloseFn = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMesage(message: ProjectorDataMessage): void {
|
||||
if (this.currentChangeId > message.change_id) {
|
||||
console.log('Projector: Change id too low:', this.currentChangeId, message.change_id);
|
||||
return;
|
||||
}
|
||||
Object.keys(message.data).forEach(_id => {
|
||||
const id = parseInt(_id, 10);
|
||||
if (this.currentProjectorData[id]) {
|
||||
this.currentProjectorData[id].next(message.data[id] as ProjectorData);
|
||||
}
|
||||
});
|
||||
this.currentChangeId = message.change_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an observable for the projector data.
|
||||
*
|
||||
|
|
|
@ -25,6 +25,11 @@ import { HttpService } from './http.service';
|
|||
import { ProjectorDataService } from './projector-data.service';
|
||||
import { ViewModelStoreService } from './view-model-store.service';
|
||||
|
||||
export interface ProjectorTitle {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This service cares about Projectables being projected and manage all projection-related
|
||||
* actions.
|
||||
|
@ -35,12 +40,6 @@ import { ViewModelStoreService } from './view-model-store.service';
|
|||
providedIn: 'root'
|
||||
})
|
||||
export class ProjectorService {
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param DS
|
||||
* @param dataSend
|
||||
*/
|
||||
public constructor(
|
||||
private DS: DataStoreService,
|
||||
private http: HttpService,
|
||||
|
@ -77,10 +76,13 @@ export class ProjectorService {
|
|||
*/
|
||||
public isProjected(obj: Projectable | ProjectorElementBuildDeskriptor | IdentifiableProjectorElement): boolean {
|
||||
const element = this.getProjectorElement(obj);
|
||||
if (element?.getIdentifiers) {
|
||||
return this.DS.getAll<Projector>('core/projector').some(projector => {
|
||||
return projector.isElementShown(element);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all projectors where the object is prejected on.
|
||||
|
@ -250,7 +252,7 @@ export class ProjectorService {
|
|||
projectorData.forEach(entry => {
|
||||
if (entry.data.error && entry.element.stable) {
|
||||
// Remove this element
|
||||
const idElementToRemove = this.slideManager.getIdentifialbeProjectorElement(entry.element);
|
||||
const idElementToRemove = this.slideManager.getIdentifiableProjectorElement(entry.element);
|
||||
elements = elements.filter(element => {
|
||||
return !elementIdentifies(idElementToRemove, element);
|
||||
});
|
||||
|
@ -312,7 +314,7 @@ export class ProjectorService {
|
|||
* @param element The projector element
|
||||
* @returns the view model from the projector element
|
||||
*/
|
||||
public getViewModelFromProjectorElement<T extends BaseProjectableViewModel>(
|
||||
public getViewModelFromIdentifiableProjectorElement<T extends BaseProjectableViewModel>(
|
||||
element: IdentifiableProjectorElement
|
||||
): T {
|
||||
this.assertElementIsMappable(element);
|
||||
|
@ -323,12 +325,16 @@ export class ProjectorService {
|
|||
return viewModel;
|
||||
}
|
||||
|
||||
public getViewModelFromProjectorElement<T extends BaseProjectableViewModel>(element: ProjectorElement): T {
|
||||
const idElement = this.slideManager.getIdentifiableProjectorElement(element);
|
||||
return this.getViewModelFromIdentifiableProjectorElement(idElement);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
public getSlideTitle(element: ProjectorElement): string {
|
||||
public getSlideTitle(element: ProjectorElement): ProjectorTitle {
|
||||
if (this.slideManager.canSlideBeMappedToModel(element.name)) {
|
||||
const idElement = this.slideManager.getIdentifialbeProjectorElement(element);
|
||||
const viewModel = this.getViewModelFromProjectorElement(idElement);
|
||||
const viewModel = this.getViewModelFromProjectorElement(element);
|
||||
if (viewModel) {
|
||||
return viewModel.getProjectorTitle();
|
||||
}
|
||||
|
@ -338,7 +344,7 @@ export class ProjectorService {
|
|||
return configuration.getSlideTitle(element, this.translate, this.viewModelStore);
|
||||
}
|
||||
|
||||
return this.translate.instant(this.slideManager.getSlideVerboseName(element.name));
|
||||
return { title: this.translate.instant(this.slideManager.getSlideVerboseName(element.name)) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -98,6 +98,17 @@ export class RelationManagerService {
|
|||
viewModel: BaseViewModel,
|
||||
relation: RelationDefinition
|
||||
): any {
|
||||
// No cache for reverse relations.
|
||||
// The issue: we cannot invalidate the cache, if a new object is created (The
|
||||
// following example is for a O2M foreign relation):
|
||||
// There is no possibility to detect the create case: The target does not update,
|
||||
// all related models does not update. The autoupdate does not provide the created-
|
||||
// information. So we may check, if the relaten has changed in length every time. But
|
||||
// this is the same as just resolving the relation every time it is requested. So no cache here.
|
||||
if (isReverseRelationDefinition(relation)) {
|
||||
return this.handleRelation(model, viewModel, relation) as BaseViewModel | BaseViewModel[];
|
||||
}
|
||||
|
||||
let result: any;
|
||||
|
||||
const cacheProperty = '__' + property;
|
||||
|
@ -187,9 +198,20 @@ export class RelationManagerService {
|
|||
const _model: M = target.getModel();
|
||||
const relation = typeof property === 'string' ? relationsByKey[property] : null;
|
||||
|
||||
// try to find a getter for property
|
||||
if (property in target) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(viewModelCtor.prototype, property);
|
||||
// iterate over prototype chain
|
||||
let prototypeFunc = viewModelCtor,
|
||||
descriptor = null;
|
||||
do {
|
||||
descriptor = Object.getOwnPropertyDescriptor(prototypeFunc.prototype, property);
|
||||
if (!descriptor || !descriptor.get) {
|
||||
prototypeFunc = Object.getPrototypeOf(prototypeFunc);
|
||||
}
|
||||
} while (!(descriptor && descriptor.get) && prototypeFunc && prototypeFunc.prototype);
|
||||
|
||||
if (descriptor && descriptor.get) {
|
||||
// if getter was found in prototype chain, bind it with this proxy for right `this` access
|
||||
result = descriptor.get.bind(viewModel)();
|
||||
} else {
|
||||
result = target[property];
|
||||
|
|
|
@ -0,0 +1,286 @@
|
|||
import {
|
||||
HttpClient,
|
||||
HttpDownloadProgressEvent,
|
||||
HttpEvent,
|
||||
HttpHeaderResponse,
|
||||
HttpHeaders,
|
||||
HttpParams
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
|
||||
import { HTTPMethod } from '../definitions/http-methods';
|
||||
|
||||
const HEADER_EVENT_TYPE = 2;
|
||||
const PROGRESS_EVENT_TYPE = 3;
|
||||
const FINISH_EVENT_TYPE = 4;
|
||||
|
||||
export type Params = HttpParams | { [param: string]: string | string[] };
|
||||
export enum ErrorType {
|
||||
Client,
|
||||
Server, // or network errors, they are the same.
|
||||
Unknown
|
||||
}
|
||||
export function verboseErrorType(type: ErrorType): string {
|
||||
switch (type) {
|
||||
case ErrorType.Client:
|
||||
return 'Client';
|
||||
case ErrorType.Server:
|
||||
return 'Server';
|
||||
case ErrorType.Unknown:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
export interface CommunicationError {
|
||||
type: string;
|
||||
msg: string;
|
||||
}
|
||||
export function isCommunicationError(obj: any): obj is CommunicationError {
|
||||
const _obj = obj as CommunicationError;
|
||||
return typeof obj === 'object' && typeof _obj.msg === 'string' && typeof _obj.type === 'string';
|
||||
}
|
||||
interface CommunicationErrorWrapper {
|
||||
error: CommunicationError;
|
||||
}
|
||||
export function isCommunicationErrorWrapper(obj: any): obj is CommunicationErrorWrapper {
|
||||
return typeof obj === 'object' && isCommunicationError(obj.error);
|
||||
}
|
||||
export type ErrorHandler = (type: ErrorType, error: CommunicationError, message: string) => void;
|
||||
|
||||
export class StreamConnectionError extends Error {
|
||||
public constructor(public code: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'StreamConnectionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamContainer<T> {
|
||||
public readonly id = Math.floor(Math.random() * (900000 - 1) + 100000); // [100000, 999999]
|
||||
|
||||
public messageHandler: (message: T) => void;
|
||||
|
||||
public hasErroredAmount = 0;
|
||||
|
||||
public stream?: Stream<T>;
|
||||
|
||||
public constructor(public url: string, messageHandler: (message: T) => void, public params: () => Params) {
|
||||
this.messageHandler = (message: T) => {
|
||||
// {connected: true} is a special message just to trigger the code below
|
||||
if ((<any>message).connected) {
|
||||
console.log(`resetting error amount for ${this.url} since there was a connect message`);
|
||||
this.hasErroredAmount = 0;
|
||||
} else {
|
||||
messageHandler(message);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Stream<T> {
|
||||
private subscription: Subscription = null;
|
||||
|
||||
private hasError = false;
|
||||
private reportedError = false;
|
||||
|
||||
private _statuscode: number;
|
||||
public get statuscode(): number {
|
||||
return this._statuscode;
|
||||
}
|
||||
|
||||
private _errorContent: CommunicationError;
|
||||
public get errorContent(): CommunicationError {
|
||||
return this._errorContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the index where we checked, if there is a \n in the read buffer (event.partialText)
|
||||
* This position is always >= contentStartIndex and is > contentStartIndex, if the message
|
||||
* was too big to fit into one buffer. So we have just a partial message.
|
||||
*
|
||||
* The difference between this index and contentStartIndex is that this index remembers the position
|
||||
* we checked for a \n which lay in the middle of the next JOSN-packet.
|
||||
*/
|
||||
private checkedUntilIndex = 0;
|
||||
|
||||
/**
|
||||
* This index holds always the position of the current JOSN-packet, that we are receiving.
|
||||
*/
|
||||
private contentStartIndex = 0;
|
||||
|
||||
private closed = false;
|
||||
|
||||
public constructor(
|
||||
observable: Observable<HttpEvent<string>>,
|
||||
private messageHandler: (message: T) => void,
|
||||
private errorHandler: ErrorHandler
|
||||
) {
|
||||
this.subscription = observable.subscribe(
|
||||
(event: HttpEvent<string>) => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
if (event.type === HEADER_EVENT_TYPE) {
|
||||
const headerResponse = event as HttpHeaderResponse;
|
||||
this._statuscode = headerResponse.status;
|
||||
if (headerResponse.status >= 400) {
|
||||
this.hasError = true;
|
||||
}
|
||||
} else if ((<HttpEvent<string>>event).type === PROGRESS_EVENT_TYPE) {
|
||||
this.handleMessage(event as HttpDownloadProgressEvent);
|
||||
} else if ((<HttpEvent<string>>event).type === FINISH_EVENT_TYPE) {
|
||||
this.errorHandler(ErrorType.Server, null, 'The stream was closed');
|
||||
}
|
||||
},
|
||||
error => {
|
||||
this.errorHandler(ErrorType.Server, error, 'Network error');
|
||||
},
|
||||
() => {
|
||||
console.log('The stream was completed');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private handleMessage(event: HttpDownloadProgressEvent): void {
|
||||
if (this.hasError) {
|
||||
if (!this.reportedError) {
|
||||
this.reportedError = true;
|
||||
// try to get the `error` key from object
|
||||
this._errorContent = this.tryParseError(event.partialText);
|
||||
this.errorHandler(this.getErrorTypeFromStatusCode(), this._errorContent, 'Reported error by server');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Maybe we get multiple messages, so continue, until the complete buffer is checked.
|
||||
while (this.checkedUntilIndex < event.loaded) {
|
||||
// check if there is a \n somewhere in [checkedUntilIndex, ...]
|
||||
const LF_index = event.partialText.indexOf('\n', this.checkedUntilIndex);
|
||||
|
||||
if (LF_index >= 0) {
|
||||
// take string in [contentStartIndex, LF_index-1]. This must be valid JSON.
|
||||
// In substring, the last character is exlusive.
|
||||
const content = event.partialText.substring(this.contentStartIndex, LF_index);
|
||||
|
||||
// move pointer: next JSON starts at LF_index + 1
|
||||
this.checkedUntilIndex = LF_index + 1;
|
||||
this.contentStartIndex = LF_index + 1;
|
||||
|
||||
const parsedContent = this.tryParseJson(content);
|
||||
if (isCommunicationError(parsedContent)) {
|
||||
if (this.hasError && this.reportedError) {
|
||||
return;
|
||||
}
|
||||
this.hasError = true;
|
||||
this._errorContent = parsedContent;
|
||||
// Do not trigger the error handler, if the connection-retry-routine is still handling this issue
|
||||
if (!this.reportedError) {
|
||||
this.reportedError = true;
|
||||
console.error(this._errorContent);
|
||||
this.errorHandler(
|
||||
this.getErrorTypeFromStatusCode(),
|
||||
this._errorContent,
|
||||
'Reported error by server'
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
console.debug('received', parsedContent);
|
||||
this.messageHandler(parsedContent);
|
||||
}
|
||||
} else {
|
||||
this.checkedUntilIndex = event.loaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getErrorTypeFromStatusCode(): ErrorType {
|
||||
if (!this.statuscode) {
|
||||
return ErrorType.Unknown;
|
||||
}
|
||||
if (this.statuscode >= 400 && this.statuscode < 500) {
|
||||
return ErrorType.Client;
|
||||
}
|
||||
if (this.statuscode >= 500) {
|
||||
return ErrorType.Server;
|
||||
}
|
||||
return ErrorType.Unknown;
|
||||
}
|
||||
|
||||
private tryParseJson(json: string): T | CommunicationError {
|
||||
try {
|
||||
return JSON.parse(json) as T;
|
||||
} catch (e) {
|
||||
return this.tryParseError(json);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This one is a bit tricky. Error can be:
|
||||
* - string with HTML, e.g. provided by proxies if the service is unreachable
|
||||
* - string with json of form {"error": {"type": ..., "msg": ...}}
|
||||
*/
|
||||
private tryParseError(error: any): CommunicationError {
|
||||
if (typeof error === 'string') {
|
||||
try {
|
||||
error = JSON.parse(error);
|
||||
} catch (e) {
|
||||
return { type: 'Unknown Error', msg: error };
|
||||
}
|
||||
}
|
||||
|
||||
if (isCommunicationErrorWrapper(error)) {
|
||||
return error.error;
|
||||
} else if (isCommunicationError(error)) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// we have something else.... ??
|
||||
console.error('Unknown error', error);
|
||||
throw new Error('Unknown error: ' + error.toString());
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.subscription.unsubscribe();
|
||||
this.subscription = null;
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class StreamingCommunicationService {
|
||||
public constructor(private http: HttpClient) {}
|
||||
|
||||
public subscribe<T>(streamContainer: StreamContainer<T>, errorHandler: ErrorHandler): void {
|
||||
const options: {
|
||||
body?: any;
|
||||
headers?: HttpHeaders | { [header: string]: string | string[] };
|
||||
observe: 'events';
|
||||
params?: HttpParams | { [param: string]: string | string[] };
|
||||
reportProgress?: boolean;
|
||||
responseType: 'text';
|
||||
withCredentials?: boolean;
|
||||
} = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'ngsw-bypass': 'yes'
|
||||
},
|
||||
responseType: 'text',
|
||||
observe: 'events',
|
||||
reportProgress: true
|
||||
};
|
||||
const params = streamContainer.params();
|
||||
if (params) {
|
||||
options.params = params;
|
||||
}
|
||||
const observable = this.http.request(HTTPMethod.GET, streamContainer.url, options);
|
||||
|
||||
if (streamContainer.stream) {
|
||||
console.error('Illegal state!');
|
||||
}
|
||||
|
||||
const stream = new Stream<T>(observable, streamContainer.messageHandler, errorHandler);
|
||||
streamContainer.stream = stream;
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ describe('TimeTravelService', () => {
|
|||
);
|
||||
|
||||
it('should be created', () => {
|
||||
const service: TimeTravelService = TestBed.get(TimeTravelService);
|
||||
const service: TimeTravelService = TestBed.inject(TimeTravelService);
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,7 +9,6 @@ import { DataStoreService, DataStoreUpdateManagerService } from './data-store.se
|
|||
import { HttpService } from './http.service';
|
||||
import { OpenSlidesStatusService } from './openslides-status.service';
|
||||
import { OpenSlidesService } from './openslides.service';
|
||||
import { WebsocketService } from './websocket.service';
|
||||
|
||||
interface HistoryData {
|
||||
[collection: string]: BaseModel[];
|
||||
|
@ -31,7 +30,6 @@ export class TimeTravelService {
|
|||
* Constructs the time travel service
|
||||
*
|
||||
* @param httpService To fetch the history data
|
||||
* @param webSocketService to disable websocket connection
|
||||
* @param modelMapperService to cast history objects into models
|
||||
* @param DS to overwrite the dataStore
|
||||
* @param OSStatus Sets the history status
|
||||
|
@ -39,7 +37,6 @@ export class TimeTravelService {
|
|||
*/
|
||||
public constructor(
|
||||
private httpService: HttpService,
|
||||
private webSocketService: WebsocketService,
|
||||
private modelMapperService: CollectionStringMapperService,
|
||||
private DS: DataStoreService,
|
||||
private OSStatus: OpenSlidesStatusService,
|
||||
|
@ -100,7 +97,8 @@ export class TimeTravelService {
|
|||
* Clears the DataStore and stops the WebSocket connection
|
||||
*/
|
||||
private async stopTime(history: History): Promise<void> {
|
||||
await this.webSocketService.close();
|
||||
// await this.webSocketService.close();
|
||||
// TODO
|
||||
await this.DS.set(); // Same as clear, but not persistent.
|
||||
this.OSStatus.enterHistoryMode(history);
|
||||
}
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
import { inject, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { E2EImportsModule } from '../../../e2e-imports.module';
|
||||
import { WebsocketService } from './websocket.service';
|
||||
|
||||
describe('WebsocketService', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [E2EImportsModule],
|
||||
providers: [WebsocketService]
|
||||
});
|
||||
});
|
||||
|
||||
it('should be created', inject([WebsocketService], (service: WebsocketService) => {
|
||||
expect(service).toBeTruthy();
|
||||
}));
|
||||
});
|
|
@ -1,567 +0,0 @@
|
|||
import { EventEmitter, Injectable, NgZone } from '@angular/core';
|
||||
import { MatSnackBarRef, SimpleSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { compress, decompress } from 'lz4js';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
||||
import { OfflineService } from './offline.service';
|
||||
import { OpenSlidesStatusService } from './openslides-status.service';
|
||||
import { formatQueryParams, QueryParams } from '../definitions/query-params';
|
||||
|
||||
/**
|
||||
* The generic message format in which messages are send and recieved by the server.
|
||||
*/
|
||||
interface BaseWebsocketMessage {
|
||||
type: string;
|
||||
content: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outgoing messages must have an id.
|
||||
*/
|
||||
interface OutgoingWebsocketMessage extends BaseWebsocketMessage {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incomming messages may have an `in_response`, if they are an answer to a previously
|
||||
* submitted request.
|
||||
*/
|
||||
interface IncommingWebsocketMessage extends BaseWebsocketMessage {
|
||||
in_response?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The format of a messages content, if the message type is "error"
|
||||
*/
|
||||
interface WebsocketErrorContent {
|
||||
code: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function isWebsocketErrorContent(obj: any): obj is WebsocketErrorContent {
|
||||
return !!obj && obj.code !== undefined && obj.message !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* All (custom) error codes that are used to pass error information
|
||||
* from the server to the client
|
||||
*/
|
||||
export const WEBSOCKET_ERROR_CODES = {
|
||||
NOT_AUTHORIZED: 100,
|
||||
CHANGE_ID_TOO_HIGH: 101,
|
||||
WRONG_FORMAT: 102
|
||||
};
|
||||
|
||||
/*
|
||||
* Options for (re-)connecting.
|
||||
*/
|
||||
interface ConnectOptions {
|
||||
changeId?: number;
|
||||
enableAutoupdates?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service that handles WebSocket connections. Other services can register themselfs
|
||||
* with {@method getOberservable} for a specific type of messages. The content will be published.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class WebsocketService {
|
||||
/**
|
||||
* The reference to the snackbar entry that is shown, if the connection is lost.
|
||||
*/
|
||||
private connectionErrorNotice: MatSnackBarRef<SimpleSnackBar>;
|
||||
|
||||
/**
|
||||
* Subjects that will be called, if a reconnect after a retry (e.g. with a previous
|
||||
* connection loss) was successful.
|
||||
*/
|
||||
private readonly _retryReconnectEvent: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Getter for the retry reconnect event.
|
||||
*/
|
||||
public get retryReconnectEvent(): EventEmitter<void> {
|
||||
return this._retryReconnectEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subjects that will be called, if connect took place, but not a retry reconnect.
|
||||
* THis is the complement from the generalConnectEvent to the retryReconnectEvent.
|
||||
*/
|
||||
private readonly _noRetryConnectEvent: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Getter for the no-retry connect event.
|
||||
*/
|
||||
public get noRetryConnectEvent(): EventEmitter<void> {
|
||||
return this._noRetryConnectEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listeners will be nofitied, if the wesocket connection is establiched.
|
||||
*/
|
||||
private readonly _generalConnectEvent: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Getter for the connect event.
|
||||
*/
|
||||
public get generalConnectEvent(): EventEmitter<void> {
|
||||
return this._generalConnectEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listeners will be nofitied, if the wesocket connection is closed.
|
||||
*/
|
||||
private readonly _closeEvent: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Getter for the close event.
|
||||
*/
|
||||
public get closeEvent(): EventEmitter<void> {
|
||||
return this._closeEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject for all websocket *message* errors (no connection errors).
|
||||
*/
|
||||
private readonly _errorResponseSubject = new Subject<WebsocketErrorContent>();
|
||||
|
||||
/**
|
||||
* The error response obersable for all websocket message errors.
|
||||
*/
|
||||
public get errorResponseObservable(): Observable<WebsocketErrorContent> {
|
||||
return this._errorResponseSubject.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves, if the connection is open
|
||||
*/
|
||||
private _connectionOpen = false;
|
||||
|
||||
/**
|
||||
* Whether the WebSocket connection is established
|
||||
*/
|
||||
public get isConnected(): boolean {
|
||||
return this._connectionOpen;
|
||||
}
|
||||
|
||||
private sendQueueWhileNotConnected: (string | ArrayBuffer)[] = [];
|
||||
|
||||
/**
|
||||
* The websocket.
|
||||
*/
|
||||
private websocket: WebSocket | null;
|
||||
private websocketId: string | null;
|
||||
|
||||
/**
|
||||
* Subjects for types of websocket messages. A subscriber can get an Observable by {@function getOberservable}.
|
||||
*/
|
||||
private subjects: { [type: string]: Subject<any> } = {};
|
||||
|
||||
/**
|
||||
* Callbacks for a waiting response. If any callback returns true, the message/error will not be propagated with the
|
||||
* responsible subjects for the message type.
|
||||
*/
|
||||
private responseCallbacks: {
|
||||
[id: string]: [(val: any) => boolean, (error: WebsocketErrorContent) => boolean];
|
||||
} = {};
|
||||
|
||||
/**
|
||||
* Saves, if the WS Connection should be closed (e.g. after an explicit `close()`). Prohibits
|
||||
* retry connection attempts.
|
||||
*/
|
||||
private shouldBeClosed = true;
|
||||
|
||||
/**
|
||||
* Counter for delaying the offline message.
|
||||
*/
|
||||
private retryCounter = 0;
|
||||
|
||||
/**
|
||||
* The timeout in the onClose-handler for the next reconnect retry.
|
||||
*/
|
||||
private retryTimeout: any = null;
|
||||
|
||||
/**
|
||||
* Constructor that handles the router
|
||||
*
|
||||
* @param zone
|
||||
* @param router
|
||||
* @param openSlidesStatusService
|
||||
* @param offlineService
|
||||
*/
|
||||
public constructor(
|
||||
private zone: NgZone,
|
||||
private router: Router,
|
||||
private openSlidesStatusService: OpenSlidesStatusService,
|
||||
private offlineService: OfflineService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a new WebSocket connection and handles incomming events.
|
||||
*
|
||||
* Uses NgZone to let all callbacks run in the angular context.
|
||||
*/
|
||||
public async connect(options: ConnectOptions = {}, retry: boolean = false): Promise<void> {
|
||||
const websocketId = Math.random()
|
||||
.toString(36)
|
||||
.substring(7);
|
||||
this.websocketId = websocketId;
|
||||
|
||||
if (this.websocket) {
|
||||
this.websocket.close();
|
||||
this.websocket = null;
|
||||
}
|
||||
|
||||
if (!retry) {
|
||||
this.shouldBeClosed = false;
|
||||
}
|
||||
|
||||
// set defaults
|
||||
options = Object.assign(options, {
|
||||
enableAutoupdates: true
|
||||
});
|
||||
|
||||
const queryParams: QueryParams = {
|
||||
autoupdate: options.enableAutoupdates
|
||||
};
|
||||
|
||||
if (options.changeId !== undefined) {
|
||||
queryParams.change_id = options.changeId;
|
||||
}
|
||||
|
||||
// Create the websocket
|
||||
let socketPath = location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
socketPath += window.location.host;
|
||||
if (this.openSlidesStatusService.isPrioritizedClient) {
|
||||
socketPath += '/prioritize';
|
||||
}
|
||||
socketPath += '/ws/';
|
||||
socketPath += formatQueryParams(queryParams);
|
||||
|
||||
this.websocket = new WebSocket(socketPath);
|
||||
this.websocket.binaryType = 'arraybuffer';
|
||||
|
||||
// connection established. If this connect attept was a retry,
|
||||
// The error notice will be removed and the reconnectSubject is published.
|
||||
this.websocket.onopen = (event: Event) => {
|
||||
if (this.websocketId !== websocketId) {
|
||||
return;
|
||||
}
|
||||
this.zone.run(() => {
|
||||
this.retryCounter = 0;
|
||||
|
||||
if (this.shouldBeClosed) {
|
||||
this.offlineService.goOnline();
|
||||
return;
|
||||
}
|
||||
|
||||
this._connectionOpen = true;
|
||||
if (retry) {
|
||||
this.offlineService.goOnline();
|
||||
this._retryReconnectEvent.emit();
|
||||
} else {
|
||||
this._noRetryConnectEvent.emit();
|
||||
}
|
||||
this._generalConnectEvent.emit();
|
||||
this.sendQueueWhileNotConnected.forEach(entry => {
|
||||
this.websocket.send(entry);
|
||||
});
|
||||
this.sendQueueWhileNotConnected = [];
|
||||
});
|
||||
};
|
||||
|
||||
this.websocket.onmessage = (event: MessageEvent) => {
|
||||
if (this.websocketId !== websocketId) {
|
||||
return;
|
||||
}
|
||||
this.zone.run(() => {
|
||||
this.handleMessage(event.data);
|
||||
});
|
||||
};
|
||||
|
||||
this.websocket.onclose = (event: CloseEvent) => {
|
||||
if (this.websocketId !== websocketId) {
|
||||
return;
|
||||
}
|
||||
this.zone.run(() => {
|
||||
this.onclose();
|
||||
});
|
||||
};
|
||||
|
||||
this.websocket.onerror = (event: ErrorEvent) => {
|
||||
if (this.websocketId !== websocketId) {
|
||||
return;
|
||||
}
|
||||
// place for proper error handling and debugging.
|
||||
// Required to get more information about errors
|
||||
this.zone.run(() => {
|
||||
console.warn('WS error event:', event);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an incomming message.
|
||||
*
|
||||
* @param data The message
|
||||
*/
|
||||
private handleMessage(data: string | ArrayBuffer): void {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
const compressedSize = data.byteLength;
|
||||
const decompressedBuffer: Uint8Array = decompress(new Uint8Array(data));
|
||||
console.debug(
|
||||
`Recieved ${compressedSize / 1024} KB (${decompressedBuffer.byteLength /
|
||||
1024} KB uncompressed), ratio ${decompressedBuffer.byteLength / compressedSize}`
|
||||
);
|
||||
data = this.arrayBufferToString(decompressedBuffer);
|
||||
}
|
||||
|
||||
const message: IncommingWebsocketMessage = JSON.parse(data);
|
||||
console.debug('Received', message);
|
||||
const type = message.type;
|
||||
const inResponse = message.in_response;
|
||||
const callbacks = this.responseCallbacks[inResponse];
|
||||
if (callbacks) {
|
||||
delete this.responseCallbacks[inResponse];
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
if (!isWebsocketErrorContent(message.content)) {
|
||||
console.error('Websocket error without standard form!', message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Print this to the console.
|
||||
const error = message.content;
|
||||
const errorDescription =
|
||||
Object.keys(WEBSOCKET_ERROR_CODES).find(key => WEBSOCKET_ERROR_CODES[key] === error.code) ||
|
||||
'unknown code';
|
||||
console.error(`Websocket error with code=${error.code} (${errorDescription}):`, error.message);
|
||||
|
||||
// call the error callback, if there is any. If it returns true (means "handled"),
|
||||
// the errorResponseSubject will not be called
|
||||
if (inResponse && callbacks && callbacks[1] && callbacks[1](error)) {
|
||||
return;
|
||||
}
|
||||
this._errorResponseSubject.next(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to fire a response callback directly. If it returnes true, the message is handeled
|
||||
// and not distributed further
|
||||
if (inResponse && callbacks && callbacks[0](message.content)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.subjects[type]) {
|
||||
// Pass the content to the registered subscribers.
|
||||
this.subjects[type].next(message.content);
|
||||
} else {
|
||||
console.warn(
|
||||
`Got unknown websocket message type "${type}" (inResponse: ${inResponse}) with content`,
|
||||
message.content
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the connection error notice
|
||||
*/
|
||||
private onclose(): void {
|
||||
if (this.websocket) {
|
||||
this.websocketId = null; // set to null, so now further events will be
|
||||
// registered with the line below.
|
||||
this.websocket.close(); // Cleanup old connection
|
||||
this.websocket = null;
|
||||
}
|
||||
this._connectionOpen = false;
|
||||
// 1000 is a normal close, like the close on logout
|
||||
this._closeEvent.emit();
|
||||
if (!this.shouldBeClosed) {
|
||||
// Do not show the message snackbar on the projector
|
||||
// tests for /projector and /projector/<id>
|
||||
const onProjector = this.router.url.match(/^\/projector(\/[0-9]+\/?)?$/);
|
||||
if (this.retryCounter <= 3) {
|
||||
this.retryCounter++;
|
||||
}
|
||||
|
||||
if (!this.connectionErrorNotice && !onProjector && this.retryCounter > 3) {
|
||||
this.offlineService.goOfflineBecauseConnectionLost();
|
||||
}
|
||||
|
||||
// A random retry timeout between 2000 and 5000 ms.
|
||||
const timeout = Math.floor(Math.random() * 3000 + 2000);
|
||||
this.retryTimeout = setTimeout(() => {
|
||||
this.retryTimeout = null;
|
||||
this.connect({ enableAutoupdates: true }, true);
|
||||
}, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
public cancelReconnectenRetry(): void {
|
||||
if (this.retryTimeout) {
|
||||
clearTimeout(this.retryTimeout);
|
||||
this.retryTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the websocket connection.
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
this.shouldBeClosed = true;
|
||||
this.offlineService.goOnline();
|
||||
if (this.websocket) {
|
||||
this.websocket.close();
|
||||
this.websocket = null;
|
||||
await this.closeEvent.pipe(take(1)).toPromise();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates an abnormal close.
|
||||
*
|
||||
* Internally does not set `shouldBeClosed`, so a reconnect is forced.
|
||||
*/
|
||||
public simulateAbnormalClose(): void {
|
||||
this.onclose();
|
||||
}
|
||||
|
||||
/**
|
||||
* closes and reopens the connection. If the connection was closed before,
|
||||
* it will be just opened.
|
||||
*
|
||||
* @param options The options for the new connection
|
||||
*/
|
||||
public async reconnect(options: ConnectOptions = {}): Promise<void> {
|
||||
await this.close();
|
||||
await this.connect(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable for messages of the given type.
|
||||
* @param type the message type
|
||||
*/
|
||||
public getOberservable<T>(type: string): Observable<T> {
|
||||
if (!this.subjects[type]) {
|
||||
this.subjects[type] = new Subject<T>();
|
||||
}
|
||||
return this.subjects[type].asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the server with the content and the given type.
|
||||
*
|
||||
* @param type the message type
|
||||
* @param content the actual content
|
||||
* @param success an optional success callback for a response. If it returns true, the message will not be
|
||||
* propagated through the recieve subjects.
|
||||
* @param error an optional error callback for a response. If it returns true, the error will not be propagated
|
||||
* with the error subject.
|
||||
* @param id an optional id for the message. If not given, a random id will be generated and returned.
|
||||
* @returns the message id
|
||||
*/
|
||||
public send<T, R>(
|
||||
type: string,
|
||||
content: T,
|
||||
success?: (val: R) => boolean,
|
||||
error?: (error: WebsocketErrorContent) => boolean,
|
||||
id?: string
|
||||
): string {
|
||||
if (!this.websocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message: OutgoingWebsocketMessage = {
|
||||
type: type,
|
||||
content: content,
|
||||
id: id
|
||||
};
|
||||
|
||||
// create message id if not given. Required by the server.
|
||||
if (!message.id) {
|
||||
message.id = '';
|
||||
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
message.id += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
this.responseCallbacks[message.id] = [success, error];
|
||||
}
|
||||
|
||||
const jsonMessage = JSON.stringify(message);
|
||||
const bytesMessage = this.stringToBuffer(jsonMessage);
|
||||
|
||||
const compressedMessage: ArrayBuffer = compress(bytesMessage);
|
||||
const ratio = bytesMessage.byteLength / compressedMessage.byteLength;
|
||||
|
||||
const toSend = ratio > 1 ? compressedMessage : jsonMessage;
|
||||
|
||||
if (this.isConnected) {
|
||||
this.websocket.send(toSend);
|
||||
} else {
|
||||
this.sendQueueWhileNotConnected.push(toSend);
|
||||
}
|
||||
|
||||
return message.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message and waits for the response
|
||||
*
|
||||
* @param type the message type
|
||||
* @param content the actual content
|
||||
* @param id an optional id for the message. If not given, a random id will be generated and returned.
|
||||
*/
|
||||
public sendAndGetResponse<T, R>(type: string, content: T, id?: string): Promise<R> {
|
||||
return new Promise<R>((resolve, reject) => {
|
||||
this.send<T, R>(
|
||||
type,
|
||||
content,
|
||||
val => {
|
||||
resolve(val);
|
||||
return true;
|
||||
},
|
||||
val => {
|
||||
reject(val);
|
||||
return true;
|
||||
},
|
||||
id
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an ArrayBuffer to a String.
|
||||
*
|
||||
* @param buffer - Buffer to convert
|
||||
* @returns String
|
||||
*/
|
||||
private arrayBufferToString(buffer: Uint8Array): string {
|
||||
return Array.from(buffer)
|
||||
.map(code => String.fromCharCode(code))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a String to an ArrayBuffer.
|
||||
*
|
||||
* @param str - String to convert.
|
||||
* @returns bufferView.
|
||||
*/
|
||||
private stringToBuffer(str: string): Uint8Array {
|
||||
const bufferView = new Uint8Array();
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
bufferView[i] = str.charCodeAt(i);
|
||||
}
|
||||
return bufferView;
|
||||
}
|
||||
}
|
|
@ -2,11 +2,8 @@ import { CommonModule } from '@angular/common';
|
|||
import { NgModule, Optional, SkipSelf, Type } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
|
||||
import { ProjectionDialogComponent } from 'app/shared/components/projection-dialog/projection-dialog.component';
|
||||
import { ChoiceDialogComponent } from '../shared/components/choice-dialog/choice-dialog.component';
|
||||
import { OnAfterAppsLoaded } from './definitions/on-after-apps-loaded';
|
||||
import { OperatorService } from './core-services/operator.service';
|
||||
import { PromptDialogComponent } from '../shared/components/prompt-dialog/prompt-dialog.component';
|
||||
|
||||
export const ServicesToLoadOnAppsLoaded: Type<OnAfterAppsLoaded>[] = [OperatorService];
|
||||
|
||||
|
@ -15,8 +12,7 @@ export const ServicesToLoadOnAppsLoaded: Type<OnAfterAppsLoaded>[] = [OperatorSe
|
|||
*/
|
||||
@NgModule({
|
||||
imports: [CommonModule],
|
||||
providers: [Title],
|
||||
entryComponents: [PromptDialogComponent, ChoiceDialogComponent, ProjectionDialogComponent]
|
||||
providers: [Title]
|
||||
})
|
||||
export class CoreModule {
|
||||
/** make sure CoreModule is imported only by one NgModule, the AppModule */
|
||||
|
|
|
@ -7,7 +7,6 @@ import { MainMenuEntry } from '../core-services/main-menu.service';
|
|||
import { Searchable } from '../../site/base/searchable';
|
||||
|
||||
interface BaseModelEntry {
|
||||
collectionString: string;
|
||||
repository: Type<BaseRepository<any, any, any>>;
|
||||
model: ModelConstructor<BaseModel>;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
export interface AutoupdateFormat {
|
||||
/**
|
||||
* All changed (and created) items as their full/restricted data grouped by their collection.
|
||||
*/
|
||||
changed: {
|
||||
[collectionString: string]: object[];
|
||||
};
|
||||
|
||||
/**
|
||||
* All deleted items (by id) grouped by their collection.
|
||||
*/
|
||||
deleted: {
|
||||
[collectionString: string]: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The lower change id bond for this autoupdate
|
||||
*/
|
||||
from_change_id: number;
|
||||
|
||||
/**
|
||||
* The upper change id bound for this autoupdate
|
||||
*/
|
||||
to_change_id: number;
|
||||
|
||||
/**
|
||||
* Flag, if this autoupdate contains all data. If so, the DS needs to be resetted.
|
||||
*/
|
||||
all_data: boolean;
|
||||
}
|
||||
|
||||
export function isAutoupdateFormat(obj: any): obj is AutoupdateFormat {
|
||||
const format = obj as AutoupdateFormat;
|
||||
return (
|
||||
obj &&
|
||||
typeof obj === 'object' &&
|
||||
format.changed !== undefined &&
|
||||
format.deleted !== undefined &&
|
||||
format.from_change_id !== undefined &&
|
||||
format.to_change_id !== undefined &&
|
||||
format.all_data !== undefined
|
||||
);
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
|
||||
|
||||
/**
|
||||
* Define custom error classes here
|
||||
*/
|
||||
|
||||
export class PreventedInDemo extends Error {
|
||||
public constructor(message: string = _('Cannot do that in demo mode!'), name: string = 'Error') {
|
||||
super(message);
|
||||
this.name = name;
|
||||
Object.setPrototypeOf(this, PreventedInDemo.prototype);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface HasViewModelListObservable<V> {
|
||||
getViewModelListObservable(): Observable<V[]>;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Enum for different HTTPMethods
|
||||
*/
|
||||
export enum HTTPMethod {
|
||||
GET = 'get',
|
||||
POST = 'post',
|
||||
PUT = 'put',
|
||||
PATCH = 'patch',
|
||||
DELETE = 'delete'
|
||||
}
|
|
@ -32,11 +32,6 @@ export class HtmlToPdfService {
|
|||
*/
|
||||
private lineNumberingMode: LineNumberingMode;
|
||||
|
||||
/**
|
||||
* Space between list elements
|
||||
*/
|
||||
private LI_MARGIN_BOTTOM = 8;
|
||||
|
||||
/**
|
||||
* Normal line height for paragraphs
|
||||
*/
|
||||
|
@ -176,6 +171,9 @@ export class HtmlToPdfService {
|
|||
docDef.push(parsedElement);
|
||||
}
|
||||
|
||||
// DEBUG: printing the following. Do not remove, just comment out
|
||||
// console.log('MakePDF doc :\n---\n', JSON.stringify(docDef), '\n---\n');
|
||||
|
||||
return docDef;
|
||||
}
|
||||
|
||||
|
@ -189,6 +187,8 @@ export class HtmlToPdfService {
|
|||
*/
|
||||
public parseElement(element: Element, styles?: string[]): any {
|
||||
const nodeName = element.nodeName.toLowerCase();
|
||||
const childNodes = Array.from(element.childNodes) as Element[];
|
||||
const directChildIsCrNode = childNodes.some(child => this.isCrElement(child));
|
||||
let classes = [];
|
||||
let newParagraph: any;
|
||||
|
||||
|
@ -234,7 +234,12 @@ export class HtmlToPdfService {
|
|||
case 'div': {
|
||||
const children = this.parseChildren(element, styles);
|
||||
|
||||
if (this.lineNumberingMode === LineNumberingMode.Outside && !classes.includes('insert')) {
|
||||
if (
|
||||
this.lineNumberingMode === LineNumberingMode.Outside &&
|
||||
!classes.includes('insert') &&
|
||||
!(nodeName === 'li' && directChildIsCrNode)
|
||||
) {
|
||||
//
|
||||
newParagraph = this.create('stack');
|
||||
newParagraph.stack = children;
|
||||
} else {
|
||||
|
@ -273,6 +278,26 @@ export class HtmlToPdfService {
|
|||
...this.computeStyle(styles),
|
||||
...this.computeStyle(this.elementStyles[nodeName])
|
||||
};
|
||||
// if the ol list has specific list type
|
||||
if (nodeName === 'li' && element.parentNode.nodeName === 'OL') {
|
||||
const type = element.parentElement.getAttribute('type');
|
||||
switch (type) {
|
||||
case 'a':
|
||||
newParagraph.listType = 'lower-alpha';
|
||||
break;
|
||||
case 'A':
|
||||
newParagraph.listType = 'upper-alpha';
|
||||
break;
|
||||
case 'i':
|
||||
newParagraph.listType = 'lower-roman';
|
||||
break;
|
||||
case 'I':
|
||||
newParagraph.listType = 'upper-roman';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'a':
|
||||
|
@ -364,7 +389,7 @@ export class HtmlToPdfService {
|
|||
|
||||
// if this is a "fake list" lower put it close to the element above
|
||||
if (this.isFakeList(element)) {
|
||||
listCol.margin[3] = -this.LI_MARGIN_BOTTOM;
|
||||
listCol.margin[3] = -this.P_MARGIN_BOTTOM;
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
|
@ -458,7 +483,6 @@ export class HtmlToPdfService {
|
|||
// Add a blank with the normal font size here, so in rare cases the text
|
||||
// is rendered on the next page and the line number on the previous page.
|
||||
text: ' ',
|
||||
fontSize: 10,
|
||||
decoration: ''
|
||||
},
|
||||
{
|
||||
|
@ -499,7 +523,7 @@ export class HtmlToPdfService {
|
|||
children[i].remove();
|
||||
}
|
||||
|
||||
if (children[i].childNodes.length > 0) {
|
||||
if (children[i]?.childNodes.length > 0) {
|
||||
const cleanChildren = this.cleanLineNumbers(children[i] as Element);
|
||||
elementCopy.replaceChild(cleanChildren, children[i]);
|
||||
}
|
||||
|
@ -540,10 +564,8 @@ export class HtmlToPdfService {
|
|||
}
|
||||
}
|
||||
|
||||
// If this is an list item, add some space to the lineNumbers:
|
||||
if (childrenLineNumbers.length && element.nodeName === 'LI') {
|
||||
childrenLineNumbers[childrenLineNumbers.length - 1].marginBottom = this.LI_MARGIN_BOTTOM;
|
||||
} else if (childrenLineNumbers.length && element.parentNode.nodeName === 'LI') {
|
||||
// if the found element is a list item, add some spacing
|
||||
if (childrenLineNumbers.length && (element.nodeName === 'LI' || element.parentNode.nodeName === 'LI')) {
|
||||
childrenLineNumbers[childrenLineNumbers.length - 1].marginBottom = this.P_MARGIN_BOTTOM;
|
||||
}
|
||||
|
||||
|
@ -618,10 +640,7 @@ export class HtmlToPdfService {
|
|||
const styleObject: any = {};
|
||||
if (styles && styles.length > 0) {
|
||||
for (const style of styles) {
|
||||
const styleDefinition = style
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(':');
|
||||
const styleDefinition = style.trim().toLowerCase().split(':');
|
||||
const key = styleDefinition[0];
|
||||
const value = styleDefinition[1];
|
||||
|
||||
|
@ -692,6 +711,16 @@ export class HtmlToPdfService {
|
|||
return styleObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the given element is a cr exclusive node
|
||||
* @param child
|
||||
*/
|
||||
private isCrElement(element: Element): boolean {
|
||||
const nodeName = element.nodeName.toLowerCase();
|
||||
const crNodeNames = ['ins', 'del'];
|
||||
return crNodeNames.includes(nodeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the color in a hex format (e.g. #12ff00).
|
||||
* Also tries to convert RGB colors into hex values
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
@ -47,8 +46,8 @@ export class PdfError extends Error {
|
|||
* Provides the general document structure for PDF documents, such as page margins, header, footer and styles.
|
||||
* Also provides general purpose open and download functions.
|
||||
*
|
||||
* Use a local pdf service (i.e. MotionPdfService) to get the document definition for the content and use this service to
|
||||
* open or download the pdf document
|
||||
* Use a local pdf service (i.e. MotionPdfService) to get the document definition for the content and
|
||||
* use this service to open or download the pdf document
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
|
@ -100,7 +99,7 @@ export class PdfDocumentService {
|
|||
);
|
||||
|
||||
const promises = fontPathList.map(fontPath => {
|
||||
return this.convertUrlToBase64(fontPath).then(base64 => {
|
||||
return this.httpService.downloadAsBase64(fontPath).then(base64 => {
|
||||
return {
|
||||
[fontPath.split('/').pop()]: base64
|
||||
};
|
||||
|
@ -117,29 +116,6 @@ export class PdfDocumentService {
|
|||
return vfs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a binary file from the url and returns a base64 value
|
||||
*
|
||||
* @param url file url
|
||||
* @returns a promise with a base64 string
|
||||
*/
|
||||
private async convertUrlToBase64(url: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const headers = new HttpHeaders();
|
||||
this.httpService.get<Blob>(url, {}, {}, headers, 'blob').then(file => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
const resultStr: string = reader.result as string;
|
||||
resolve(resultStr.split(',')[1]);
|
||||
};
|
||||
reader.onerror = error => {
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of a font from the value of the given
|
||||
* config variable.
|
||||
|
@ -256,14 +232,11 @@ export class PdfDocumentService {
|
|||
if (logoHeaderLeftUrl && logoHeaderRightUrl) {
|
||||
text = '';
|
||||
} else {
|
||||
const general_event_name = this.configService.instant<string>('general_event_name');
|
||||
const general_event_description = this.configService.instant<string>('general_event_description');
|
||||
const line1 = [
|
||||
this.translate.instant(general_event_name),
|
||||
this.translate.instant(general_event_description)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' – ');
|
||||
const general_event_name = this.translate.instant(this.configService.instant<string>('general_event_name'));
|
||||
const general_event_description = this.translate.instant(
|
||||
this.configService.instant<string>('general_event_description')
|
||||
);
|
||||
const line1 = [general_event_name, general_event_description].filter(Boolean).join(' - ');
|
||||
const line2 = [
|
||||
this.configService.instant('general_event_location'),
|
||||
this.configService.instant('general_event_date')
|
||||
|
@ -469,7 +442,7 @@ export class PdfDocumentService {
|
|||
|
||||
const isIE = /msie\s|trident\//i.test(window.navigator.userAgent);
|
||||
if (typeof Worker !== 'undefined' && !isIE) {
|
||||
this.pdfWorker = new Worker('./pdf-worker.worker', {
|
||||
this.pdfWorker = new Worker(new URL('./pdf-worker.worker', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
|
||||
|
@ -668,7 +641,7 @@ export class PdfDocumentService {
|
|||
}
|
||||
|
||||
if (!vfs[url]) {
|
||||
const base64 = await this.convertUrlToBase64(url);
|
||||
const base64 = await this.httpService.downloadAsBase64(url);
|
||||
vfs[url] = base64;
|
||||
}
|
||||
}
|
||||
|
@ -712,6 +685,13 @@ export class PdfDocumentService {
|
|||
};
|
||||
}
|
||||
|
||||
public getSpacer(): Object {
|
||||
return {
|
||||
text: '',
|
||||
margin: [0, 10]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the table definition for the TOC
|
||||
*
|
||||
|
|
|
@ -77,10 +77,14 @@ function addPageNumbers(data: any): void {
|
|||
|
||||
data.doc.footer = (currentPage, pageCount) => {
|
||||
const footer = data.doc.tmpfooter;
|
||||
|
||||
// if the tmpfooter starts with an image, the pagenumber will be found in column 1
|
||||
const pageNumberColIndex = !!footer.columns[0].image ? 1 : 0;
|
||||
|
||||
// "%PAGENR% needs to be found once. After that, the same position should always update page numbers"
|
||||
if (footer.columns[0].stack[0] === '%PAGENR%' || countPageNumbers) {
|
||||
if (footer.columns[pageNumberColIndex]?.stack[0] === '%PAGENR%' || countPageNumbers) {
|
||||
countPageNumbers = true;
|
||||
footer.columns[0].stack[0] = `${currentPage} / ${pageCount}`;
|
||||
footer.columns[pageNumberColIndex].stack[0] = `${currentPage} / ${pageCount}`;
|
||||
}
|
||||
return footer;
|
||||
};
|
||||
|
|
|
@ -14,16 +14,16 @@
|
|||
* ```
|
||||
*/
|
||||
export class Deferred<T = void> extends Promise<T> {
|
||||
/**
|
||||
* The promise to wait for
|
||||
*/
|
||||
public readonly promise: Promise<T>;
|
||||
|
||||
/**
|
||||
* custom resolve function
|
||||
*/
|
||||
private _resolve: (val?: T) => void;
|
||||
|
||||
private _wasResolved;
|
||||
public get wasResolved(): boolean {
|
||||
return this._wasResolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the promise and overloads the resolve function
|
||||
*/
|
||||
|
@ -32,6 +32,7 @@ export class Deferred<T = void> extends Promise<T> {
|
|||
super(resolve => {
|
||||
preResolve = resolve;
|
||||
});
|
||||
this._wasResolved = false;
|
||||
this._resolve = preResolve;
|
||||
}
|
||||
|
||||
|
@ -40,5 +41,6 @@ export class Deferred<T = void> extends Promise<T> {
|
|||
*/
|
||||
public resolve(val?: T): void {
|
||||
this._resolve(val);
|
||||
this._wasResolved = true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* A mutex as described in every textbook
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* mutex = new Mutex(); // create e.g. as class member
|
||||
*
|
||||
* // Somewhere in the code to lock (must be async code!)
|
||||
* const unlock = await this.mutex.lock()
|
||||
* // ...the code to synchronize
|
||||
* unlock()
|
||||
* ```
|
||||
*/
|
||||
export class Mutex {
|
||||
private mutex = Promise.resolve();
|
||||
|
||||
public lock(): PromiseLike<() => void> {
|
||||
// this will capture the code-to-synchronize
|
||||
let begin: (unlock: () => void) => void = () => {};
|
||||
|
||||
// All "requests" to execute code are chained in a promise-chain
|
||||
this.mutex = this.mutex.then(() => {
|
||||
return new Promise(begin);
|
||||
});
|
||||
|
||||
return new Promise(res => {
|
||||
begin = res;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Wraps a promise and let it reject after the given timeout (in ms), if it was
|
||||
* not resolved before this timeout.
|
||||
*
|
||||
* @param delay The time to sleep in miliseconds
|
||||
* @returns a new Promise
|
||||
*/
|
||||
export function SleepPromise(delay: number): Promise<void> {
|
||||
return new Promise((resolve, _) => setTimeout(resolve, delay));
|
||||
}
|
|
@ -14,11 +14,13 @@ import { Identifiable } from 'app/shared/models/base/identifiable';
|
|||
import { ItemTitleInformation, ViewItem } from 'app/site/agenda/models/view-item';
|
||||
import { ViewAssignment } from 'app/site/assignments/models/view-assignment';
|
||||
import {
|
||||
AgendaListTitle,
|
||||
BaseViewModelWithAgendaItem,
|
||||
isBaseViewModelWithAgendaItem
|
||||
} from 'app/site/base/base-view-model-with-agenda-item';
|
||||
import { ViewMotion } from 'app/site/motions/models/view-motion';
|
||||
import { ViewMotionBlock } from 'app/site/motions/models/view-motion-block';
|
||||
import { ViewTag } from 'app/site/tags/models/view-tag';
|
||||
import { ViewTopic } from 'app/site/topics/models/view-topic';
|
||||
import { BaseHasContentObjectRepository } from '../base-has-content-object-repository';
|
||||
import { BaseIsAgendaItemContentObjectRepository } from '../base-is-agenda-item-content-object-repository';
|
||||
|
@ -33,6 +35,12 @@ const ItemRelations: RelationDefinition[] = [
|
|||
VForeignVerbose: 'BaseViewModelWithAgendaItem',
|
||||
ownContentObjectDataKey: 'contentObjectData',
|
||||
ownKey: 'contentObject'
|
||||
},
|
||||
{
|
||||
type: 'M2M',
|
||||
ownIdKey: 'tags_id',
|
||||
ownKey: 'tags',
|
||||
foreignViewModel: ViewTag
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -79,7 +87,7 @@ export class ItemRepositoryService extends BaseHasContentObjectRepository<
|
|||
return this.translate.instant(plural ? 'Items' : 'Item');
|
||||
};
|
||||
|
||||
public getTitle = (titleInformation: ItemTitleInformation) => {
|
||||
private getAgendaTitle(titleInformation: ItemTitleInformation): AgendaListTitle {
|
||||
if (titleInformation.contentObject) {
|
||||
return titleInformation.contentObject.getAgendaListTitle();
|
||||
} else {
|
||||
|
@ -88,36 +96,14 @@ export class ItemRepositoryService extends BaseHasContentObjectRepository<
|
|||
) as BaseIsAgendaItemContentObjectRepository<any, any, any>;
|
||||
return repo.getAgendaListTitle(titleInformation.title_information);
|
||||
}
|
||||
}
|
||||
|
||||
public getTitle = (titleInformation: ItemTitleInformation) => {
|
||||
return this.getAgendaTitle(titleInformation).title;
|
||||
};
|
||||
|
||||
/**
|
||||
* Overrides the base function, if implemented.
|
||||
*
|
||||
* @returns An optional subtitle as `string`. Defaults to `null`.
|
||||
*/
|
||||
public getSubtitle = (viewItem: ViewItem) => {
|
||||
if (viewItem.contentObject) {
|
||||
return viewItem.contentObject.getAgendaSubtitle();
|
||||
} else {
|
||||
// The subtitle is not present in the title_information yet.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Overrides the base function.
|
||||
*
|
||||
* @returns The title without any prefix like item number.
|
||||
*/
|
||||
public getTitleWithoutItemNumber = (titleInformation: ItemTitleInformation) => {
|
||||
if (titleInformation.contentObject) {
|
||||
return titleInformation.contentObject.getAgendaListTitleWithoutItemNumber();
|
||||
} else {
|
||||
const repo = this.collectionStringMapperService.getRepository(
|
||||
titleInformation.contentObjectData.collection
|
||||
) as BaseIsAgendaItemContentObjectRepository<any, any, any>;
|
||||
return repo.getAgendaListTitleWithoutItemNumber(titleInformation.title_information);
|
||||
}
|
||||
public getSubtitle = (titleInformation: ItemTitleInformation) => {
|
||||
return this.getAgendaTitle(titleInformation).subtitle;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -62,6 +62,17 @@ const ListOfSpeakersNestedModelDescriptors: NestedModelDescriptors = {
|
|||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* An object, that contains information about structure-level,
|
||||
* speaking-time and finished-speakers.
|
||||
* Helpful to get a relation between speakers and their structure-level.
|
||||
*/
|
||||
export interface SpeakingTimeStructureLevelObject {
|
||||
structureLevel: string;
|
||||
finishedSpeakers: ViewSpeaker[];
|
||||
speakingTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository service for lists of speakers
|
||||
*
|
||||
|
@ -132,6 +143,14 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
|
|||
}
|
||||
};
|
||||
|
||||
public async delete(viewModel: ViewListOfSpeakers): Promise<void> {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
|
||||
public async create(model: ListOfSpeakers): Promise<Identifiable> {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new speaker to a list of speakers.
|
||||
* Sends the users id to the server
|
||||
|
@ -139,9 +158,14 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
|
|||
* @param userId {@link User} id of the new speaker
|
||||
* @param listOfSpeakers the target agenda item
|
||||
*/
|
||||
public async createSpeaker(listOfSpeakers: ViewListOfSpeakers, userId: number): Promise<Identifiable> {
|
||||
public async createSpeaker(
|
||||
listOfSpeakers: ViewListOfSpeakers,
|
||||
userId: number,
|
||||
pointOfOrder?: boolean,
|
||||
note?: string
|
||||
): Promise<Identifiable> {
|
||||
const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker');
|
||||
return await this.httpService.post<Identifiable>(restUrl, { user: userId });
|
||||
return await this.httpService.post<Identifiable>(restUrl, { user: userId, point_of_order: pointOfOrder, note });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -151,9 +175,20 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
|
|||
* @param speakerId (otional) the speakers id. If no id is given, the speaker with the
|
||||
* current operator is removed.
|
||||
*/
|
||||
public async delete(listOfSpeakers: ViewListOfSpeakers, speakerId?: number): Promise<void> {
|
||||
public async deleteSpeaker(
|
||||
listOfSpeakers: ViewListOfSpeakers,
|
||||
speakerId?: number,
|
||||
pointOfOrder?: boolean
|
||||
): Promise<void> {
|
||||
const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker');
|
||||
await this.httpService.delete(restUrl, speakerId ? { speaker: speakerId } : null);
|
||||
const payload: { speaker?: number; point_of_order?: boolean } = {};
|
||||
if (speakerId) {
|
||||
payload.speaker = speakerId;
|
||||
}
|
||||
if (pointOfOrder) {
|
||||
payload.point_of_order = pointOfOrder;
|
||||
}
|
||||
await this.httpService.delete(restUrl, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -169,8 +204,8 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
|
|||
/**
|
||||
* Posts an (manually) sorted speaker list to the server
|
||||
*
|
||||
* @param listOfSpeakers the target list of speakers, which speaker-list is changed.
|
||||
* @param speakerIds array of speaker id numbers
|
||||
* @param Item the target agenda item
|
||||
*/
|
||||
public async sortSpeakers(listOfSpeakers: ViewListOfSpeakers, speakerIds: number[]): Promise<void> {
|
||||
const restUrl = this.getRestUrl(listOfSpeakers.id, 'sort_speakers');
|
||||
|
@ -188,15 +223,14 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
|
|||
}
|
||||
|
||||
/**
|
||||
* Marks all speakers for a given user
|
||||
*
|
||||
* @param userId {@link User} id of the user
|
||||
* @param marked determine if the user should be marked or not
|
||||
* @param listOfSpeakers the target list of speakers
|
||||
* Toggles the mark for a given speaker.
|
||||
*/
|
||||
public async markSpeaker(listOfSpeakers: ViewListOfSpeakers, speaker: ViewSpeaker, marked: boolean): Promise<void> {
|
||||
const restUrl = this.getRestUrl(listOfSpeakers.id, 'manage_speaker');
|
||||
await this.httpService.patch(restUrl, { user: speaker.user.id, marked: marked });
|
||||
public async toggleMarked(speaker: ViewSpeaker): Promise<void> {
|
||||
await this.httpService.put(`/rest/agenda/speaker/${speaker.id}/`, { marked: !speaker.marked });
|
||||
}
|
||||
|
||||
public async setProContraSpeech(speaker: ViewSpeaker, proSpeech: boolean | null): Promise<void> {
|
||||
await this.httpService.put(`/rest/agenda/speaker/${speaker.id}/`, { pro_speech: proSpeech });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -220,6 +254,115 @@ export class ListOfSpeakersRepositoryService extends BaseHasContentObjectReposit
|
|||
await this.httpService.put(restUrl, { speaker: speaker.id });
|
||||
}
|
||||
|
||||
public async deleteAllSpeakersOfAllListsOfSpeakers(): Promise<void> {
|
||||
await this.httpService.post('/rest/agenda/list-of-speakers/delete_all_speakers/');
|
||||
}
|
||||
|
||||
public isFirstContribution(speaker: ViewSpeaker): boolean {
|
||||
return !this.getViewModelList().some(list => list.hasSpeakerSpoken(speaker));
|
||||
}
|
||||
|
||||
public async setListOpenness(listOfSpeakers: ViewListOfSpeakers, open: boolean): Promise<void> {
|
||||
await this.update({ closed: !open }, listOfSpeakers);
|
||||
}
|
||||
|
||||
/**
|
||||
* List every speaker only once, who has spoken
|
||||
*
|
||||
* @returns A list with all different speakers.
|
||||
*/
|
||||
public getAllFirstContributions(): ViewSpeaker[] {
|
||||
const speakers: ViewSpeaker[] = this.getViewModelList().flatMap(
|
||||
(los: ViewListOfSpeakers) => los.finishedSpeakers
|
||||
);
|
||||
const firstContributions: ViewSpeaker[] = [];
|
||||
for (const speaker of speakers) {
|
||||
if (!firstContributions.find(s => s.user_id === speaker.user_id)) {
|
||||
firstContributions.push(speaker);
|
||||
}
|
||||
}
|
||||
return firstContributions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the amount of point of orders (sync)
|
||||
*/
|
||||
public getPooAmount(): number {
|
||||
const speakers: ViewSpeaker[] = this.getViewModelList()
|
||||
.flatMap((los: ViewListOfSpeakers) => los.finishedSpeakers)
|
||||
.filter(speaker => speaker.point_of_order);
|
||||
return speakers.length || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps structure-level to speaker.
|
||||
*
|
||||
* @returns A list, which entries are `SpeakingTimeStructureLevelObject`.
|
||||
*/
|
||||
public getSpeakingTimeStructureLevelRelation(): SpeakingTimeStructureLevelObject[] {
|
||||
let listSpeakingTimeStructureLevel: SpeakingTimeStructureLevelObject[] = [];
|
||||
for (const los of this.getViewModelList()) {
|
||||
for (const speaker of los.finishedSpeakers) {
|
||||
const nextEntry = this.getSpeakingTimeStructureLevelObject(speaker);
|
||||
listSpeakingTimeStructureLevel = this.getSpeakingTimeStructureLevelList(
|
||||
nextEntry,
|
||||
listSpeakingTimeStructureLevel
|
||||
);
|
||||
}
|
||||
}
|
||||
return listSpeakingTimeStructureLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper-function to create a `SpeakingTimeStructureLevelObject` by a given speaker.
|
||||
*
|
||||
* @param speaker, with whom structure-level and speaking-time is calculated.
|
||||
*
|
||||
* @returns The created `SpeakingTimeStructureLevelObject`.
|
||||
*/
|
||||
private getSpeakingTimeStructureLevelObject(speaker: ViewSpeaker): SpeakingTimeStructureLevelObject {
|
||||
return {
|
||||
structureLevel:
|
||||
!speaker.user || (speaker.user && !speaker.user.structure_level) ? '–' : speaker.user.structure_level,
|
||||
finishedSpeakers: [speaker],
|
||||
speakingTime: this.getSpeakingTimeAsNumber(speaker)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper-function to update entries in a given list, if already existing, or create entries otherwise.
|
||||
*
|
||||
* @param object A `SpeakingTimeStructureLevelObject`, that contains information about speaking-time
|
||||
* and structure-level.
|
||||
* @param list A list, at which speaking-time, structure-level and finished_speakers are set.
|
||||
*
|
||||
* @returns The updated map.
|
||||
*/
|
||||
private getSpeakingTimeStructureLevelList(
|
||||
object: SpeakingTimeStructureLevelObject,
|
||||
list: SpeakingTimeStructureLevelObject[]
|
||||
): SpeakingTimeStructureLevelObject[] {
|
||||
const index = list.findIndex(entry => entry.structureLevel === object.structureLevel);
|
||||
if (index >= 0) {
|
||||
list[index].speakingTime += object.speakingTime;
|
||||
list[index].finishedSpeakers.push(...object.finishedSpeakers);
|
||||
} else {
|
||||
list.push(object);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function calculates speaking-time as number for a given speaker.
|
||||
*
|
||||
* @param speaker The speaker, whose speaking-time should be calculated.
|
||||
*
|
||||
* @returns A number, that represents the speaking-time.
|
||||
*/
|
||||
private getSpeakingTimeAsNumber(speaker: ViewSpeaker): number {
|
||||
return Math.floor((new Date(speaker.end_time).valueOf() - new Date(speaker.begin_time).valueOf()) / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function get the url to the speaker rest address
|
||||
*
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { E2EImportsModule } from 'e2e-imports.module';
|
||||
|
||||
import { AssignmentOptionRepositoryService } from './assignment-option-repository.service';
|
||||
|
||||
describe('AssignmentOptionRepositoryService', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] }));
|
||||
|
||||
it('should be created', () => {
|
||||
const service: AssignmentOptionRepositoryService = TestBed.inject(AssignmentOptionRepositoryService);
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,75 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { DataSendService } from 'app/core/core-services/data-send.service';
|
||||
import { RelationManagerService } from 'app/core/core-services/relation-manager.service';
|
||||
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
|
||||
import { RelationDefinition } from 'app/core/definitions/relations';
|
||||
import { AssignmentOption } from 'app/shared/models/assignments/assignment-option';
|
||||
import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
|
||||
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
|
||||
import { ViewAssignmentVote } from 'app/site/assignments/models/view-assignment-vote';
|
||||
import { ViewUser } from 'app/site/users/models/view-user';
|
||||
import { BaseRepository } from '../base-repository';
|
||||
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
|
||||
import { DataStoreService } from '../../core-services/data-store.service';
|
||||
|
||||
const AssignmentOptionRelations: RelationDefinition[] = [
|
||||
{
|
||||
type: 'O2M',
|
||||
foreignIdKey: 'option_id',
|
||||
ownKey: 'votes',
|
||||
foreignViewModel: ViewAssignmentVote
|
||||
},
|
||||
{
|
||||
type: 'M2O',
|
||||
ownIdKey: 'poll_id',
|
||||
ownKey: 'poll',
|
||||
foreignViewModel: ViewAssignmentPoll
|
||||
},
|
||||
{
|
||||
type: 'M2O',
|
||||
ownIdKey: 'user_id',
|
||||
ownKey: 'user',
|
||||
foreignViewModel: ViewUser
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Repository Service for Options.
|
||||
*
|
||||
* Documentation partially provided in {@link BaseRepository}
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AssignmentOptionRepositoryService extends BaseRepository<ViewAssignmentOption, AssignmentOption, object> {
|
||||
public constructor(
|
||||
DS: DataStoreService,
|
||||
dataSend: DataSendService,
|
||||
mapperService: CollectionStringMapperService,
|
||||
viewModelStoreService: ViewModelStoreService,
|
||||
translate: TranslateService,
|
||||
relationManager: RelationManagerService
|
||||
) {
|
||||
super(
|
||||
DS,
|
||||
dataSend,
|
||||
mapperService,
|
||||
viewModelStoreService,
|
||||
translate,
|
||||
relationManager,
|
||||
AssignmentOption,
|
||||
AssignmentOptionRelations
|
||||
);
|
||||
}
|
||||
|
||||
public getTitle = (titleInformation: object) => {
|
||||
return 'Option';
|
||||
};
|
||||
|
||||
public getVerboseName = (plural: boolean = false) => {
|
||||
return this.translate.instant(plural ? 'Options' : 'Option');
|
||||
};
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { E2EImportsModule } from 'e2e-imports.module';
|
||||
|
||||
import { AssignmentPollRepositoryService } from './assignment-poll-repository.service';
|
||||
|
||||
describe('AssignmentPollRepositoryService', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] }));
|
||||
|
||||
it('should be created', () => {
|
||||
const service: AssignmentPollRepositoryService = TestBed.inject(AssignmentPollRepositoryService);
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,133 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { DataSendService } from 'app/core/core-services/data-send.service';
|
||||
import { HttpService } from 'app/core/core-services/http.service';
|
||||
import { RelationManagerService } from 'app/core/core-services/relation-manager.service';
|
||||
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
|
||||
import { RelationDefinition } from 'app/core/definitions/relations';
|
||||
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
|
||||
import { UserVote } from 'app/shared/models/poll/base-vote';
|
||||
import { ViewAssignment } from 'app/site/assignments/models/view-assignment';
|
||||
import { ViewAssignmentOption } from 'app/site/assignments/models/view-assignment-option';
|
||||
import { AssignmentPollTitleInformation, ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
|
||||
import { BasePollRepositoryService } from 'app/site/polls/services/base-poll-repository.service';
|
||||
import { ViewGroup } from 'app/site/users/models/view-group';
|
||||
import { ViewUser } from 'app/site/users/models/view-user';
|
||||
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
|
||||
import { DataStoreService } from '../../core-services/data-store.service';
|
||||
|
||||
const AssignmentPollRelations: RelationDefinition[] = [
|
||||
{
|
||||
type: 'M2M',
|
||||
ownIdKey: 'groups_id',
|
||||
ownKey: 'groups',
|
||||
foreignViewModel: ViewGroup
|
||||
},
|
||||
{
|
||||
type: 'O2M',
|
||||
ownIdKey: 'options_id',
|
||||
ownKey: 'options',
|
||||
foreignViewModel: ViewAssignmentOption,
|
||||
order: 'weight'
|
||||
},
|
||||
{
|
||||
type: 'M2O',
|
||||
ownIdKey: 'assignment_id',
|
||||
ownKey: 'assignment',
|
||||
foreignViewModel: ViewAssignment
|
||||
},
|
||||
{
|
||||
type: 'M2M',
|
||||
ownIdKey: 'voted_id',
|
||||
ownKey: 'voted',
|
||||
foreignViewModel: ViewUser
|
||||
}
|
||||
];
|
||||
|
||||
export interface AssignmentAnalogVoteData {
|
||||
options: {
|
||||
[key: number]: {
|
||||
Y: number;
|
||||
N?: number;
|
||||
A?: number;
|
||||
};
|
||||
};
|
||||
votesvalid?: number;
|
||||
votesinvalid?: number;
|
||||
votescast?: number;
|
||||
global_yes?: number;
|
||||
global_no?: number;
|
||||
global_abstain?: number;
|
||||
}
|
||||
|
||||
export interface VotingData {
|
||||
votes: Object;
|
||||
global?: GlobalVote;
|
||||
}
|
||||
|
||||
export type GlobalVote = 'A' | 'N';
|
||||
|
||||
/**
|
||||
* Repository Service for Assignments.
|
||||
*
|
||||
* Documentation partially provided in {@link BaseRepository}
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AssignmentPollRepositoryService extends BasePollRepositoryService<
|
||||
ViewAssignmentPoll,
|
||||
AssignmentPoll,
|
||||
AssignmentPollTitleInformation
|
||||
> {
|
||||
/**
|
||||
* Constructor for the Assignment Repository.
|
||||
*
|
||||
* @param DS DataStore access
|
||||
* @param dataSend Sending data
|
||||
* @param mapperService Map models to object
|
||||
* @param viewModelStoreService Access view models
|
||||
* @param translate Translate string
|
||||
* @param httpService make HTTP Requests
|
||||
*/
|
||||
public constructor(
|
||||
DS: DataStoreService,
|
||||
dataSend: DataSendService,
|
||||
mapperService: CollectionStringMapperService,
|
||||
viewModelStoreService: ViewModelStoreService,
|
||||
translate: TranslateService,
|
||||
relationManager: RelationManagerService,
|
||||
http: HttpService
|
||||
) {
|
||||
super(
|
||||
DS,
|
||||
dataSend,
|
||||
mapperService,
|
||||
viewModelStoreService,
|
||||
translate,
|
||||
relationManager,
|
||||
AssignmentPoll,
|
||||
AssignmentPollRelations,
|
||||
{},
|
||||
http
|
||||
);
|
||||
}
|
||||
|
||||
public getTitle = (titleInformation: AssignmentPollTitleInformation) => {
|
||||
return titleInformation.title;
|
||||
};
|
||||
|
||||
public getVerboseName = (plural: boolean = false) => {
|
||||
return this.translate.instant(plural ? 'Polls' : 'Poll');
|
||||
};
|
||||
|
||||
public vote(data: VotingData, poll_id: number, userId?: number): Promise<void> {
|
||||
const requestData: UserVote = {
|
||||
data: data.global ?? data.votes,
|
||||
user_id: userId ?? undefined
|
||||
};
|
||||
return this.http.post(`/rest/assignments/assignment-poll/${poll_id}/vote/`, requestData);
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ describe('AssignmentRepositoryService', () => {
|
|||
beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] }));
|
||||
|
||||
it('should be created', () => {
|
||||
const service: AssignmentRepositoryService = TestBed.get(AssignmentRepositoryService);
|
||||
const service: AssignmentRepositoryService = TestBed.inject(AssignmentRepositoryService);
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,12 +8,9 @@ import { RelationManagerService } from 'app/core/core-services/relation-manager.
|
|||
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
|
||||
import { RelationDefinition } from 'app/core/definitions/relations';
|
||||
import { Assignment } from 'app/shared/models/assignments/assignment';
|
||||
import { AssignmentPoll } from 'app/shared/models/assignments/assignment-poll';
|
||||
import { AssignmentPollOption } from 'app/shared/models/assignments/assignment-poll-option';
|
||||
import { AssignmentRelatedUser } from 'app/shared/models/assignments/assignment-related-user';
|
||||
import { AssignmentTitleInformation, ViewAssignment } from 'app/site/assignments/models/view-assignment';
|
||||
import { ViewAssignmentPoll } from 'app/site/assignments/models/view-assignment-poll';
|
||||
import { ViewAssignmentPollOption } from 'app/site/assignments/models/view-assignment-poll-option';
|
||||
import { ViewAssignmentRelatedUser } from 'app/site/assignments/models/view-assignment-related-user';
|
||||
import { ViewMediafile } from 'app/site/mediafiles/models/view-mediafile';
|
||||
import { ViewTag } from 'app/site/tags/models/view-tag';
|
||||
|
@ -35,6 +32,12 @@ const AssignmentRelations: RelationDefinition[] = [
|
|||
ownIdKey: 'attachments_id',
|
||||
ownKey: 'attachments',
|
||||
foreignViewModel: ViewMediafile
|
||||
},
|
||||
{
|
||||
type: 'O2M',
|
||||
ownKey: 'polls',
|
||||
foreignIdKey: 'assignment_id',
|
||||
foreignViewModel: ViewAssignmentPoll
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -57,28 +60,6 @@ const AssignmentNestedModelDescriptors: NestedModelDescriptors = {
|
|||
getTitle: (viewAssignmentRelatedUser: ViewAssignmentRelatedUser) =>
|
||||
viewAssignmentRelatedUser.user ? viewAssignmentRelatedUser.user.getFullName() : ''
|
||||
}
|
||||
},
|
||||
{
|
||||
ownKey: 'polls',
|
||||
foreignViewModel: ViewAssignmentPoll,
|
||||
foreignModel: AssignmentPoll,
|
||||
relationDefinitionsByKey: {}
|
||||
}
|
||||
],
|
||||
'assignments/assignment-poll': [
|
||||
{
|
||||
ownKey: 'options',
|
||||
foreignViewModel: ViewAssignmentPollOption,
|
||||
foreignModel: AssignmentPollOption,
|
||||
order: 'weight',
|
||||
relationDefinitionsByKey: {
|
||||
user: {
|
||||
type: 'M2O',
|
||||
ownIdKey: 'candidate_id',
|
||||
ownKey: 'user',
|
||||
foreignViewModel: ViewUser
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
@ -97,11 +78,8 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
|
|||
AssignmentTitleInformation
|
||||
> {
|
||||
private readonly restPath = '/rest/assignments/assignment/';
|
||||
private readonly restPollPath = '/rest/assignments/poll/';
|
||||
private readonly candidatureOtherPath = '/candidature_other/';
|
||||
private readonly candidatureSelfPath = '/candidature_self/';
|
||||
private readonly createPollPath = '/create_poll/';
|
||||
private readonly markElectedPath = '/mark_elected/';
|
||||
|
||||
/**
|
||||
* Constructor for the Assignment Repository.
|
||||
|
@ -152,9 +130,9 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
|
|||
* of the candidate. Else, the candidate will be added if not on the list,
|
||||
* and removed if on the list
|
||||
*/
|
||||
public async changeCandidate(user: ViewUser, assignment: ViewAssignment, adding?: boolean): Promise<void> {
|
||||
const data = { user: user.id };
|
||||
if (assignment.candidates.some(candidate => candidate.id === user.id) && adding !== true) {
|
||||
public async changeCandidate(userId: number, assignment: ViewAssignment, adding?: boolean): Promise<void> {
|
||||
const data = { user: userId };
|
||||
if (assignment.candidates.some(candidate => candidate.id === userId) && adding !== true) {
|
||||
await this.httpService.delete(this.restPath + assignment.id + this.candidatureOtherPath, data);
|
||||
} else if (adding !== false) {
|
||||
await this.httpService.post(this.restPath + assignment.id + this.candidatureOtherPath, data);
|
||||
|
@ -179,87 +157,6 @@ export class AssignmentRepositoryService extends BaseIsAgendaItemAndListOfSpeake
|
|||
await this.httpService.delete(this.restPath + assignment.id + this.candidatureSelfPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Poll to a given assignment
|
||||
*
|
||||
* @param assignment The assignment to add the poll to
|
||||
*/
|
||||
public async addPoll(assignment: ViewAssignment): Promise<void> {
|
||||
await this.httpService.post(this.restPath + assignment.id + this.createPollPath);
|
||||
// TODO: change current tab to new poll
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a poll
|
||||
*
|
||||
* @param id id of the poll to delete
|
||||
*/
|
||||
public async deletePoll(poll: ViewAssignmentPoll): Promise<void> {
|
||||
await this.httpService.delete(`${this.restPollPath}${poll.id}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* update data (metadata etc) for a poll
|
||||
*
|
||||
* @param poll the (partial) data to update
|
||||
* @param originalPoll the poll to update
|
||||
*
|
||||
* TODO: check if votes is untouched
|
||||
*/
|
||||
public async updatePoll(poll: Partial<AssignmentPoll>, originalPoll: ViewAssignmentPoll): Promise<void> {
|
||||
const data: AssignmentPoll = Object.assign(originalPoll.poll, poll);
|
||||
await this.httpService.patch(`${this.restPollPath}${originalPoll.id}/`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: temporary (?) update votes method. Needed because server needs
|
||||
* different input than it's output in case of votes ?
|
||||
*
|
||||
* @param poll the updated Poll
|
||||
* @param originalPoll the original poll
|
||||
*/
|
||||
public async updateVotes(poll: Partial<AssignmentPoll>, originalPoll: ViewAssignmentPoll): Promise<void> {
|
||||
const votes = poll.options.map(option => {
|
||||
const voteObject = {};
|
||||
for (const vote of option.votes) {
|
||||
voteObject[vote.value] = vote.weight;
|
||||
}
|
||||
return voteObject;
|
||||
});
|
||||
|
||||
const data = {
|
||||
assignment_id: originalPoll.assignment_id,
|
||||
votes: votes,
|
||||
votesabstain: poll.votesabstain || null,
|
||||
votescast: poll.votescast || null,
|
||||
votesinvalid: poll.votesinvalid || null,
|
||||
votesno: poll.votesno || null,
|
||||
votesvalid: poll.votesvalid || null
|
||||
};
|
||||
|
||||
await this.httpService.put(`${this.restPollPath}${originalPoll.id}/`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* change the 'elected' state of an election candidate
|
||||
*
|
||||
* @param assignmentRelatedUser
|
||||
* @param assignment
|
||||
* @param elected true if the candidate is to be elected, false if unelected
|
||||
*/
|
||||
public async markElected(
|
||||
assignmentRelatedUser: ViewAssignmentRelatedUser,
|
||||
assignment: ViewAssignment,
|
||||
elected: boolean
|
||||
): Promise<void> {
|
||||
const data = { user: assignmentRelatedUser.user_id };
|
||||
if (elected) {
|
||||
await this.httpService.post(this.restPath + assignment.id + this.markElectedPath, data);
|
||||
} else {
|
||||
await this.httpService.delete(this.restPath + assignment.id + this.markElectedPath, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to sort an assignment's candidates
|
||||
*
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue