mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbe21bed1b | ||
|
|
12c585acde | ||
|
|
e829c35396 | ||
|
|
d4371d2e09 | ||
|
|
090dfb7665 | ||
|
|
3cc06c5a7b | ||
|
|
62bfbd884e | ||
|
|
2b243a0178 | ||
|
|
69bf9b60f4 | ||
|
|
2392c36c98 | ||
|
|
601ea18df8 | ||
|
|
b51fea8ca9 | ||
|
|
929ed9d8e8 | ||
|
|
436ff94a06 | ||
|
|
4ce1f9a45e | ||
|
|
5dd3022006 | ||
|
|
7b5715fca8 | ||
|
|
5f0034c4bf | ||
|
|
83ac1f6ff9 | ||
|
|
09d7ca36e8 | ||
|
|
09c47f2d1e | ||
|
|
1ae263d5c6 | ||
|
|
43f34a417d | ||
|
|
90975a6ae9 | ||
|
|
fefb37e510 | ||
|
|
6edccdd1a8 | ||
|
|
ea21d8bfd9 | ||
|
|
c048e5c1a7 | ||
|
|
07b2a665bc | ||
|
|
b33181d8cc | ||
|
|
0539306034 | ||
|
|
54300120fc | ||
|
|
0385a00bc7 | ||
|
|
7126943ccc | ||
|
|
f4e82c7d1e | ||
|
|
b6c0dbfa50 | ||
|
|
a78a19d1ce | ||
|
|
330c7a1290 | ||
|
|
1c452d498d | ||
|
|
3d92df6316 | ||
|
|
a822a4a33b | ||
|
|
7cd202065c | ||
|
|
18b5699cca | ||
|
|
a44b02cc88 | ||
|
|
a139e99c01 | ||
|
|
40f68d2f43 | ||
|
|
73c97b925f | ||
|
|
3420d6fc09 | ||
|
|
a200d5103b | ||
|
|
2f2cef2560 | ||
|
|
7cbe19b041 | ||
|
|
648ec2e7fe | ||
|
|
7a6dcd2520 | ||
|
|
d6bde89de8 | ||
|
|
4ad257f227 | ||
|
|
d3bdb75156 | ||
|
|
1e0364c6ef | ||
|
|
b5cd123fbb | ||
|
|
a572d387dc | ||
|
|
37b76cfd73 | ||
|
|
2fc8ca6e78 | ||
|
|
59983149e5 | ||
|
|
2f2a0728f8 | ||
|
|
d61ca334e5 | ||
|
|
29283e06fc | ||
|
|
0185d496ed | ||
|
|
06435ab251 | ||
|
|
1c4ad598c2 | ||
|
|
4cae824f1b | ||
|
|
975dcae051 | ||
|
|
dc75fdf09e | ||
|
|
cc76f77831 | ||
|
|
ff0383881c | ||
|
|
3470ff1892 | ||
|
|
11e3cc6064 | ||
|
|
c3262ae30b | ||
|
|
89b503ce31 | ||
|
|
d12176d2bf | ||
|
|
a3777e8666 | ||
|
|
67ec3ae395 | ||
|
|
383dedec0c | ||
|
|
621f9ab851 | ||
|
|
925009b671 | ||
|
|
ad8ff6b38d | ||
|
|
c641df4a3e | ||
|
|
54152e0128 | ||
|
|
445d25a830 | ||
|
|
9b750ddba6 | ||
|
|
02c3b19128 | ||
|
|
e4219ac039 | ||
|
|
449a7aa6d4 | ||
|
|
97b4e3bcb6 | ||
|
|
c8ff5eb0ae | ||
|
|
8ce83243ee | ||
|
|
24c9032293 | ||
|
|
51430fdb73 | ||
|
|
3249cb0544 | ||
|
|
bd3a920a6d | ||
|
|
029ac4ce4b | ||
|
|
38d12d9724 | ||
|
|
3b12eceb70 | ||
|
|
07eda34bd6 | ||
|
|
f76ab7357f | ||
|
|
9fc5545aa9 | ||
|
|
8c539882ec | ||
|
|
0646de00bc | ||
|
|
814d20d915 | ||
|
|
bb21d7c789 | ||
|
|
b25c10095b | ||
|
|
a6ddb2ee57 | ||
|
|
816ae576d7 | ||
|
|
b4d38ad3a1 | ||
|
|
e56426f7d6 | ||
|
|
8eaaf78cd9 | ||
|
|
c1a7eae149 | ||
|
|
b1da93e508 | ||
|
|
e8f56e266f | ||
|
|
d7ebc4ebbe | ||
|
|
39cd9b6ab3 | ||
|
|
a315c0f37c | ||
|
|
96a99481f7 | ||
|
|
68f3145716 | ||
|
|
2d80613c0d | ||
|
|
f40c27d8db | ||
|
|
15fc168855 | ||
|
|
175732fff8 | ||
|
|
8a08ec16e5 | ||
|
|
e0f78b8b83 | ||
|
|
534f828679 | ||
|
|
343ba5d629 | ||
|
|
8cd4d9fd1f | ||
|
|
a3767cca58 | ||
|
|
5ab92540c0 | ||
|
|
3a0399f3cc | ||
|
|
774d21c4cf | ||
|
|
4e36236ffd | ||
|
|
8d39dbb067 | ||
|
|
c0d8f9bf22 | ||
|
|
2c3514f992 | ||
|
|
be05cb834d | ||
|
|
98c13d35a0 | ||
|
|
1ab1d61818 | ||
|
|
0f53b6337e | ||
|
|
e00762d595 | ||
|
|
97ead4877a | ||
|
|
a0cc9f9030 | ||
|
|
4676c0a8cb | ||
|
|
4b085ee560 | ||
|
|
2bf74aa5ef | ||
|
|
e5c8b1dec8 | ||
|
|
e2be82aa8f | ||
|
|
6e597525f5 | ||
|
|
dc049bfea6 | ||
|
|
13ee8e00e2 | ||
|
|
1519806e07 | ||
|
|
16f71a30e6 | ||
|
|
d92bdd64d8 | ||
|
|
a2204d1723 | ||
|
|
5e0666db64 | ||
|
|
c49145ba42 | ||
|
|
9bf058d256 | ||
|
|
d7194d3873 | ||
|
|
8b026fd215 | ||
|
|
e1de28cb56 | ||
|
|
60792a80d8 | ||
|
|
38d0b92bea | ||
|
|
682193c742 | ||
|
|
2a14bedb0e | ||
|
|
e7aa6fb439 | ||
|
|
425b86d5a0 | ||
|
|
d2e850ebf5 | ||
|
|
917cc29286 | ||
|
|
a4fb790f13 | ||
|
|
6c29f8abc1 | ||
|
|
bf19a78202 | ||
|
|
f4e6d93d7c | ||
|
|
12b9e62ddc | ||
|
|
9fd4c8d610 | ||
|
|
436e28464d | ||
|
|
cf19ddbb4e | ||
|
|
a94b3693e9 | ||
|
|
2f6ae7e88d | ||
|
|
b59a41a766 | ||
|
|
652c31d97c | ||
|
|
dacb82b610 | ||
|
|
822ce8af6c | ||
|
|
e7bea0302c | ||
|
|
58b1886857 | ||
|
|
27fcff3075 | ||
|
|
a6da50cf19 | ||
|
|
50d0586db4 | ||
|
|
147864affc | ||
|
|
82aea61f12 | ||
|
|
4516e3d328 | ||
|
|
2913e01cff | ||
|
|
5d1c9feb87 | ||
|
|
f64349e695 | ||
|
|
dad9ebcada | ||
|
|
f38cfac57f | ||
|
|
a30e374246 | ||
|
|
8036fdd9ac | ||
|
|
25af5d47b7 | ||
|
|
a2d3cf083f | ||
|
|
42de728ce5 | ||
|
|
253fe616ac | ||
|
|
f1995e84a3 | ||
|
|
422c16bfa5 | ||
|
|
dc9c10df32 | ||
|
|
d7d7295647 | ||
|
|
6e63ea3363 | ||
|
|
78a04b6a46 | ||
|
|
3f2c41c185 | ||
|
|
c0e7c03f19 | ||
|
|
c799b0edbc | ||
|
|
5c478c1d63 | ||
|
|
e3bf53bce3 | ||
|
|
f0cbd6611e | ||
|
|
e304c21523 | ||
|
|
4c284a4483 | ||
|
|
cd30cc7a3a | ||
|
|
f56e3aa45a | ||
|
|
467238e87a | ||
|
|
08156b5b90 | ||
|
|
76b7e97dd3 | ||
|
|
3b0a6d2235 | ||
|
|
069ebc3ce9 | ||
|
|
8157010959 | ||
|
|
6e66336841 | ||
|
|
1ff98c5efd | ||
|
|
ebffacafdc | ||
|
|
5d791dc7f1 | ||
|
|
17172faa40 | ||
|
|
dc43625df5 | ||
|
|
93edc01e8f | ||
|
|
2443b611f2 | ||
|
|
b8dcf7f1c4 | ||
|
|
a0c03be52d | ||
|
|
748c9d30f8 | ||
|
|
52424a4069 | ||
|
|
15d6938883 | ||
|
|
e7ff8952ad | ||
|
|
a2e0c00087 | ||
|
|
77e853c1a1 | ||
|
|
0c039d877a | ||
|
|
df68e87d29 | ||
|
|
8a7aaca325 | ||
|
|
259fa10e9c | ||
|
|
cb437c7559 | ||
|
|
82d92aa0b5 | ||
|
|
aed9029c7c | ||
|
|
724a749e2c | ||
|
|
092bc11265 | ||
|
|
371f8ba5ab | ||
|
|
a916711106 | ||
|
|
dc1502de8d | ||
|
|
c570986161 | ||
|
|
8ff11df102 | ||
|
|
5cd20a8b63 | ||
|
|
29c39c68ea | ||
|
|
1ff6b7b31f | ||
|
|
20ab0bbfc9 | ||
|
|
fc096f5cb9 | ||
|
|
04ff187b5b | ||
|
|
b4c3e45323 | ||
|
|
f1c8c339b4 | ||
|
|
4f5f98329b | ||
|
|
4df1853525 | ||
|
|
678a4320de | ||
|
|
aa493e9f31 | ||
|
|
c19f99953b | ||
|
|
510fd8e5a2 | ||
|
|
c3eeea14d9 | ||
|
|
e0521e89d2 | ||
|
|
1c6268307e | ||
|
|
e734dbf6e2 | ||
|
|
32dd714b98 | ||
|
|
97555865d9 | ||
|
|
23aabaf959 | ||
|
|
c3ae5bf15a | ||
|
|
6d79dddd4d | ||
|
|
2a00ce59ad | ||
|
|
1a3b924ac9 | ||
|
|
9ed155b42b | ||
|
|
09189e34be | ||
|
|
2a8103443d | ||
|
|
3f3f295376 | ||
|
|
d38f3774ca | ||
|
|
5abcac8dc2 | ||
|
|
cbeca7fbf5 | ||
|
|
c0fae224f0 | ||
|
|
04f46c1828 | ||
|
|
a021a92ecc | ||
|
|
20d7fc2607 | ||
|
|
e5b3f00cf5 | ||
|
|
1faf4c8c83 | ||
|
|
2ea11e4724 | ||
|
|
c72cfc0699 | ||
|
|
8b436c7442 | ||
|
|
563d3c9459 | ||
|
|
85fbb02338 | ||
|
|
ff6252b889 | ||
|
|
d741b4741e | ||
|
|
60d1bcbf8d | ||
|
|
c0f4abc5f5 | ||
|
|
29a0c40e01 | ||
|
|
c7c0241a78 | ||
|
|
b059a2f276 | ||
|
|
52b819424b | ||
|
|
b74cbd8789 | ||
|
|
5702f0e49c | ||
|
|
cf88efde90 | ||
|
|
b63b845b02 | ||
|
|
4858084f24 | ||
|
|
e21b956483 | ||
|
|
74c0b2486d | ||
|
|
cbd84c5ffd | ||
|
|
ce8e7def89 | ||
|
|
1c2f36b158 | ||
|
|
e1d54e4c63 | ||
|
|
a95f96b96d | ||
|
|
1c6d45bfdc | ||
|
|
7cbff2c1b6 | ||
|
|
88bc3667fc | ||
|
|
0682c89aac | ||
|
|
96ff6c190a | ||
|
|
9f82cda4bf | ||
|
|
24d9ea947e | ||
|
|
d4e020669b | ||
|
|
29e6264660 | ||
|
|
30cc569236 | ||
|
|
b17688a06e | ||
|
|
c5dff7ac63 | ||
|
|
af6771cbd6 | ||
|
|
78205fd1b2 | ||
|
|
5094fb3bee | ||
|
|
03d28b0e6f | ||
|
|
48f8b38ccf | ||
|
|
0031c5f7f5 | ||
|
|
e150bede1c | ||
|
|
e48a9f2ff0 | ||
|
|
95e98746e4 | ||
|
|
8e6b4b1d11 | ||
|
|
ee58112c97 | ||
|
|
75e7b65a93 | ||
|
|
fe261c288c | ||
|
|
02a3a8531d | ||
|
|
62d0a10a03 | ||
|
|
4213ada22d | ||
|
|
722bcfb6ee | ||
|
|
22af3a19eb | ||
|
|
9ea3ebd9e4 | ||
|
|
57a0794651 | ||
|
|
436c2cc0d7 | ||
|
|
d7dc2d8ab6 | ||
|
|
d8e3215cc8 | ||
|
|
1b038abbf5 | ||
|
|
0fcfb727c0 | ||
|
|
51e413a134 | ||
|
|
b20e8a6e10 | ||
|
|
08f42b50e2 | ||
|
|
0434243491 | ||
|
|
ece9fdaff1 | ||
|
|
6cdab87f0f | ||
|
|
e8bd4b9d2c | ||
|
|
2ec18fa14c | ||
|
|
2f39975efd | ||
|
|
f4bcfa0539 | ||
|
|
e61abe4525 | ||
|
|
1cd7bb149f | ||
|
|
873e9609dd | ||
|
|
eec2000eaa | ||
|
|
87607165ce | ||
|
|
22ec9e4c79 | ||
|
|
deeec81efd | ||
|
|
22129188a9 | ||
|
|
c261b148c5 | ||
|
|
b9690808d8 | ||
|
|
f982fcfbf9 | ||
|
|
8adf0109f1 | ||
|
|
64e7414c10 | ||
|
|
cd8cd4e7f0 | ||
|
|
187af16f85 | ||
|
|
a4d57e51ea | ||
|
|
1531d77d5c | ||
|
|
ae40e59fa9 | ||
|
|
d532788e6a | ||
|
|
c4ac1a04c3 | ||
|
|
34e76883de | ||
|
|
427f729675 | ||
|
|
0efdc9dd42 | ||
|
|
d086d8fb09 | ||
|
|
7d03b58c3f | ||
|
|
5629f3af38 | ||
|
|
fc1b9f110e | ||
|
|
d1c117da2b | ||
|
|
780d9f7e69 | ||
|
|
34c00de187 | ||
|
|
4f8b700a12 | ||
|
|
7ed20990b9 | ||
|
|
8ca3cb6ca0 | ||
|
|
514ac093be | ||
|
|
eae5efdb62 | ||
|
|
4716fc0dd6 | ||
|
|
28c0519ca3 | ||
|
|
283303386a | ||
|
|
eb6c9d087b | ||
|
|
27fdcadd3f | ||
|
|
64f1398f1a | ||
|
|
9968ef1685 | ||
|
|
5577f3ad0c | ||
|
|
7b377d90d8 | ||
|
|
3e8046efaf | ||
|
|
bdda5fb048 | ||
|
|
fab8a653dc | ||
|
|
1bb1824331 | ||
|
|
a91ca60fad | ||
|
|
4e625c6ba8 | ||
|
|
d440ef18ff | ||
|
|
80275f793a | ||
|
|
ae1846e65b | ||
|
|
dcba353b2d | ||
|
|
0e5e470bcb | ||
|
|
52ea016995 | ||
|
|
3329225b50 | ||
|
|
204657bd7c | ||
|
|
82d651d8b2 | ||
|
|
f05d46cbbc | ||
|
|
6a478b1d8c | ||
|
|
10f068666c | ||
|
|
49cfd61c16 | ||
|
|
e9f47563b0 | ||
|
|
7b38aad1d8 | ||
|
|
2a4f5fad27 | ||
|
|
03e97ceb9a | ||
|
|
054fad76d7 | ||
|
|
acdcf8ce1a | ||
|
|
71593d26af | ||
|
|
72aa373bb6 | ||
|
|
f5b525a382 | ||
|
|
5ea94908ca | ||
|
|
508c365d42 | ||
|
|
d41f97f43a | ||
|
|
765de6f9d8 | ||
|
|
8b494b79a9 | ||
|
|
1b8bfcaac9 | ||
|
|
86f4c46ace | ||
|
|
faa528d01f | ||
|
|
e8ec56db00 | ||
|
|
8cc4802ca1 | ||
|
|
2dc47d3f37 | ||
|
|
e08776fc07 | ||
|
|
67d7438e09 | ||
|
|
f2a7846c26 | ||
|
|
05ddbde60b | ||
|
|
2df28be735 | ||
|
|
ec97274ad1 | ||
|
|
0dffe56060 | ||
|
|
bd2128626a | ||
|
|
59b46cbcc3 | ||
|
|
3bece7392c | ||
|
|
109fbb0b40 | ||
|
|
4d986484a0 | ||
|
|
d19cfc1378 | ||
|
|
752f589b04 | ||
|
|
f74a9900f4 | ||
|
|
a5047170b6 | ||
|
|
f3d97ab3c4 | ||
|
|
281658fc1b | ||
|
|
6a7f279844 | ||
|
|
e1b5c50367 | ||
|
|
ebc450a97b | ||
|
|
657438d695 | ||
|
|
bb567047d1 | ||
|
|
71adbcf36a | ||
|
|
ab363a59e8 | ||
|
|
3985d906db | ||
|
|
e609d04e14 | ||
|
|
50b2270f26 | ||
|
|
ec9a001a3b | ||
|
|
85b18406c2 | ||
|
|
cc4bb03560 | ||
|
|
8415aa6904 | ||
|
|
5e3039fee7 | ||
|
|
df2e038b70 | ||
|
|
fc75025ebc | ||
|
|
4b700a5c32 | ||
|
|
e2bdc7695f | ||
|
|
bc8e7de98a | ||
|
|
c15a003fa8 | ||
|
|
505043f0bb | ||
|
|
5bd3a9e6fc | ||
|
|
5a306be4b4 | ||
|
|
830845395a | ||
|
|
163b0396b6 | ||
|
|
889e193b01 | ||
|
|
dc84cae141 | ||
|
|
23fe77c27c | ||
|
|
a22d04bd25 | ||
|
|
34484d9e3d | ||
|
|
c5253ef5fd | ||
|
|
1fe0c2142a | ||
|
|
2d8249f6e8 | ||
|
|
1739e27a3b | ||
|
|
908f2bdecc | ||
|
|
d6b85acf35 | ||
|
|
8b8317ccb0 | ||
|
|
2423797246 | ||
|
|
90e47c21bb | ||
|
|
1b1827c3b3 | ||
|
|
8f4809dffd | ||
|
|
61dc90c5e0 | ||
|
|
426ebfe46a | ||
|
|
77e5e3f731 | ||
|
|
bfafc42266 | ||
|
|
805e436c92 | ||
|
|
0c26a26491 | ||
|
|
26049d72d2 | ||
|
|
439ecb4e7f | ||
|
|
12290823b9 | ||
|
|
354cd5e68b | ||
|
|
0032045f5b | ||
|
|
e57e16ba68 | ||
|
|
fbf7624359 | ||
|
|
1a3f1b7cf4 | ||
|
|
24e68e2a4e | ||
|
|
5ab46da608 | ||
|
|
45f5d092e8 | ||
|
|
54678dbe39 | ||
|
|
7e46dcf296 | ||
|
|
52d2add5a1 | ||
|
|
8942bac990 | ||
|
|
1d82154811 | ||
|
|
e47362be43 | ||
|
|
0ecbfe275d | ||
|
|
e3239b5f95 | ||
|
|
c824453ef8 | ||
|
|
ea026081bd | ||
|
|
c4e71122e6 | ||
|
|
0a737d2cc7 | ||
|
|
099ee64639 | ||
|
|
73c491aaae | ||
|
|
dca980e6af | ||
|
|
3187c4bcee | ||
|
|
601107796a | ||
|
|
6df8ab6fa4 | ||
|
|
17387514c5 | ||
|
|
b36fcfc730 | ||
|
|
d1745003b4 | ||
|
|
e17fa0886f | ||
|
|
6633a2f2b4 | ||
|
|
c1ccb22470 | ||
|
|
cef98cca22 | ||
|
|
17a1095f00 | ||
|
|
94bb1d6b39 | ||
|
|
4cefe627fb | ||
|
|
28594af9a2 | ||
|
|
85905d98fa | ||
|
|
3b1faff253 | ||
|
|
c8e1db5f19 | ||
|
|
6fb102808d | ||
|
|
e910f8660b | ||
|
|
a0ba838f3b | ||
|
|
faac4aa816 | ||
|
|
48b8177133 | ||
|
|
f87800cbfc | ||
|
|
f2afd9e91a | ||
|
|
f94b3e18af | ||
|
|
5384074527 | ||
|
|
366abfc0d9 | ||
|
|
08cfb3de80 | ||
|
|
8c6326a668 | ||
|
|
626d3cf462 | ||
|
|
5c7931ac5c | ||
|
|
6bf613fb03 | ||
|
|
fec01a2c02 | ||
|
|
24e5be335f | ||
|
|
c19017b2bd | ||
|
|
eea6fb0918 | ||
|
|
674ea56f99 | ||
|
|
ce49e64e45 | ||
|
|
8b483cf4e8 | ||
|
|
bc8febe25e | ||
|
|
00ee23750c | ||
|
|
7634179d78 | ||
|
|
8c58ffd9e7 | ||
|
|
6208691147 | ||
|
|
19d7a53f1d | ||
|
|
11d039da25 | ||
|
|
527206981d | ||
|
|
57546e4455 | ||
|
|
39dbd37469 | ||
|
|
5c50088152 | ||
|
|
621f2e45e7 | ||
|
|
6808f0a5ae | ||
|
|
7f8df7acee | ||
|
|
be12a44bbf | ||
|
|
904058aa02 | ||
|
|
8331c8ef77 | ||
|
|
75686c3768 | ||
|
|
3509644f05 | ||
|
|
96095fa218 | ||
|
|
8aa2dc70d8 | ||
|
|
321a2da3ef | ||
|
|
8627ba85c6 | ||
|
|
6948e7c2df | ||
|
|
462af37d37 | ||
|
|
3b93d970de | ||
|
|
26fad4a031 | ||
|
|
ed28394566 | ||
|
|
f9467a8edb | ||
|
|
22fbfd6f29 | ||
|
|
a0ce5d5b0f | ||
|
|
5f252d5470 | ||
|
|
e232dc684e | ||
|
|
35b7c4e38e | ||
|
|
00db629958 | ||
|
|
307d87c3e6 | ||
|
|
265c24421e | ||
|
|
8269e4bce8 | ||
|
|
24c9e7b50f | ||
|
|
06a2b0655e | ||
|
|
fa55bcfa79 | ||
|
|
6b48e86848 | ||
|
|
8d131131f7 | ||
|
|
478079ac74 | ||
|
|
38f40c1dc5 | ||
|
|
983f110ffa | ||
|
|
a1be78fc18 | ||
|
|
5a8b36d028 | ||
|
|
bef5fe1881 | ||
|
|
1135d9ed99 | ||
|
|
747d2729e5 | ||
|
|
01dc6f183b | ||
|
|
0f68978909 | ||
|
|
b8235a88e2 | ||
|
|
a64b961a4b | ||
|
|
b058f19e47 | ||
|
|
7346e666c5 | ||
|
|
ad7de5c63d | ||
|
|
b54eef59af | ||
|
|
862a75dd7a | ||
|
|
a89daf5f4f | ||
|
|
51ed24bfdc | ||
|
|
87ff19e595 | ||
|
|
29e2654c33 | ||
|
|
2fca45c394 | ||
|
|
c915e305f0 | ||
|
|
e1585a77b1 | ||
|
|
1dabb7a26b | ||
|
|
ed6f0f1337 | ||
|
|
9cfdc42a8d | ||
|
|
200401b7be | ||
|
|
95d85cd24d | ||
|
|
52ef63d9e3 | ||
|
|
3a4c0df383 | ||
|
|
61d35cbd26 | ||
|
|
0737722e5a | ||
|
|
3b582ba5d3 | ||
|
|
6c31202923 | ||
|
|
0fa7670e94 | ||
|
|
586a257098 | ||
|
|
b010963b56 | ||
|
|
3591297247 | ||
|
|
7edc2ac5a8 | ||
|
|
25d5eb9561 | ||
|
|
53416146bd | ||
|
|
11c217a9ad | ||
|
|
fc63a895a5 | ||
|
|
9bf0ae3771 | ||
|
|
5a458120ff | ||
|
|
d8f2aa5bef | ||
|
|
31402e5b71 | ||
|
|
ac99125c2b | ||
|
|
b835c0938c | ||
|
|
61e252dabb | ||
|
|
f2a6955666 | ||
|
|
b52517891c | ||
|
|
082d1466a9 | ||
|
|
19d01bf2c9 | ||
|
|
d9a099f3b9 | ||
|
|
448cf2d14e | ||
|
|
a8b570e4bb | ||
|
|
fbdf23125b | ||
|
|
a0b2fae993 | ||
|
|
c11e588e13 | ||
|
|
65f7808c36 | ||
|
|
060297676d | ||
|
|
a3055e518d | ||
|
|
d2b5231c31 | ||
|
|
1d2c54fe4b | ||
|
|
433d57ff24 | ||
|
|
fb84c9f1e5 | ||
|
|
d1f87d0952 | ||
|
|
7e54071f07 | ||
|
|
df091f8645 | ||
|
|
776ac42cc6 | ||
|
|
6d36a1a37a | ||
|
|
939235051e | ||
|
|
320f31e224 | ||
|
|
1148fe7a0d | ||
|
|
4ce9f1c26f | ||
|
|
b51c8ef2ee | ||
|
|
a34aa5967e | ||
|
|
0db528f3a3 | ||
|
|
84d43be5d6 | ||
|
|
2cc928f24b | ||
|
|
e903e81cdb | ||
|
|
20a5a74bc0 | ||
|
|
6965b830f1 | ||
|
|
3f778e65ac | ||
|
|
8f3a144f30 | ||
|
|
bcf31de217 | ||
|
|
59a60dbc56 | ||
|
|
497c14fbf4 | ||
|
|
cb89c2fcc7 | ||
|
|
9544156a57 | ||
|
|
d8752e7f65 | ||
|
|
228e50a763 | ||
|
|
c28182101f | ||
|
|
b4742d3862 | ||
|
|
63a4a20103 | ||
|
|
bc5ca17fe5 | ||
|
|
a0f56461fd | ||
|
|
24c8c9eaec | ||
|
|
85df38552f | ||
|
|
9e970d7a9d | ||
|
|
e4f118b234 | ||
|
|
d0cdf4908c | ||
|
|
5276906203 | ||
|
|
9dedc05d6b | ||
|
|
3ce4eaca61 | ||
|
|
d084a41ffe | ||
|
|
3123ef88dd | ||
|
|
865eacc6d4 | ||
|
|
0d0107c9c3 | ||
|
|
3c91747283 | ||
|
|
6c18abb88b | ||
|
|
887d0a6ca3 | ||
|
|
0568756662 | ||
|
|
ad2c4104f8 | ||
|
|
fe0eab8a26 | ||
|
|
6251eeb297 | ||
|
|
4317383c9e | ||
|
|
65b73234e7 | ||
|
|
2932e0b631 | ||
|
|
745342d985 | ||
|
|
4245599321 | ||
|
|
a9e7cb714d | ||
|
|
37db94f2ad | ||
|
|
731aa5dfc3 | ||
|
|
f65cb9f8ed | ||
|
|
8b8fd233d5 | ||
|
|
da7587b871 | ||
|
|
8e21c47487 | ||
|
|
63f1f76e58 | ||
|
|
dea5126a5d | ||
|
|
bf38483d32 | ||
|
|
b8a87b7bd4 | ||
|
|
6e2718897f | ||
|
|
db41071f1b | ||
|
|
09b90bb6db | ||
|
|
972e26aa89 | ||
|
|
40d68a34a3 | ||
|
|
a3005fd325 | ||
|
|
e745fbe0f5 | ||
|
|
3bc8fd2ce8 | ||
|
|
6365104f97 | ||
|
|
69ce1554a7 | ||
|
|
2cec3f68be | ||
|
|
8fd8a962f4 | ||
|
|
c19c251c51 | ||
|
|
fe3c993143 | ||
|
|
88ac7c5c18 | ||
|
|
b81fb676a7 | ||
|
|
293d9d097b | ||
|
|
26826f626a | ||
|
|
4be9149bcd | ||
|
|
ee91d57e3a | ||
|
|
cf01e648e1 | ||
|
|
5a83811cb6 | ||
|
|
311a69a414 | ||
|
|
83fa615ff9 | ||
|
|
12508202d2 | ||
|
|
c4c02c43dd | ||
|
|
207d00579b | ||
|
|
9a2ae922f7 | ||
|
|
af896688d1 | ||
|
|
2d66cf80ba | ||
|
|
28559533f2 | ||
|
|
d6d5008d3f | ||
|
|
7b4e3d8dfa | ||
|
|
3a22617d8d | ||
|
|
359dbbe1c5 | ||
|
|
c6e5a33584 | ||
|
|
a7cd4d08b1 | ||
|
|
618590cb0c | ||
|
|
43087d7e61 | ||
|
|
ccd61eb27c | ||
|
|
f794a45ab6 | ||
|
|
df116a47d5 | ||
|
|
6aebc404be | ||
|
|
16c764df4e | ||
|
|
94d9a43dab | ||
|
|
44d6685af8 | ||
|
|
abc8189616 | ||
|
|
61b4b684d0 | ||
|
|
6b95d6a7b0 | ||
|
|
cce375a3fe | ||
|
|
8c6cc3c89a | ||
|
|
12cf733ae1 | ||
|
|
eb49fcde1a | ||
|
|
05682e973d | ||
|
|
9061a6a3a5 | ||
|
|
785cfd0780 | ||
|
|
ea9daef50b | ||
|
|
8ade9a003b | ||
|
|
78cec1dd89 | ||
|
|
50023d4ab5 | ||
|
|
fe7a0caa1f | ||
|
|
b04fa49df7 | ||
|
|
ba87890445 | ||
|
|
76e2980bb9 | ||
|
|
02b5fdc6f3 | ||
|
|
8ac3ff1786 | ||
|
|
991c92fdaf | ||
|
|
31d01d58de | ||
|
|
33118fc244 | ||
|
|
14496b7934 | ||
|
|
eff7e0a5fb | ||
|
|
dac69b38fd | ||
|
|
7424530d49 | ||
|
|
2f24cb8116 | ||
|
|
a1831d70e0 | ||
|
|
d1b30e415c | ||
|
|
ceaeaf03e8 | ||
|
|
83e95ec8d3 | ||
|
|
1c36563d1b | ||
|
|
3e6cd896cc | ||
|
|
86b2f559cc | ||
|
|
091127ee63 | ||
|
|
a57012d422 | ||
|
|
64a3c84d1b | ||
|
|
75713d4228 | ||
|
|
799e703750 | ||
|
|
e1b0db4a86 | ||
|
|
13e17cf084 | ||
|
|
dde753562d | ||
|
|
3f39e59826 | ||
|
|
4aaba75099 | ||
|
|
f9ed86fec7 | ||
|
|
1aff38613c | ||
|
|
a94f7f73b6 | ||
|
|
b7ac291a48 | ||
|
|
dd6ad7b84b | ||
|
|
e9d049eef9 | ||
|
|
0819c0599d | ||
|
|
4ce9379522 | ||
|
|
b593ee7d2b | ||
|
|
31623bea66 | ||
|
|
05ed218ec6 | ||
|
|
6e55ecac39 | ||
|
|
a91b44c47c | ||
|
|
13817e741f | ||
|
|
d2df706162 | ||
|
|
c9e3439087 | ||
|
|
8717e3cfce | ||
|
|
6a7e721722 |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"presets": [
|
||||
["latest", {
|
||||
"es2015": {
|
||||
"modules": false
|
||||
}
|
||||
}],
|
||||
"react"
|
||||
],
|
||||
"env": {
|
||||
"development": {
|
||||
"plugins": [
|
||||
"transform-object-rest-spread",
|
||||
"transform-class-properties",
|
||||
"react-hot-loader/babel"
|
||||
]
|
||||
},
|
||||
"test": {
|
||||
"plugins": [
|
||||
"transform-async-to-generator",
|
||||
"inline-json-import",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Read(**/.env*)",
|
||||
"Read(./.databasedumps/**)",
|
||||
"Read(**/*.sqz)",
|
||||
"Read(**/*.sql)",
|
||||
"Bash(docker exec *-db *)",
|
||||
"Bash(*psql *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
# Populate and change this to .env
|
||||
# To get the crypt key, run `openssl rand -hex 32` or
|
||||
# `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
|
||||
IMPORTER_DB_API_TOKEN=
|
||||
PATE_TOKEN=
|
||||
CRYPT_KEY=
|
||||
# Changes the user and group docker runs as. Use "id -u" and "id -g" to run containers as current user+group.
|
||||
# Fixes errors with permissions when mounting.
|
||||
DOCKER_UID=
|
||||
DOCKER_GID=
|
||||
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
config/
|
||||
+44
-11
@@ -1,13 +1,46 @@
|
||||
{
|
||||
"ignorePatterns": [
|
||||
"eslint.config.mjs",
|
||||
"**/build/**",
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"**/instrumented/**",
|
||||
"**/coverage/**",
|
||||
"nyc-config.cjs",
|
||||
".lintstagedrc.mjs",
|
||||
"playwright/**/*.js"
|
||||
"root": true,
|
||||
"parser": "babel-eslint",
|
||||
"extends": "airbnb",
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"rules": {
|
||||
"comma-dangle": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"no-param-reassign": [
|
||||
2,
|
||||
{
|
||||
"props": false
|
||||
}
|
||||
],
|
||||
"import/no-unresolved": 2,
|
||||
"react/jsx-no-bind": 2,
|
||||
"react/no-did-mount-set-state": 0,
|
||||
"react/prefer-stateless-function": 2,
|
||||
"react/no-typos": 0,
|
||||
"jsx-a11y/no-static-element-interactions": 0,
|
||||
"jsx-a11y/click-events-have-key-events": 0,
|
||||
"jsx-a11y/label-has-for": 0,
|
||||
"object-curly-newline": "off",
|
||||
"prefer-object-spread/prefer-object-spread": 2,
|
||||
"prefer-arrow-callback": 2,
|
||||
"babel/no-invalid-this": 2,
|
||||
"babel/semi": [
|
||||
2,
|
||||
"never"
|
||||
],
|
||||
"semi": [
|
||||
2,
|
||||
"never"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"react",
|
||||
"import",
|
||||
"prefer-object-spread",
|
||||
"babel"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
name: 'Build backend'
|
||||
description: 'Build backend in staging or production version'
|
||||
|
||||
inputs:
|
||||
environment:
|
||||
required: true
|
||||
description: 'staging or production'
|
||||
default: 'production'
|
||||
release_version:
|
||||
required: false
|
||||
description: 'Release version added to production builds'
|
||||
default: ${{ github.sha }}
|
||||
push:
|
||||
required: false
|
||||
description: Also push built image to toska registry
|
||||
default: 'false'
|
||||
registry_password:
|
||||
required: false
|
||||
description: Password to quay.io registry
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
quay.io/toska/oodikone-backend
|
||||
tags: |
|
||||
${{ inputs.environment }}
|
||||
${{ github.sha }}
|
||||
|
||||
- name: Login to quay.io
|
||||
if: ${{ inputs.push == 'true' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ inputs.registry_password }}
|
||||
|
||||
- name: 'Build image and export it'
|
||||
id: build-image-action
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: services
|
||||
file: services/backend/Dockerfile
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
|
||||
BASE_PATH=${{ case(inputs.environment == 'staging', '/oodikone', '') }}
|
||||
SENTRY_ENVIRONMENT=${{ inputs.environment }}
|
||||
SENTRY_RELEASE=${{ inputs.release_version }}
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
@@ -1,72 +0,0 @@
|
||||
name: 'Build frontend'
|
||||
description: 'Build frontend in staging or production versions'
|
||||
|
||||
inputs:
|
||||
environment:
|
||||
required: true
|
||||
description: 'staging or production'
|
||||
default: 'production'
|
||||
release_version:
|
||||
required: false
|
||||
description: 'Release version added to production builds'
|
||||
default: ${{ github.sha }}
|
||||
sentry_dns:
|
||||
required: false
|
||||
description: 'Sentry url to send error logs to (only production, enforced)'
|
||||
default: ''
|
||||
push:
|
||||
required: false
|
||||
description: Also push built image to toska registry
|
||||
default: 'false'
|
||||
registry_password:
|
||||
required: false
|
||||
description: Password to quay.io registry
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to quay.io
|
||||
if: ${{ inputs.push == 'true' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ inputs.registry_password }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
quay.io/toska/oodikone-frontend
|
||||
tags: |
|
||||
${{ inputs.environment }}
|
||||
${{ github.sha }}
|
||||
|
||||
- name: Get current time
|
||||
id: get_current_time
|
||||
shell: bash
|
||||
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 'Build image and export it'
|
||||
id: build-image-action
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: services
|
||||
file: services/frontend/Dockerfile
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
|
||||
BASE_PATH=${{ case(inputs.environment == 'staging', '/oodikone', '') }}
|
||||
SENTRY_ENVIRONMENT=${{ inputs.environment }}
|
||||
SENTRY_RELEASE=${{ inputs.release_version }}
|
||||
BUILT_AT=${{ steps.get_current_time.outputs.NOW }}
|
||||
SENTRY_DSN=${{ case(inputs.environment == 'production', inputs.sentry_dns, '') }}
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
@@ -1,60 +0,0 @@
|
||||
name: 'Build updater scheduler'
|
||||
description: 'Build updater scheduler in staging or production version'
|
||||
|
||||
inputs:
|
||||
environment:
|
||||
required: true
|
||||
description: 'staging or production'
|
||||
default: 'production'
|
||||
release_version:
|
||||
required: false
|
||||
description: 'Release version added to production builds'
|
||||
default: ${{ github.sha }}
|
||||
push:
|
||||
required: false
|
||||
description: Also push built image to toska registry
|
||||
default: 'false'
|
||||
registry_password:
|
||||
required: false
|
||||
description: Password to quay.io registry
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to quay.io
|
||||
if: ${{ inputs.push == 'true' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ inputs.registry_password }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
quay.io/toska/updater-scheduler
|
||||
tags: |
|
||||
${{ inputs.environment }}
|
||||
${{ github.sha }}
|
||||
|
||||
- name: 'Build image and export it'
|
||||
id: build-image-action
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: updater/sis-updater-scheduler
|
||||
file: updater/sis-updater-scheduler/Dockerfile
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
|
||||
SENTRY_ENVIRONMENT=${{ inputs.environment }}
|
||||
SENTRY_RELEASE=${{ inputs.release_version }}
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
@@ -1,60 +0,0 @@
|
||||
name: 'Build updater worker'
|
||||
description: 'Build updater worker in staging or production version'
|
||||
|
||||
inputs:
|
||||
environment:
|
||||
required: true
|
||||
description: 'staging or production'
|
||||
default: 'production'
|
||||
release_version:
|
||||
required: false
|
||||
description: 'Release version added to production builds'
|
||||
default: ${{ github.sha }}
|
||||
push:
|
||||
required: false
|
||||
description: Also push built image to toska registry
|
||||
default: 'false'
|
||||
registry_password:
|
||||
required: false
|
||||
description: Password to quay.io registry
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to quay.io
|
||||
if: ${{ inputs.push == 'true' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: quay.io/toska
|
||||
username: toska+github
|
||||
password: ${{ inputs.registry_password }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
quay.io/toska/updater-worker
|
||||
tags: |
|
||||
${{ inputs.environment }}
|
||||
${{ github.sha }}
|
||||
|
||||
- name: 'Build image and export it'
|
||||
id: build-image-action
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: updater/sis-updater-worker
|
||||
file: updater/sis-updater-worker/Dockerfile
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
STAGING=${{ case(inputs.environment == 'staging', 'true', 'false') }}
|
||||
SENTRY_ENVIRONMENT=${{ inputs.environment }}
|
||||
SENTRY_RELEASE=${{ inputs.release_version }}
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
@@ -1,18 +0,0 @@
|
||||
name: 'Lint code'
|
||||
description: 'Runs Oxlint and Stylelint for frontend and backend'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
- name: 'Install dependencies'
|
||||
shell: bash
|
||||
run: npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend
|
||||
- name: 'Run Stylelint for CSS files'
|
||||
shell: bash
|
||||
run: npm run stylelint "services/frontend/src/**/*.css"
|
||||
- name: 'Run Oxlint for the whole repository'
|
||||
shell: bash
|
||||
run: npm run lint
|
||||
@@ -1,9 +0,0 @@
|
||||
name: Teardown docker compose (docker-compose.ci.yml)
|
||||
description: 'Teardown all containers started by docker (docker-compose.ci.yml)'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: 'Teardown docker compose'
|
||||
shell: bash
|
||||
run: docker compose --file docker-compose.ci.yml --profile="*" down --remove-orphans
|
||||
@@ -1,41 +0,0 @@
|
||||
name: 'Run api tests'
|
||||
description: 'Runs api tests in backend'
|
||||
|
||||
inputs:
|
||||
username:
|
||||
required: true
|
||||
description: 'Toska registry username'
|
||||
password:
|
||||
required: true
|
||||
description: 'Toska registry password'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install node-24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Login to toska docker registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ inputs.username }}
|
||||
password: ${{ inputs.password }}
|
||||
|
||||
- name: Start oodikone (without frontend and backend)
|
||||
shell: bash
|
||||
run: docker compose --file docker-compose.ci.yml up --quiet-pull --detach --wait
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: npm ci --prefix ./services/backend
|
||||
|
||||
- name: Run api-tests
|
||||
shell: bash
|
||||
run: npm run test:api --prefix ./services/backend
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
@@ -1,31 +0,0 @@
|
||||
name: 'Run component tests'
|
||||
description: 'Runs frontend component tests'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install node-24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: npm ci --prefix ./services/shared && npm ci --prefix ./services/frontend
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
shell: bash
|
||||
run: npm exec -- playwright install --with-deps
|
||||
working-directory: ./services/frontend
|
||||
|
||||
- name: Run Playwright tests
|
||||
shell: bash
|
||||
run: npm run test:component --prefix ./services/frontend
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: |
|
||||
!cancelled()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Run e2e tests with playwright
|
||||
description: Runs playwright tests for individual spec
|
||||
|
||||
inputs:
|
||||
username:
|
||||
required: true
|
||||
description: 'Toska registry username'
|
||||
password:
|
||||
required: true
|
||||
description: 'Toska registry password'
|
||||
spec:
|
||||
required: true
|
||||
description: 'Path to test file'
|
||||
test_name:
|
||||
required: true
|
||||
description: 'Name of the test'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: 'Login to toska Docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ inputs.username }}
|
||||
password: ${{ inputs.password }}
|
||||
|
||||
- name: 'Run services, pull rest of the needed images'
|
||||
shell: bash
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
|
||||
|
||||
- name: Run playwright tests (${{ inputs.test_name }}})
|
||||
shell: bash
|
||||
run: |
|
||||
CI='true' npm run playwright ${{ inputs.spec }}
|
||||
|
||||
- name: 'Print logs on failure'
|
||||
shell: bash
|
||||
if: failure()
|
||||
run: npm run logs:ci && sleep 15
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
@@ -1,41 +0,0 @@
|
||||
name: 'Run service tests'
|
||||
description: 'Runs service tests in backend'
|
||||
|
||||
inputs:
|
||||
username:
|
||||
required: true
|
||||
description: 'Toska registry username'
|
||||
password:
|
||||
required: true
|
||||
description: 'Toska registry password'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install node-24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Login to toska docker registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ inputs.username }}
|
||||
password: ${{ inputs.password }}
|
||||
|
||||
- name: Start oodikone (without frontend and backend)
|
||||
shell: bash
|
||||
run: docker compose --file docker-compose.ci.yml up --quiet-pull --detach --wait
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: npm ci --prefix ./services/backend
|
||||
|
||||
- name: Run service-tests
|
||||
shell: bash
|
||||
run: npm run test:services --prefix ./services/backend
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
@@ -1,26 +0,0 @@
|
||||
name: 'Run unit tests'
|
||||
description: 'Runs unit tests in backend and frontend'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install node-24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: npm ci --prefix ./services/shared && npm ci --prefix ./services/backend && npm ci --prefix ./services/frontend
|
||||
|
||||
- name: Run shared unit tests
|
||||
shell: bash
|
||||
run: npm run test:unit --prefix ./services/shared
|
||||
|
||||
- name: Run frontend unit tests
|
||||
shell: bash
|
||||
run: npm run test:unit --prefix ./services/frontend
|
||||
|
||||
- name: Run backend unit tests
|
||||
shell: bash
|
||||
run: npm run test:unit --prefix ./services/backend
|
||||
@@ -1,18 +0,0 @@
|
||||
name: 'Run updater unit tests'
|
||||
description: 'Runs unit tests in sis-updater-worker'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install node-24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: npm ci --prefix ./updater/sis-updater-worker
|
||||
|
||||
- name: Run updater unit tests
|
||||
shell: bash
|
||||
run: npm run test:unit --prefix ./updater/sis-updater-worker
|
||||
@@ -1,162 +0,0 @@
|
||||
name: 'Release to production'
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
release_backend:
|
||||
name: 'Build and release backend'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 'Build backend'
|
||||
id: build-image
|
||||
uses: ./.github/actions/build/backend
|
||||
with:
|
||||
environment: 'production'
|
||||
release_version: ${{ steps.extract_version.outputs.VERSION }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
release_frontend:
|
||||
name: 'Build and release frontend'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 'Build frontend'
|
||||
id: build-image
|
||||
uses: ./.github/actions/build/frontend
|
||||
with:
|
||||
environment: 'production'
|
||||
release_version: ${{ steps.extract_version.outputs.VERSION }}
|
||||
sentry_dns: ${{ secrets.OODIKONE_FRONTEND_SENTRY_DSN }} # Used only in production
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
sentry_release_frontend:
|
||||
name: 'Create Sentry release for frontend'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
needs:
|
||||
- release_frontend
|
||||
- release_backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Pull frontend image and create a container
|
||||
run: docker create --name frontend quay.io/toska/oodikone-frontend:production
|
||||
|
||||
- name: Copy assets from the frontend container
|
||||
run: docker cp frontend:/opt/app-root/frontend/build ./build
|
||||
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_URL: https://toska.it.helsinki.fi/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
|
||||
SENTRY_ORG: sentry
|
||||
SENTRY_PROJECT: oodikone-frontend
|
||||
with:
|
||||
environment: production
|
||||
set_commits: 'skip'
|
||||
sourcemaps: './build/assets'
|
||||
url_prefix: '~/assets'
|
||||
version: ${{ steps.extract_version.outputs.VERSION }}
|
||||
|
||||
sentry_release_backend:
|
||||
name: 'Create Sentry release for backend'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
needs:
|
||||
- release_frontend
|
||||
- release_backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Pull backend image and create a container
|
||||
run: docker create --name backend quay.io/toska/oodikone-backend:production
|
||||
|
||||
- name: Copy assets from the backend container
|
||||
run: docker cp backend:/opt/app-root/backend/dist ./dist
|
||||
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_URL: https://toska.it.helsinki.fi/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
|
||||
SENTRY_ORG: sentry
|
||||
SENTRY_PROJECT: oodikone-backend
|
||||
with:
|
||||
environment: production
|
||||
set_commits: 'skip'
|
||||
sourcemaps: './dist'
|
||||
url_prefix: '~/opt/app-root/backend/dist/'
|
||||
version: ${{ steps.extract_version.outputs.VERSION }}
|
||||
|
||||
report_success:
|
||||
name: 'Report deployment success to the "oodikone_notifications" channel'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
needs:
|
||||
- release_frontend
|
||||
- release_backend
|
||||
- sentry_release_frontend
|
||||
- sentry_release_backend
|
||||
|
||||
if: success()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.6.1
|
||||
with:
|
||||
webhook-url: ${{ secrets.WEBHOOK_URL }}
|
||||
message-type: deployment
|
||||
softa-url: https://oodikone.helsinki.fi/
|
||||
deployment-target: production
|
||||
|
||||
report_failure:
|
||||
name: 'Report deployment failure to the "oodikone_notifications" channel'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
needs:
|
||||
- release_frontend
|
||||
- release_backend
|
||||
- sentry_release_frontend
|
||||
- sentry_release_backend
|
||||
|
||||
if: failure()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.6.1
|
||||
with:
|
||||
webhook-url: ${{ secrets.WEBHOOK_URL }}
|
||||
message-type: deployment-failure
|
||||
deployment-target: production
|
||||
@@ -1,30 +0,0 @@
|
||||
name: 'Run tests'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/pull_request.yaml'
|
||||
- '.github/workflows/test.yaml'
|
||||
- 'services/**'
|
||||
- 'updater/**'
|
||||
- 'e2e/**'
|
||||
- 'docker-compose.ci.yml'
|
||||
- 'docker-compose.test.yml'
|
||||
- '.oxlintrc.json'
|
||||
- '.oxlint-backend-rules.cjs'
|
||||
- 'package*.json'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: ${{ github.event.pull_request.user.login != 'renovate[bot]' }}
|
||||
uses: ./.github/workflows/test.yaml
|
||||
with:
|
||||
skip_db_update: true
|
||||
secrets:
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
s3_access_key: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3_secret_key: ${{ secrets.S3_SECRET_KEY }}
|
||||
@@ -1,140 +0,0 @@
|
||||
name: 'Test and release to staging'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/staging.yaml'
|
||||
- '.github/workflows/test.yaml'
|
||||
- 'services/**'
|
||||
- 'updater/**'
|
||||
- 'e2e/**'
|
||||
- 'docker-compose.ci.yml'
|
||||
- 'docker-compose.test.yml'
|
||||
- '.oxlintrc.json'
|
||||
- '.oxlint-backend-rules.cjs'
|
||||
- 'package*.json'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: UniversityOfHelsinkiCS/oodikone/.github/workflows/test.yaml@master
|
||||
secrets:
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
s3_access_key: ${{ secrets.S3_ACCESS_KEY }}
|
||||
s3_secret_key: ${{ secrets.S3_SECRET_KEY }}
|
||||
|
||||
release_backend:
|
||||
name: 'Build and release backend'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Build backend'
|
||||
id: build-image
|
||||
uses: ./.github/actions/build/backend
|
||||
with:
|
||||
environment: 'staging'
|
||||
release_version: ${{ github.sha }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
release_frontend:
|
||||
name: 'Build and release frontend'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Build frontend'
|
||||
id: build-image
|
||||
uses: ./.github/actions/build/frontend
|
||||
with:
|
||||
environment: 'staging'
|
||||
release_version: ${{ github.sha }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
release_updater_worker:
|
||||
name: 'Build and release updater worker'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Build worker'
|
||||
id: build-image
|
||||
uses: ./.github/actions/build/worker
|
||||
with:
|
||||
environment: 'staging'
|
||||
release_version: ${{ github.sha }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
release_updater_scheduler:
|
||||
name: 'Build and release updater scheduler'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Build scheduler'
|
||||
id: build-image
|
||||
uses: ./.github/actions/build/scheduler
|
||||
with:
|
||||
environment: 'staging'
|
||||
release_version: ${{ github.sha }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
report_success:
|
||||
name: 'Report deployment success to the "oodikone_notifications" channel'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: success()
|
||||
needs:
|
||||
- release_frontend
|
||||
- release_backend
|
||||
- release_updater_worker
|
||||
- release_updater_scheduler
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.6.1
|
||||
with:
|
||||
webhook-url: ${{ secrets.WEBHOOK_URL }}
|
||||
message-type: deployment
|
||||
softa-url: https://toska-staging.cs.helsinki.fi/oodikone
|
||||
deployment-target: staging
|
||||
|
||||
report_failure:
|
||||
name: 'Report deployment failure to the "oodikone_notifications" channel'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: failure()
|
||||
needs:
|
||||
- release_frontend
|
||||
- release_backend
|
||||
- release_updater_worker
|
||||
- release_updater_scheduler
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.6.1
|
||||
with:
|
||||
webhook-url: ${{ secrets.WEBHOOK_URL }}
|
||||
message-type: deployment-failure
|
||||
deployment-target: staging
|
||||
@@ -1,474 +0,0 @@
|
||||
name: 'Lint and test'
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
skip_db_update:
|
||||
description: 'When true, skip committing/pushing anon DB images (used for PR runs)'
|
||||
required: false
|
||||
type: string
|
||||
default: 'false'
|
||||
secrets:
|
||||
username:
|
||||
required: true
|
||||
description: 'Toska registry username'
|
||||
password:
|
||||
required: true
|
||||
description: 'Toska registry password'
|
||||
s3_access_key:
|
||||
required: true
|
||||
description: 'S3 access key for the test database dumps'
|
||||
s3_secret_key:
|
||||
required: true
|
||||
description: 'S3 secret key for the test database dumps'
|
||||
|
||||
jobs:
|
||||
# === Static tests / linting ===
|
||||
lint_packages:
|
||||
name: 'Lint packages'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Lint backend, frontend and shared
|
||||
uses: ./.github/actions/lint
|
||||
|
||||
# === Build database images ===
|
||||
build_backend:
|
||||
name: 'Build backend'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build backend
|
||||
uses: ./.github/actions/build/backend
|
||||
with:
|
||||
environment: 'staging'
|
||||
release_version: ${{ github.sha }}
|
||||
push: false
|
||||
|
||||
build_frontend:
|
||||
name: 'Build frontend'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build frontend
|
||||
uses: ./.github/actions/build/frontend
|
||||
with:
|
||||
environment: 'staging'
|
||||
release_version: ${{ github.sha }}
|
||||
push: false
|
||||
|
||||
# === Update databases ===
|
||||
check_migration_files:
|
||||
name: Check if new migrations have been added
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
kone_db_migrations: ${{ steps.check_migrations.outputs.kone_db_migrations }}
|
||||
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
|
||||
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Check if new migrations have been added
|
||||
id: check_migrations
|
||||
uses: dorny/paths-filter@v4
|
||||
with:
|
||||
filters: |
|
||||
kone_db_migrations:
|
||||
- 'services/backend/src/database/migrations_kone/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
user_db_migrations:
|
||||
- 'services/backend/src/database/migrations_user/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
sis_db_migrations:
|
||||
- 'updater/sis-updater-worker/src/db/migrations/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
|
||||
build_and_push_anon_sis_db:
|
||||
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: |
|
||||
!cancelled() && !failure() &&
|
||||
needs.check_migration_files.outputs.sis_db_migrations == 'true' &&
|
||||
inputs.skip_db_update != 'true'
|
||||
needs:
|
||||
- check_migration_files
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- lint_packages
|
||||
- test_unit
|
||||
- test_updater_unit
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: 'Let sis-updater-worker run migrations, then push new version of image to toska hub'
|
||||
env:
|
||||
ACCESS_KEY: ${{ secrets.s3_access_key }}
|
||||
SECRET_KEY: ${{ secrets.s3_secret_key }}
|
||||
run: |
|
||||
docker compose --file docker-compose.ci.yml up --detach --quiet-pull sis-updater-worker
|
||||
sleep 30 # wait for sis-updater-worker to start and run migrations
|
||||
sudo apt update -y && sudo apt install s3cmd -y
|
||||
./anonyymioodi/manage_test_dbs.sh release sis-db
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
|
||||
build_and_push_anon_kone_db:
|
||||
name: 'Build anon kone-db and push to toska hub if new migrations have been added'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: |
|
||||
!cancelled() && !failure() &&
|
||||
needs.check_migration_files.outputs.kone_db_migrations == 'true' &&
|
||||
inputs.skip_db_update != 'true'
|
||||
needs:
|
||||
- check_migration_files
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- lint_packages
|
||||
- test_unit
|
||||
- test_updater_unit
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: 'Let backend run migrations, then push new version of image to toska hub'
|
||||
env:
|
||||
ACCESS_KEY: ${{ secrets.s3_access_key }}
|
||||
SECRET_KEY: ${{ secrets.s3_secret_key }}
|
||||
run: |
|
||||
docker compose --file docker-compose.ci.yml up --detach --quiet-pull backend --wait
|
||||
sleep 30 # wait for backend to start and run migrations
|
||||
sudo apt update -y && sudo apt install s3cmd -y
|
||||
./anonyymioodi/manage_test_dbs.sh release kone-db
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
|
||||
build_and_push_anon_user_db:
|
||||
name: 'Build anon user-db and push to toska hub if new migrations have been added'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: |
|
||||
!cancelled() && !failure() &&
|
||||
needs.check_migration_files.outputs.user_db_migrations == 'true' &&
|
||||
inputs.skip_db_update != 'true'
|
||||
needs:
|
||||
- check_migration_files
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- lint_packages
|
||||
- test_unit
|
||||
- test_updater_unit
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: 'Let backend run migrations, then push new version of image to toska hub'
|
||||
env:
|
||||
ACCESS_KEY: ${{ secrets.s3_access_key }}
|
||||
SECRET_KEY: ${{ secrets.s3_secret_key }}
|
||||
run: |
|
||||
docker compose --file docker-compose.ci.yml up --detach --quiet-pull backend --wait
|
||||
sleep 30 # wait for backend to start and run migrations
|
||||
sudo apt update -y && sudo apt install s3cmd -y
|
||||
./anonyymioodi/manage_test_dbs.sh release user-db
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
|
||||
# === Update sis-db with updater ===
|
||||
check_updater_changed:
|
||||
name: 'Check if updater has been updated'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
outputs:
|
||||
has_updater_changed: ${{ steps.check_updater_changed.outputs.has_updater_changed }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: check_updater_changed
|
||||
with:
|
||||
filters: |
|
||||
has_updater_changed:
|
||||
- 'updater/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
- '.github/workflows/staging.yaml'
|
||||
- 'docker-compose.ci.yml'
|
||||
|
||||
run_updater_updates:
|
||||
name: 'Run updater tests and updates'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: |
|
||||
!cancelled() && !failure() &&
|
||||
(needs.check_updater_changed.outputs.has_updater_changed == 'true' ||
|
||||
needs.check_migration_files.outputs.sis_db_migrations == 'true')
|
||||
needs:
|
||||
- check_migration_files
|
||||
- check_updater_changed
|
||||
- build_and_push_anon_sis_db
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: Start worker containers
|
||||
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-worker --scale sis-updater-worker=3 --quiet-pull --detach --wait
|
||||
|
||||
- name: Run hourly jobs
|
||||
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
|
||||
env:
|
||||
SCHEDULE_IMMEDIATE: hourly
|
||||
EXIT_AFTER_IMMEDIATES: yes
|
||||
|
||||
- name: Run weekly jobs
|
||||
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
|
||||
env:
|
||||
SCHEDULE_IMMEDIATE: weekly
|
||||
EXIT_AFTER_IMMEDIATES: yes
|
||||
|
||||
# TODO: Push the updated sis-db to toska-hub when updater is deterministic with test-data
|
||||
- name: Push newly build db to toska hub
|
||||
if: |
|
||||
success() && inputs.skip_db_update != 'true'
|
||||
env:
|
||||
ACCESS_KEY: ${{ secrets.s3_access_key }}
|
||||
SECRET_KEY: ${{ secrets.s3_secret_key }}
|
||||
run: |
|
||||
sudo apt update -y && sudo apt install s3cmd -y
|
||||
./anonyymioodi/manage_test_dbs.sh release sis-db
|
||||
|
||||
- name: Dump logs on failure
|
||||
if: failure()
|
||||
run: docker compose --file docker-compose.ci.yml --profile updater logs
|
||||
|
||||
- name: Teardown containers started by compose
|
||||
if: always()
|
||||
uses: ./.github/actions/teardown
|
||||
|
||||
# === Backend api-tests ===
|
||||
test_api:
|
||||
name: 'Run backend api tests'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
needs:
|
||||
- run_updater_updates
|
||||
- build_and_push_anon_user_db
|
||||
- build_and_push_anon_kone_db
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run api tests
|
||||
uses: ./.github/actions/test/api
|
||||
with:
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
#
|
||||
# === Backend service-tests ===
|
||||
test_services:
|
||||
name: 'Run backend service-tests'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
needs:
|
||||
- run_updater_updates
|
||||
- build_and_push_anon_user_db
|
||||
- build_and_push_anon_kone_db
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run service-tests
|
||||
uses: ./.github/actions/test/services
|
||||
with:
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
# === Unit testing ===
|
||||
test_unit:
|
||||
name: 'Run unit tests'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run unit tests (frontend and backend)
|
||||
uses: ./.github/actions/test/unit
|
||||
|
||||
# === Updater unit testing ===
|
||||
test_updater_unit:
|
||||
name: 'Run updater unit tests'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run updater unit tests
|
||||
uses: ./.github/actions/test/updater-unit
|
||||
|
||||
# === Frontend component testing ===
|
||||
test_components:
|
||||
name: 'Test frontend components'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
needs:
|
||||
- run_updater_updates
|
||||
- build_and_push_anon_user_db
|
||||
- build_and_push_anon_kone_db
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run frontend component tests
|
||||
uses: ./.github/actions/test/component
|
||||
|
||||
# === End-to-end tests ===
|
||||
test_end_to_end_playwright:
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
needs:
|
||||
- run_updater_updates
|
||||
- build_and_push_anon_user_db
|
||||
- build_and_push_anon_kone_db
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: 'Population statistics'
|
||||
spec: e2e/Population_statistics.spec.ts
|
||||
- name: 'Course statistics'
|
||||
spec: e2e/Course_statistics.spec.ts
|
||||
- name: 'Degree programme overview'
|
||||
spec: e2e/Degree_programme_overview.spec.ts
|
||||
- name: 'faculty statistics'
|
||||
spec: e2e/Faculty_statistics.spec.ts
|
||||
- name: 'Teachers'
|
||||
spec: e2e/Teachers.spec.ts
|
||||
- name: 'Users'
|
||||
spec: e2e/Users.spec.ts
|
||||
- name: 'Access rights'
|
||||
spec: e2e/Routes.spec.ts
|
||||
- name: 'University'
|
||||
spec: e2e/University.spec.ts
|
||||
- name: 'Language center view'
|
||||
spec: e2e/Language_center.spec.ts
|
||||
- name: 'Study guidance groups'
|
||||
spec: e2e/Study_guidance_groups.spec.ts
|
||||
- name: 'Custom population'
|
||||
spec: e2e/Custom_population.spec.ts
|
||||
- name: 'Filters'
|
||||
spec: e2e/Filters.spec.ts
|
||||
- name: 'Students'
|
||||
spec: e2e/Students.spec.ts
|
||||
- name: 'Completed courses'
|
||||
spec: e2e/Completed_courses.spec.ts
|
||||
- name: 'Numbers match'
|
||||
spec: e2e/Numbers_match.spec.ts
|
||||
name: ${{ matrix.name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install node-24 for playwright
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install firefox --with-deps
|
||||
|
||||
- name: Run tests
|
||||
uses: ./.github/actions/test/e2e-playwright
|
||||
with:
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
spec: ${{ matrix.spec }}
|
||||
test_name: ${{ matrix.name }}
|
||||
|
||||
# === Other tests ===
|
||||
test_service_exit_statuses:
|
||||
name: 'Check services run correctly'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
if: |
|
||||
!cancelled() && !failure()
|
||||
needs:
|
||||
- run_updater_updates
|
||||
- build_and_push_anon_user_db
|
||||
- build_and_push_anon_kone_db
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: 'Run services, build rest of the needed images'
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull --wait
|
||||
|
||||
- name: 'Check that exit statuses are not errors'
|
||||
run: |
|
||||
docker ps -a
|
||||
if [[ $(docker ps --all | grep -Eo '(Exited|Restarting) \([0-9]+\)' | grep -Eo '[0-9]+' | awk 'BEGIN {sum=0} { sum += $1 } END { print sum }') != '0' ]]; then
|
||||
echo 'Some process had nonzero exit code';
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
- name: 'Print logs on failure'
|
||||
if: failure()
|
||||
run: npm run logs:ci && sleep 15
|
||||
@@ -1,174 +0,0 @@
|
||||
name: 'Test (and release) Updater'
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
paths:
|
||||
- '.github/workflows/updater.yaml'
|
||||
- 'updater/**'
|
||||
|
||||
jobs:
|
||||
lint_scheduler_and_worker:
|
||||
name: 'Lint scheduler and worker'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
|
||||
- name: 'Run linter'
|
||||
run: npm run lint -- updater
|
||||
|
||||
test_updater_unit:
|
||||
name: 'Run updater unit tests'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run updater unit tests
|
||||
uses: ./.github/actions/test/updater-unit
|
||||
|
||||
build_worker:
|
||||
name: 'Build Updater worker'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: 'Build the image'
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: updater/sis-updater-worker/
|
||||
push: false
|
||||
outputs: type=docker,dest=/tmp/worker.tar
|
||||
tags: toska/sis-updater-worker:latest
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
|
||||
- name: 'Upload image for other jobs'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: updater-worker-image
|
||||
path: /tmp/worker.tar
|
||||
retention-days: 7
|
||||
|
||||
build_scheduler:
|
||||
name: 'Build Updater scheduler'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: 'Build the image'
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: updater/sis-updater-scheduler/
|
||||
push: false
|
||||
outputs: type=docker,dest=/tmp/scheduler.tar
|
||||
tags: toska/sis-updater-scheduler:latest
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
|
||||
- name: 'Upload image for other jobs'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: updater-scheduler-image
|
||||
path: /tmp/scheduler.tar
|
||||
retention-days: 7
|
||||
|
||||
test_updater:
|
||||
name: 'Test Updater'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
needs:
|
||||
- lint_scheduler_and_worker
|
||||
- test_updater_unit
|
||||
- build_worker
|
||||
- build_scheduler
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
|
||||
- name: 'Download built images'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: 'updater-{worker,scheduler}-image'
|
||||
merge-multiple: true
|
||||
|
||||
- name: 'Load worker image'
|
||||
run: docker load --input worker.tar
|
||||
- name: 'Load scheduler image'
|
||||
run: docker load --input scheduler.tar
|
||||
|
||||
- name: 'Start worker'
|
||||
run: docker compose --file docker-compose.ci.yml up --detach --quiet-pull --no-recreate --wait sis-updater-worker
|
||||
|
||||
- name: 'Run hourly jobs'
|
||||
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
|
||||
env:
|
||||
SCHEDULE_IMMEDIATE: hourly
|
||||
EXIT_AFTER_IMMEDIATES: yes
|
||||
|
||||
- name: 'Run weekly jobs'
|
||||
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
|
||||
env:
|
||||
SCHEDULE_IMMEDIATE: weekly
|
||||
EXIT_AFTER_IMMEDIATES: yes
|
||||
|
||||
- name: 'Run prepurge'
|
||||
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
|
||||
env:
|
||||
SCHEDULE_IMMEDIATE: prepurge
|
||||
EXIT_AFTER_IMMEDIATES: yes
|
||||
|
||||
- name: 'Run purge'
|
||||
run: docker compose -f docker-compose.ci.yml --profile updater up sis-updater-scheduler --exit-code-from sis-updater-scheduler
|
||||
env:
|
||||
SCHEDULE_IMMEDIATE: purge
|
||||
EXIT_AFTER_IMMEDIATES: yes
|
||||
|
||||
release_worker:
|
||||
name: 'Build and release Updater worker'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
needs: test_updater
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Build updater worker'
|
||||
id: build-image
|
||||
uses: ./.github/actions/build/worker
|
||||
with:
|
||||
environment: 'production'
|
||||
release_version: ${{ github.sha }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
|
||||
release_scheduler:
|
||||
name: 'Build and release Updater scheduler'
|
||||
runs-on: ${{ vars.RUNS_ON || 'ubuntu-latest' }}
|
||||
timeout-minutes: 15
|
||||
needs: test_updater
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: 'Build udpater scheduler'
|
||||
id: build-image
|
||||
uses: ./.github/actions/build/scheduler
|
||||
with:
|
||||
environment: 'production'
|
||||
release_version: ${{ github.sha }}
|
||||
push: true
|
||||
registry_password: ${{ secrets.QUAY_IO_TOKEN }}
|
||||
+26
-70
@@ -1,74 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
# See https://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
dist
|
||||
|
||||
# IDE metadata
|
||||
.vscode
|
||||
.idea
|
||||
*.iml
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
test-results
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Production
|
||||
dist/
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
node_modules/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Misc
|
||||
*.sqz
|
||||
|
||||
# Environment variable files
|
||||
.env
|
||||
.env.test
|
||||
.env.production
|
||||
|
||||
# Oodikone related stuff
|
||||
.databasedumps/
|
||||
|
||||
# Cypress
|
||||
cypress/downloads/
|
||||
cypress/screenshots/
|
||||
cypress/videos/
|
||||
|
||||
# Code coverage
|
||||
.nyc_output/
|
||||
coverage/
|
||||
instrumented/
|
||||
|
||||
# Pycharm
|
||||
.idea
|
||||
|
||||
# VS Code
|
||||
.vscode/settings.json
|
||||
.vscode/tasks.json
|
||||
.vscode/launch.json
|
||||
.vscode/terminals.json
|
||||
|
||||
# Target of shared symlink
|
||||
services/**/src/shared
|
||||
|
||||
|
||||
!services/backend/.env.test
|
||||
#Stats
|
||||
stats.json
|
||||
@@ -1,9 +0,0 @@
|
||||
npx lint-staged
|
||||
|
||||
if git grep -qE --cached "(it|describe)\.only" $(git diff --cached --name-only --diff-filter=ACMR | xargs); then
|
||||
echo "\e[0;31mDo not commit .only-tests."
|
||||
exit 1
|
||||
else
|
||||
echo "No .only-tests found"
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,32 +0,0 @@
|
||||
import { relative } from 'path'
|
||||
|
||||
const cwd = process.cwd()
|
||||
const dockerCmdBase = `docker run --rm --volume ${cwd}:/oodikone --workdir /oodikone`
|
||||
const relativeFilePaths = files => [...files.map(file => relative(cwd, file))].join(' ')
|
||||
|
||||
export default {
|
||||
// INFO: oxlint --type-check is equivalent to running tsc --noEmit
|
||||
'{services,updater}/**/*.{js,jsx,ts,tsx}': files =>
|
||||
`oxlint --fix --type-aware --type-check --no-error-on-unmatched-pattern --quiet ${files.join(' ')}`,
|
||||
|
||||
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files => `oxfmt --no-error-on-unmatched-pattern ${files.join(' ')} `,
|
||||
|
||||
'*.css': files => `stylelint --fix ${files.join(' ')}`,
|
||||
|
||||
Dockerfile: files =>
|
||||
`${dockerCmdBase} hadolint/hadolint:v2.14.0 hadolint --ignore DL3006 ${relativeFilePaths(files)}`,
|
||||
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.11.0 ${relativeFilePaths(files)} -x`,
|
||||
'docker-compose*': files => {
|
||||
const composeFiles = file => {
|
||||
if (['docker-compose.ci.yml', 'docker-compose.test.yml'].some(f => file.includes(f))) {
|
||||
return `--file ${file}`
|
||||
}
|
||||
if (file.includes('docker-compose.real.yml')) {
|
||||
return `--file ${cwd}/docker-compose.yml --file ${file}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
return files.map(file => `docker compose ${composeFiles(file)} config --quiet`)
|
||||
},
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"semi": false,
|
||||
"arrowParens": "avoid",
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "lf",
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": []
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
module.exports = {
|
||||
meta: {
|
||||
name: 'oodikone',
|
||||
},
|
||||
rules: {
|
||||
'no-date-now': {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Require the backend clock helper instead of Date.now()',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
useNow: 'Use now().getTime() instead of Date.now() so the current time can be controlled in tests.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
node.callee.object.type === 'Identifier' &&
|
||||
node.callee.object.name === 'Date' &&
|
||||
node.callee.property.type === 'Identifier' &&
|
||||
node.callee.property.name === 'now' &&
|
||||
node.arguments.length === 0
|
||||
) {
|
||||
context.report({ node, messageId: 'useNow' })
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
'no-new-date': {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Require the backend clock helper for the current date',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
useNow: 'Use now() instead of new Date() so the current time can be controlled in tests.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
NewExpression(node) {
|
||||
if (node.callee.type === 'Identifier' && node.callee.name === 'Date' && node.arguments.length === 0) {
|
||||
context.report({ node, messageId: 'useNow' })
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
-401
@@ -1,401 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["import", "typescript"],
|
||||
"jsPlugins": ["./.oxlint-backend-rules.cjs"],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"options": {
|
||||
"typeAware": true,
|
||||
"typeCheck": true
|
||||
},
|
||||
"env": {
|
||||
"builtin": true,
|
||||
"es2021": true,
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"settings": {
|
||||
"import-x/extensions": [".ts", ".tsx", ".cts", ".mts", ".js", ".jsx", ".cjs", ".mjs"],
|
||||
"import-x/external-module-folders": ["node_modules", "node_modules/@types"],
|
||||
"import-x/parsers": {
|
||||
"@typescript-eslint/parser": [".ts", ".tsx", ".cts", ".mts"]
|
||||
},
|
||||
"import-x/resolver": {
|
||||
"typescript": true
|
||||
},
|
||||
"import-x/resolver-next": [
|
||||
{
|
||||
"interfaceVersion": 3,
|
||||
"name": "eslint-import-resolver-typescript"
|
||||
},
|
||||
{
|
||||
"interfaceVersion": 3,
|
||||
"name": "eslint-plugin-import-x:node"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"eslint.config.mjs",
|
||||
".oxlint-backend-rules.cjs",
|
||||
"**/build/**",
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"**/instrumented/**",
|
||||
"**/coverage/**",
|
||||
"nyc-config.cjs",
|
||||
".lintstagedrc.mjs",
|
||||
"playwright/**/*.js",
|
||||
"playwright.config.ts"
|
||||
],
|
||||
"rules": {
|
||||
"constructor-super": "error",
|
||||
"for-direction": "error",
|
||||
"getter-return": "error",
|
||||
"no-async-promise-executor": "error",
|
||||
"no-case-declarations": "error",
|
||||
"no-class-assign": "error",
|
||||
"no-compare-neg-zero": "error",
|
||||
"no-cond-assign": "error",
|
||||
"no-const-assign": "error",
|
||||
"no-constant-binary-expression": "error",
|
||||
"no-constant-condition": "error",
|
||||
"no-control-regex": "error",
|
||||
"no-debugger": "error",
|
||||
"no-delete-var": "error",
|
||||
"no-dupe-class-members": "error",
|
||||
"no-dupe-else-if": "error",
|
||||
"no-dupe-keys": "error",
|
||||
"no-duplicate-case": "error",
|
||||
"no-empty": "error",
|
||||
"no-empty-character-class": "error",
|
||||
"no-empty-pattern": "error",
|
||||
"no-empty-static-block": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-func-assign": "error",
|
||||
"no-global-assign": "error",
|
||||
"no-import-assign": "error",
|
||||
"no-invalid-regexp": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-loss-of-precision": "error",
|
||||
"no-misleading-character-class": "error",
|
||||
"no-new-native-nonconstructor": "error",
|
||||
"no-nonoctal-decimal-escape": "error",
|
||||
"no-obj-calls": "error",
|
||||
"no-prototype-builtins": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-regex-spaces": "error",
|
||||
"no-self-assign": "error",
|
||||
"no-setter-return": "error",
|
||||
"no-shadow-restricted-names": "error",
|
||||
"no-sparse-arrays": "error",
|
||||
"no-this-before-super": "error",
|
||||
"no-undef": "error",
|
||||
"no-unexpected-multiline": "error",
|
||||
"no-unreachable": "error",
|
||||
"no-unsafe-finally": "error",
|
||||
"no-unsafe-negation": "error",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-unused-labels": "error",
|
||||
"no-unused-private-class-members": "error",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrors": "none",
|
||||
"ignoreRestSiblings": true,
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-catch": "error",
|
||||
"no-useless-escape": "error",
|
||||
"no-with": "error",
|
||||
"require-yield": "error",
|
||||
"use-isnan": "error",
|
||||
"valid-typeof": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"no-empty-function": "error",
|
||||
"class-methods-use-this": "error",
|
||||
"no-alert": "error",
|
||||
"no-await-in-loop": "error",
|
||||
"no-console": "error",
|
||||
"no-param-reassign": "error",
|
||||
"no-promise-executor-return": "error",
|
||||
"no-return-assign": "error",
|
||||
"object-shorthand": ["error", "always"],
|
||||
"prefer-const": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"regex": "^@mui/[^/]+$",
|
||||
"message": "Import only the needed module. This has no effect in prod due to tree-shake, but speeds up development builds."
|
||||
},
|
||||
{
|
||||
"regex": "^src/.*",
|
||||
"message": "Root imports will not resolve in production build (instead silently crash)."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"import/namespace": "error",
|
||||
"import/default": "error",
|
||||
"import/export": "error",
|
||||
"import/no-named-as-default": "warn",
|
||||
"import/no-named-as-default-member": "warn",
|
||||
"import/no-duplicates": "warn",
|
||||
"import/no-commonjs": "error",
|
||||
"import/no-default-export": "error",
|
||||
"typescript/await-thenable": "error",
|
||||
"typescript/ban-ts-comment": "error",
|
||||
"typescript/no-array-delete": "error",
|
||||
"typescript/no-base-to-string": "error",
|
||||
"typescript/no-duplicate-enum-values": "error",
|
||||
"typescript/no-duplicate-type-constituents": "error",
|
||||
"typescript/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
"allowInterfaces": "with-single-extends"
|
||||
}
|
||||
],
|
||||
"typescript/no-extra-non-null-assertion": "error",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-for-in-array": "error",
|
||||
"typescript/no-implied-eval": "error",
|
||||
"typescript/no-misused-new": "error",
|
||||
"typescript/no-misused-promises": "error",
|
||||
"typescript/no-namespace": "error",
|
||||
"typescript/no-non-null-asserted-optional-chain": "error",
|
||||
"typescript/no-redundant-type-constituents": "error",
|
||||
"typescript/no-require-imports": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
"typescript/no-unnecessary-type-constraint": "error",
|
||||
"typescript/no-unsafe-declaration-merging": "error",
|
||||
"typescript/no-unsafe-enum-comparison": "error",
|
||||
"typescript/no-unsafe-function-type": "error",
|
||||
"typescript/no-unsafe-unary-minus": "error",
|
||||
"typescript/no-wrapper-object-types": "error",
|
||||
"typescript/only-throw-error": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/prefer-namespace-keyword": "error",
|
||||
"typescript/prefer-promise-reject-errors": "error",
|
||||
"typescript/require-await": "error",
|
||||
"typescript/restrict-plus-operands": "error",
|
||||
"typescript/triple-slash-reference": "error",
|
||||
"typescript/unbound-method": "error",
|
||||
"typescript/adjacent-overload-signatures": "error",
|
||||
"typescript/ban-tslint-comment": "error",
|
||||
"typescript/class-literal-property-style": "error",
|
||||
"typescript/consistent-generic-constructors": "error",
|
||||
"typescript/consistent-indexed-object-style": "error",
|
||||
"typescript/consistent-type-assertions": "error",
|
||||
"typescript/dot-notation": "error",
|
||||
"typescript/no-confusing-non-null-assertion": "error",
|
||||
"typescript/no-inferrable-types": "error",
|
||||
"typescript/non-nullable-type-assertion-style": "error",
|
||||
"typescript/prefer-find": "error",
|
||||
"typescript/prefer-for-of": "error",
|
||||
"typescript/prefer-function-type": "error",
|
||||
"typescript/prefer-includes": "error",
|
||||
"typescript/prefer-nullish-coalescing": "warn",
|
||||
"typescript/prefer-optional-chain": "error",
|
||||
"typescript/prefer-regexp-exec": "error",
|
||||
"typescript/prefer-string-starts-ends-with": "error",
|
||||
"typescript/consistent-return": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
|
||||
"rules": {
|
||||
"constructor-super": "off",
|
||||
"getter-return": "off",
|
||||
"no-class-assign": "off",
|
||||
"no-const-assign": "off",
|
||||
"no-dupe-class-members": "off",
|
||||
"no-dupe-keys": "off",
|
||||
"no-func-assign": "off",
|
||||
"no-import-assign": "off",
|
||||
"no-new-native-nonconstructor": "off",
|
||||
"no-obj-calls": "off",
|
||||
"no-redeclare": "off",
|
||||
"no-setter-return": "off",
|
||||
"no-this-before-super": "off",
|
||||
"no-undef": "off",
|
||||
"no-unreachable": "off",
|
||||
"no-unsafe-negation": "off",
|
||||
"no-var": "error",
|
||||
"no-with": "off",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["services/backend/src/**/*.{js,ts,tsx}"],
|
||||
"excludeFiles": ["services/backend/src/util/clock.ts"],
|
||||
"rules": {
|
||||
"oodikone/no-new-date": "error",
|
||||
"oodikone/no-date-now": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["services/backend/src/routes/*.{ts,js}"],
|
||||
"rules": {
|
||||
"typescript/no-misused-promises": "off",
|
||||
"typescript/consistent-return": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["{updater,services}/**/*.{js,cjs}"],
|
||||
"rules": {
|
||||
"typescript/await-thenable": "off",
|
||||
"typescript/consistent-return": "off",
|
||||
"typescript/consistent-type-exports": "off",
|
||||
"typescript/dot-notation": "off",
|
||||
"typescript/no-array-delete": "off",
|
||||
"typescript/no-base-to-string": "off",
|
||||
"typescript/no-confusing-void-expression": "off",
|
||||
"typescript/no-deprecated": "off",
|
||||
"typescript/no-duplicate-type-constituents": "off",
|
||||
"typescript/no-floating-promises": "off",
|
||||
"typescript/no-for-in-array": "off",
|
||||
"typescript/no-implied-eval": "off",
|
||||
"typescript/no-meaningless-void-operator": "off",
|
||||
"typescript/no-misused-promises": "off",
|
||||
"typescript/no-misused-spread": "off",
|
||||
"typescript/no-mixed-enums": "off",
|
||||
"typescript/no-redundant-type-constituents": "off",
|
||||
"typescript/no-unnecessary-boolean-literal-compare": "off",
|
||||
"typescript/no-unnecessary-condition": "off",
|
||||
"typescript/no-unnecessary-qualifier": "off",
|
||||
"typescript/no-unnecessary-template-expression": "off",
|
||||
"typescript/no-unnecessary-type-arguments": "off",
|
||||
"typescript/no-unnecessary-type-assertion": "off",
|
||||
"typescript/no-unnecessary-type-conversion": "off",
|
||||
"typescript/no-unnecessary-type-parameters": "off",
|
||||
"typescript/no-unsafe-enum-comparison": "off",
|
||||
"typescript/no-unsafe-type-assertion": "off",
|
||||
"typescript/no-unsafe-unary-minus": "off",
|
||||
"typescript/non-nullable-type-assertion-style": "off",
|
||||
"typescript/only-throw-error": "off",
|
||||
"typescript/prefer-find": "off",
|
||||
"typescript/prefer-includes": "off",
|
||||
"typescript/prefer-nullish-coalescing": "off",
|
||||
"typescript/prefer-optional-chain": "off",
|
||||
"typescript/prefer-promise-reject-errors": "off",
|
||||
"typescript/prefer-readonly": "off",
|
||||
"typescript/prefer-readonly-parameter-types": "off",
|
||||
"typescript/prefer-reduce-type-parameter": "off",
|
||||
"typescript/prefer-regexp-exec": "off",
|
||||
"typescript/prefer-return-this-type": "off",
|
||||
"typescript/prefer-string-starts-ends-with": "off",
|
||||
"typescript/promise-function-async": "off",
|
||||
"typescript/related-getter-setter-pairs": "off",
|
||||
"typescript/require-array-sort-compare": "off",
|
||||
"typescript/require-await": "off",
|
||||
"typescript/restrict-plus-operands": "off",
|
||||
"typescript/return-await": "off",
|
||||
"typescript/strict-boolean-expressions": "off",
|
||||
"typescript/switch-exhaustiveness-check": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/use-unknown-in-catch-callback-variable": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["services/frontend/**/*.{js,jsx,mjs,cjs,ts,tsx}"],
|
||||
"rules": {
|
||||
"react/react-compiler": "warn",
|
||||
|
||||
"react/display-name": "error",
|
||||
"react/jsx-key": "error",
|
||||
"react/jsx-no-comment-textnodes": "error",
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-target-blank": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/no-children-prop": "error",
|
||||
"react/no-danger-with-children": "error",
|
||||
"react/no-direct-mutation-state": "error",
|
||||
"react/no-find-dom-node": "error",
|
||||
"react/no-is-mounted": "error",
|
||||
"react/no-render-return-value": "error",
|
||||
"react/no-string-refs": "error",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/no-unknown-property": "error",
|
||||
"react/no-unsafe": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/require-render-return": "error",
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/exhaustive-deps": "warn",
|
||||
"react/no-array-index-key": "error",
|
||||
"react/no-unstable-nested-components": "warn",
|
||||
|
||||
"react/no-this-in-sfc": "error",
|
||||
"react/jsx-boolean-value": ["error", "never"],
|
||||
"react/jsx-filename-extension": [
|
||||
"error",
|
||||
{
|
||||
"allow": "as-needed",
|
||||
"extensions": [".jsx", ".tsx"]
|
||||
}
|
||||
],
|
||||
"react/jsx-no-useless-fragment": "error"
|
||||
},
|
||||
"plugins": ["react"]
|
||||
},
|
||||
{
|
||||
"files": ["services/backend/tests/**/*.ts"],
|
||||
"rules": {
|
||||
"no-console": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"services/backend/**/*.js",
|
||||
"updater/sis-updater-worker/src/db/migrations/*.cjs",
|
||||
"updater/sis-updater-scheduler/**/*.js"
|
||||
],
|
||||
"rules": {
|
||||
"import/no-commonjs": "off",
|
||||
"typescript/no-require-imports": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["updater/sis-updater-worker/src/db/migrations/*.cjs", "services/backend/src/database/**/*.js"],
|
||||
"rules": {
|
||||
"no-empty-function": "off",
|
||||
"typescript/require-await": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["services/backend/**/*.{js,ts}", "updater/**/*.js"],
|
||||
"rules": {
|
||||
"no-await-in-loop": "off",
|
||||
"no-promise-executor-return": "off",
|
||||
"import/no-default-export": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["e2e/**/*.ts"],
|
||||
"rules": {
|
||||
"no-await-in-loop": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"services/frontend/tests/**/*.{ts,tsx}",
|
||||
"services/frontend/playwright/**/*.{ts,tsx}",
|
||||
"services/frontend/playwright-ct.config.ts"
|
||||
],
|
||||
"rules": {
|
||||
"react/jsx-filename-extension": "off",
|
||||
"import/no-default-export": "off"
|
||||
},
|
||||
"plugins": ["react"]
|
||||
}
|
||||
]
|
||||
}
|
||||
+12
-2
@@ -1,6 +1,16 @@
|
||||
{
|
||||
"extends": ["stylelint-config-standard"],
|
||||
"extends": "stylelint-config-standard",
|
||||
"rules": {
|
||||
"selector-class-pattern": "^(?:[a-z][a-zA-Z-]*|Mui[A-Za-z-]+)$"
|
||||
"selector-type-no-unknown": [true, {
|
||||
"ignoreTypes": ["/^--/", "--"]
|
||||
}],
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoClasses": [
|
||||
"global"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- '8'
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
|
||||
addons:
|
||||
chrome: stable
|
||||
|
||||
sudo: required
|
||||
before_install:
|
||||
- "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16"
|
||||
- gem install superbara
|
||||
before_script:
|
||||
- "export DISPLAY=:99.0"
|
||||
- "sh -e /etc/init.d/xvfb start"
|
||||
- sleep 3 # give xvfb some time to start
|
||||
|
||||
|
||||
services:
|
||||
- docker
|
||||
- redis-server
|
||||
|
||||
after_success:
|
||||
- "[[ $TRAVIS_BRANCH =~ (trunk) ]] && docker build -t toska/oodikone2-frontend:testing ."
|
||||
- "[[ $TRAVIS_BRANCH =~ (trunk) ]] && docker login -u $DOCKER_USER -p $DOCKER_PASS"
|
||||
- "[[ $TRAVIS_BRANCH =~ (trunk) ]] && docker push toska/oodikone2-frontend:testing"
|
||||
- "[[ $TRAVIS_BRANCH =~ (^master) ]] && docker build -t toska/oodikone2-frontend:staging ."
|
||||
- "[[ $TRAVIS_BRANCH =~ (^master) ]] && docker login -u $DOCKER_USER -p $DOCKER_PASS"
|
||||
- "[[ $TRAVIS_BRANCH =~ (^master) ]] && docker push toska/oodikone2-frontend:staging"
|
||||
- "[[ $TRAVIS_TAG =~ ([0-1].+) ]] && docker build -t toska/oodikone2-frontend:latest ."
|
||||
- "[[ $TRAVIS_TAG =~ ([0-1].+) ]] && docker login -u $DOCKER_USER -p $DOCKER_PASS"
|
||||
- "[[ $TRAVIS_TAG =~ ([0-1].+) ]] && docker push toska/oodikone2-frontend:latest"
|
||||
@@ -1,63 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
Oodikone is a Docker-based Node.js/TypeScript application for analysing University of Helsinki study data. Main areas are:
|
||||
|
||||
- `services/frontend`: React, Vite, Redux Toolkit, and Material UI.
|
||||
- `services/backend`: Express, Sequelize, PostgreSQL, Redis, and BullMQ.
|
||||
- `services/shared`: shared types and utilities.
|
||||
- `updater/`: Sisu data workers and scheduler.
|
||||
- `e2e/`: Playwright e2e tests
|
||||
|
||||
Use Node.js 24 or newer. Packages have separate dependencies and lockfiles. Install locally with
|
||||
`npm run install:local`, or use `npm run ci:local` for lockfile-exact installs. Docker uses host-mounted locks:
|
||||
update dependencies locally, then run `npm run ci:docker`; do not install in containers.
|
||||
|
||||
## Development Rules
|
||||
|
||||
- Read nearby code and tests before changing behavior; follow local conventions.
|
||||
- Keep changes focused and avoid unrelated formatting.
|
||||
- Reuse `services/shared` for cross-service types and utilities.
|
||||
- Never commit `.env` files, credentials, database dumps, or real student data.
|
||||
- Use anonymized data. Do not reset real databases, use destructive commands, or use real-data Docker profiles unless
|
||||
explicitly required.
|
||||
- Treat migrations as production-impacting. They are in `services/backend/src/database/migrations_kone`,
|
||||
`services/backend/src/database/migrations_user`, and `updater/sis-updater-worker/src/db/migrations`; test migration
|
||||
changes against the relevant database service.
|
||||
- Do not access the database directly (e.g. `docker exec` into a database container, `psql`, Adminer, or similar).
|
||||
Go through the application's models or services instead or ask if there is questions about the data.
|
||||
- Add tests, especially unit tests, when adding or changing behavior.
|
||||
|
||||
## Local Commands
|
||||
|
||||
```text
|
||||
npm run oodikone # start the anonymized environment
|
||||
npm run oodikone:ci # start the test mode
|
||||
npm run both # start Oodikone and the updater
|
||||
npm run docker:down # stop containers
|
||||
docker compose ps # show service status
|
||||
```
|
||||
|
||||
The app runs at `http://localhost:3000` and Adminer at `http://localhost:5050`. Follow logs with
|
||||
`docker compose logs --follow frontend` or `docker compose logs --follow backend`.
|
||||
|
||||
## Style And Verification
|
||||
|
||||
- TypeScript/JavaScript: 2 spaces, single quotes, no semicolons, trailing commas where valid, 120-character lines.
|
||||
- Prefer explicit types at package boundaries, API payloads, Redux state, and database interfaces. Avoid `any` except
|
||||
where needed to contain an untyped external API.
|
||||
- Run `npm run fmt` on changed source, `npm run lint`, and `npm run stylelint` when CSS changes. Do not bypass the
|
||||
type-aware Oxlint/Oxfmt pre-commit hooks.
|
||||
- Run the narrowest relevant check, then broader checks as needed:
|
||||
- Frontend: `npm run build --prefix services/frontend`, `npm run test:unit --prefix services/frontend`, or
|
||||
`npm run test:component --prefix services/frontend`.
|
||||
- Backend: `npm run build --prefix services/backend`, or `npm run test:unit --prefix services/backend`,
|
||||
`npm run test:services --prefix services/backend`, or `npm run test:api --prefix services/backend`.
|
||||
- Shared: `npm run test --prefix services/shared`.
|
||||
- E2E: `npm run playwright -- e2e/<spec>.spec.ts`.
|
||||
- If running `docker-compose.ci.yml` add env variable `CI=true` before the e2e npm command.
|
||||
Otherwise some tests fail.
|
||||
- API, service, component, and E2E tests require Docker; start the relevant environment with `npm run oodikone:test`
|
||||
or its documented profile. Report any checks that could not run and why.
|
||||
|
||||
CI also validates Docker Compose files, Dockerfiles, shell scripts, workflows, and migrations. Keep such changes
|
||||
minimal and validate them locally where practical.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
FROM node:8.9.4
|
||||
|
||||
RUN mkdir -p /usr/src/app
|
||||
COPY . /usr/src/app
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN npm i
|
||||
|
||||
RUN npm run dist
|
||||
RUN npm install -g serve
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["serve", "-s", "-l", "5000", "dist"]
|
||||
@@ -1,19 +0,0 @@
|
||||
Copyright (c) 2024 University of Helsinki
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,270 +1,31 @@
|
||||
# Oodikone
|
||||
# OodiKone2-Frontend
|
||||
Master:
|
||||
[](https://travis-ci.org/UniversityOfHelsinkiCS/oodikone2-frontend)
|
||||
|
||||
[](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone)
|
||||
Trunk:
|
||||
[](https://travis-ci.org/UniversityOfHelsinkiCS/oodikone2-frontend)
|
||||
|
||||
An application for analyzing university data, running at [https://oodikone.helsinki.fi/](https://oodikone.helsinki.fi/).
|
||||
|
||||
## ✔️ Requirements
|
||||
### Development
|
||||
fetch dependencies: `npm install`
|
||||
|
||||
To run Oodikone locally, you will need the following:
|
||||
|
||||
1. Applications
|
||||
- [Docker](https://docs.docker.com/install/) (version `20.10+`),
|
||||
- [Docker Compose](https://docs.docker.com/compose/install/) (version `1.29+`)
|
||||
- [npm](https://docs.npmjs.com/cli/v7) (version `7+`)
|
||||
2. Access to Toska Docker Hub
|
||||
3. For real data setup, access to Toska's production servers
|
||||
4. For some features to work, access to Toska's service tokens
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
Pre-installation steps:
|
||||
|
||||
- Clone this repo
|
||||
- `git@github.com:UniversityOfHelsinkiCS/oodikone.git`
|
||||
- Login to Toska's Docker Hub (see Toska's GitLab for more information)
|
||||
- Copy the `.env.template` file to `.env` and populate with correct tokens
|
||||
- `cp .env.template .env`
|
||||
|
||||
Then launch the CLI and follow the instructions:
|
||||
|
||||
```bash
|
||||
npm run cli
|
||||
```
|
||||
|
||||
_Please use a terminal at least 80 characters wide, the CLI is a bit rudimentary_ 😊
|
||||
|
||||
What different CLI options do:
|
||||
|
||||
1. Set up Oodikone from scratch
|
||||
- Cleans up any previous installations
|
||||
- Installs all needed npm packages locally
|
||||
- Sets up pre-commit linting hooks
|
||||
- Pulls and builds all needed Docker images and sets up dockerized development environment
|
||||
- Setup happens with anonymized data, which developers should use by default
|
||||
- **Note:** Running this option cleans up all real data too, so please don' run option 2 before this.
|
||||
2. Reset all real data
|
||||
- Cleans up any previous real data databases
|
||||
- Downloads needed database dumps from production servers.
|
||||
- Creates real data databases and populates them with downloaded dumps.
|
||||
3. Reset a single database
|
||||
- Like option 2, but only for single database
|
||||
- Leaves other databases untouched
|
||||
4. Restore data from dumps
|
||||
- Like option 2, but skips downloading, and simply restores databases from the dumps downloaded earlier
|
||||
5. Docker system prune
|
||||
- Runs `docker system prune -a && docker volume prune -a`
|
||||
- This will remove all your Docker data, also related to any other applications than Oodikone!
|
||||
- Sometimes necessary after strange errors due to caching
|
||||
6. Clear node_nodules
|
||||
- Deletes "nodemod" docker volumes and all local node_modules from each service
|
||||
- Useful when resolving permission/installation/library issues without the need to reset all data
|
||||
- Runs `npm ci` for every service locally
|
||||
|
||||
## ⌨️ Development
|
||||
|
||||
### Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Oodikone & Updater
|
||||
subgraph Oodikone
|
||||
client[React app] --> server[NodeJS server]
|
||||
server --> ok_redis(Redis)
|
||||
konedb[(kone-db)]
|
||||
userdb[(user-db)]
|
||||
end
|
||||
server -->|Read| sisdb[(sis-db)]
|
||||
subgraph Updater
|
||||
updaterscheduler[Scheduler] --> |Push BullMQ jobs| redis(Redis)
|
||||
updaterworker[Worker] -->|Write| sisdb
|
||||
updaterworker --> |Fetch BullMQ jobs| redis
|
||||
end
|
||||
end
|
||||
|
||||
subgraph Common Toska services
|
||||
jami[JAMI]
|
||||
pate[Pate]
|
||||
importer[Importer]
|
||||
end
|
||||
|
||||
updaterworker -->|Read db| importer
|
||||
updaterscheduler -->|Read db| importer
|
||||
importer -->|Fetch data| sisu[Sisu export APIs]
|
||||
server -->|Get IAM access| jami
|
||||
server -->|Send mail| pate
|
||||
|
||||
subgraph Analytics
|
||||
direction TB
|
||||
sentry[Sentry]
|
||||
graylog[Graylog]
|
||||
grafana[Grafana]
|
||||
end
|
||||
```
|
||||
|
||||
In development, backend and updater use the same Redis, but in production they are separate.
|
||||
|
||||
See also the [older graph](documentation/img/oodikone.png) for more information.
|
||||
|
||||
To read a bit more details about what updater-scheduler and updater-worker do, see their own READMEs:
|
||||
|
||||
- [updater-scheduler's README](./updater/sis-updater-scheduler/README.md)
|
||||
- [updater-worker's README](./updater/sis-updater-worker/README.md)
|
||||
|
||||
### Documentation
|
||||
|
||||
The `documentation` directory contains some notes on the sis-db schema and is meant as a place for storing useful information on Oodikone to make life easier for future developers. Feel free to add anything relevant!
|
||||
|
||||
For internal meeting notes and sensitive information, see Toska GitLab.
|
||||
|
||||
### Basics
|
||||
|
||||
The development environment is entirely configured in the `docker-compose.yml` file located in this repository. The file defines the services for Oodikone's two main components: **oodikone** and **updater**.
|
||||
|
||||
Running Oodikone with real data requires separated databases and redis, which are defined in `docker-compose.real.yml` file. Otherwise real data development environment uses the configuration as anonymized data development environment.
|
||||
|
||||
Some useful commands are defined in `package.json` and can be run with `npm run <command>` as follows:
|
||||
|
||||
- `npm run oodikone`: starts Oodikone with anonymized data
|
||||
- `npm run oodikone:real`: starts Oodikone with real data
|
||||
- `npm run oodikone:test`: starts Oodikone with non-persistent database (wipes after every reset)
|
||||
- `npm run oodikone:ci`: starts Oodikone in CI mode
|
||||
- `npm run updater`: starts updater with anonymized data
|
||||
- `npm run updater:real`: starts updater with real data
|
||||
- `npm run both`: starts Oodikone and updater with anonymized data
|
||||
- `npm run both:real`: starts Oodikone and updater with real data
|
||||
- `npm run docker:down`: stops the whole environment
|
||||
- `npm run testupdater`: runs tests for updater
|
||||
- `npm run flushredis`: clears Redis, which forces big calculations to be redone in e.g. studyprogramme overview and faculty views
|
||||
- `npm run install:local`: installs `node_modules` for each package (backend, frontend, sis-updater-scheduler and sis-updater-worker) locally
|
||||
- `npm run ci:local`: same as above, but sticks to the package versions pinned in package-lock
|
||||
- `npm run ci:docker`: installs `node_modules` for each package inside containers
|
||||
- `npm run ci:both`: runs both `npm run ci:local` and `npm run ci:docker`
|
||||
|
||||
Once you have ran setup for Oodikone , you can just execute the first one (`npm run oodikone`). After starting and waiting for a while for containers to compile, Oodikone can be accessed at [http://localhost:3000](http://localhost:3000/) and Adminer (database investigation tool) at [http://localhost:5050](http://localhost:5050/). Adminer requires you to login with username `postgres` and with any password you choose (for example `p`).
|
||||
|
||||
### Run.sh script
|
||||
|
||||
As said, the development environment runs entirely inside Docker containers. To keep `package.json` clean and not filled with predefined scripts, we have created a simple helper script called `./run.sh`. The script allows you to use docker-compose commands without the need to write long list of parameters. Try to run `./run.sh` in the root of the project and see what happens!
|
||||
|
||||
`./run.sh` is simply a wrapper script to run Oodikone, updater or both services in either anon or real mode. If you take a look at `package.json`, you can see that most of the predefined scripts above use `./run.sh` under the hood.
|
||||
|
||||
It is recommended to spend some time to become familiar with `docker` and `docker compose` cli commands. You can then use them directly or with `./run.sh` wrapper. Here is some examples for day-to-day development situations:
|
||||
|
||||
- `./run.sh oodikone anon pull`: pull all images related to Oodikone development
|
||||
- `./run.sh updater real up --build --force-recreate --detach`: start updater detached (in the background) in real data mode, but build new images before starting
|
||||
- `docker compose ps`: view the containers in the running environment
|
||||
- `docker compose logs frontend`: print logs for just frontend
|
||||
- `docker compose logs --follow --tail 100 backend`: print last hundred rows of backend logs and begin to follow them in your terminal window
|
||||
- `docker exec -it oodikone-backend sh`: open bash terminal inside backend container
|
||||
- `docker exec -it oodikone-sis-db psql -U postgres sis-db-real`: open psql client to investigate sis real data database
|
||||
|
||||
### User types for development
|
||||
|
||||
By default, you're login as `mluukkai` dev user when running Oodikone in development mode. If you want to debug with certain type of user, you can use mocking: go to the "Users" page, click "edit" on the user you want to use and then click the icon on the right corner of user card. Another way is to mock user headers - see frontend's API configuration for how this is done.
|
||||
|
||||
Anonymous data contains some preset user types, corresponding to most usual user types in real data. These are used in testing too (see "Testing" below for more info).
|
||||
local development: `npm run dev`
|
||||
|
||||
### Linting
|
||||
Install eslint: `npm install -g eslint`
|
||||
|
||||
Cli script sets up pre-commit hooks that are used to lint and fix files before committing. If your `package.json` file doesn't include "lint-staged" -key, please set hooks manually with `npm run prepare`.
|
||||
lint js & css: `npm run lint`
|
||||
lint only js: `npm run lint:js`
|
||||
lint only css: `npm run lint:css`
|
||||
lint fix for js: `npm run lintfix`
|
||||
|
||||
For more information on how files are tested, take a look at "lint-staged" in `package.json`. Some files (e.g. github action files, dockerfiles, shell scripts) are checked with external tools and may require you to install those tools in case you're modifying files in question.
|
||||
|
||||
Pre-commit hooks will fix auto-fixable problems. To set up quick formatting: In VSCode you can go to command palette, select "ESLint: Fix all auto-fixable problems" and assign a hotkey for it, like `SHIFT + CTRL + '.'`.
|
||||
### Building
|
||||
`npm run dist`
|
||||
|
||||
## 🔨 Testing & CI
|
||||
Builds optimized production package to /dist
|
||||
|
||||
We use Playwright for end-to-end testing.
|
||||
### Deployment
|
||||
|
||||
Playwright
|
||||
Read part deployment from [backend README](https://github.com/UniversityOfHelsinkiCS/oodikone2-backend/)
|
||||
|
||||
- E2E specs are in the `e2e/` folder.
|
||||
- Run all e2e specs with `npm run playwright`.
|
||||
- Run in headed mode with `npm run playwright:headed`.
|
||||
- Open in UI mode with `npm run playwright:ui`.
|
||||
- If using a command that runs `docker-compose.ci.yml`, remember to add CI=true before the playwright command, eg. `CI=true npm run playwright`.
|
||||
|
||||
Continuous integration (CI) works with Github actions and is defined in workflow files in `.github/workflows` folder:
|
||||
|
||||
- Oodikone setup for e2e and other tests in CI is defined in `docker-compose.ci.yml`. Take a look at this too if debugging github action workflows.
|
||||
- Tests are run on every push
|
||||
- After a successful test run, Oodikone is deployed to staging
|
||||
- After creating a release, Oodikone is deployed to production
|
||||
- Updater is deployed to production when pushed if tests pass
|
||||
- Test databases can be updated, instructions in [anonyymioodi README](anonyymioodi/README.md)
|
||||
|
||||
## ❓FAQ
|
||||
|
||||
### How to install a new package
|
||||
|
||||
Install the package locally to the relevant service with `npm install` followed by the script `npm run ci:docker`. The application running inside container uses your host-mounted package-lock; therefore when making any changes to packages do them first locally. Avoid running `npm install` inside docker containers. Our docker images run as non-root, and cannot write on the host mounted package-lock files (without sudo'ing and changing back the permissions later). The CLI has an option to clear all node_modules should the need arise.
|
||||
|
||||
### Modules are missing after an update to package.json
|
||||
|
||||
Module might be missing for example when someone else installs a new library and you pull the changes to the package files, without installing the acual packages. To sync the node_modules with the package files run `npm run ci:both`.
|
||||
|
||||
### Study guidance groups don't work on my machine
|
||||
|
||||
Make sure you have your VPN on.
|
||||
|
||||
### Everything is broken, can't get oodikone running, data is not there etc.
|
||||
|
||||
First: Try `npm run cli` option 1: _Set up oodikone from scratch_ and then option 2 to download and restore databases (or option 4 to skip downloading, if you've done it recently).
|
||||
|
||||
If that does not help, try option 5: _Docker system prune_. Notice that this clears up **all Docker data**, including data related to other projects. If this is not ok, you'll need to identify the Oodikone-specific Docker volumes yourself and caches and clear those invididually.
|
||||
|
||||
### How to run tests locally faster
|
||||
|
||||
Vite simplifies development but can be **very slow** when running tests.
|
||||
|
||||
To speed up testing, you can use Vite's preview mode. This will make the tests run faster, but keep in mind that you must rebuild the frontend code every time you make changes to it. To use the preview mode, follow these steps:
|
||||
|
||||
1. Run `npm run build` in the `services/frontend` directory.
|
||||
2. Run `npm run preview` in the `services/frontend` directory and keep it running.
|
||||
3. Set `PLAYWRIGHT_BASE_URL=http://localhost:4173` for Playwright.
|
||||
4. Start Oodikone with `npm run oodikone`.
|
||||
|
||||
After these steps, you can run your tests:
|
||||
|
||||
- Use `npm run playwright:ui` to open Playwright UI and run tests interactively.
|
||||
|
||||
Tests can also be run in headless mode, similar to how they are run in the CI/CD pipeline. To do this, use:
|
||||
|
||||
```bash
|
||||
npm run playwright -- <file path>
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```bash
|
||||
npm run playwright -- e2e/Language_center.js
|
||||
```
|
||||
|
||||
Running tests in headless mode may also help if your browser keeps crashing when running tests interactively. If you want to run all tests, use:
|
||||
|
||||
```bash
|
||||
npm run playwright
|
||||
```
|
||||
|
||||
## How to read clients minds
|
||||
|
||||
Search for client queries in grafana using a variation of this query:
|
||||
|
||||
```
|
||||
{app="oodikone"} | regexp `\s(?P<payload>\{.*\}$)` | line_format `{{.payload}}` | json userId, mockedBy | userId = `client` | mockedBy != `admin`
|
||||
```
|
||||
|
||||
For quick access to _any and all params_ under certain path:
|
||||
|
||||
```
|
||||
{app="oodikone"} |= `path` | regexp `\s(?P<payload>\{.*\}$)` | line_format `{{.payload}}` | json userId, mockedBy | userId = `client` | mockedBy != `admin`
|
||||
```
|
||||
|
||||
## ✌🏼 Maintainers and contribution
|
||||
|
||||
[Toska](https://toska.dev) - University of Helsinki
|
||||
|
||||
## 🛡️ Tietosuoja / data protection
|
||||
|
||||
See [tietosuoja.md](./documentation/tietosuoja.md).
|
||||
|
||||
Binary file not shown.
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
require_once('plugins/login-password-less.php');
|
||||
|
||||
/** Set allowed password
|
||||
* @param string result of password_hash
|
||||
*/
|
||||
return new AdminerLoginPasswordLess(
|
||||
$password_hash = password_hash("postgres", PASSWORD_DEFAULT)
|
||||
);
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
require_once('plugins/login-servers.php');
|
||||
$driver = "pgsql";
|
||||
|
||||
/** Set supported servers
|
||||
* @param array array($description => array("server" => , "driver" => "server|pgsql|sqlite|..."))
|
||||
*/
|
||||
return new AdminerLoginServers(
|
||||
$servers = array(
|
||||
"kone-db" => array("server" => "kone-db", "driver" => $driver),
|
||||
"sis-db" => array("server" => "sis-db", "driver" => $driver),
|
||||
"sis-importer-db" => array("server" => "sis-importer-db", "driver" => $driver),
|
||||
"user-db" => array("server" => "user-db", "driver" => $driver)
|
||||
)
|
||||
);
|
||||
@@ -1,96 +0,0 @@
|
||||
# Anonyymioodi - test database setup
|
||||
|
||||
This folder contains a guide and scripts to modify or create anonymous database images for Oodikone, mainly used for testing.
|
||||
|
||||
The quick guide should be enough for most purposes: updating schema, data, or the PostgreSQL version. If you need to generate completely new `sis-importer-db` and `sis-db` images (which shouldn't happen too often), follow the instructions in the section [Generate new sis-importer-db and sis-db completely from scratch](#generate-new-sis-importer-db-and-sis-db-completely-from-scratch).
|
||||
|
||||
## Scripts
|
||||
|
||||
`manage_test_dbs.sh` is the front end for the test database tooling. Run it without arguments for an interactive menu, or give it an action and one or more databases (or `all`) to run it directly:
|
||||
|
||||
```bash
|
||||
./manage_test_dbs.sh dump sis-db # dump sis-db from its running container
|
||||
./manage_test_dbs.sh upload sis-db # upload the current dump to s3
|
||||
./manage_test_dbs.sh release all # dump, upload and push images for every database
|
||||
```
|
||||
|
||||
Actions are `download`, `dump`, `upload` (upload + prune), `push`, `prune`, `release` (dump + upload + prune + push) and `rebuild` (download + push). Each one is a thin call to a script in this folder, which can also be used on its own:
|
||||
|
||||
| Script | What it does |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `dump_local_db.sh` | `pg_dump`s the databases from the running containers into `.databasedumps/` |
|
||||
| `download_dump_from_s3.sh` | Downloads the newest dump of each database from s3 (`-b <branch>` to pick dumps from a single branch) |
|
||||
| `upload_dump_to_s3.sh` | Uploads the dumps in `.databasedumps/test` to s3 as `<database>-<git_branch>-<datetime>.sql` |
|
||||
| `prune_s3_dumps.sh` | Deletes dumps older than 30 days from s3, always keeping the newest `master` dump of each database (`-d <days>`, `-n`) |
|
||||
| `build_and_push_image.sh` | Builds database images from the current dumps and pushes them to the Toska registry |
|
||||
| `create_s3_bucket.sh` | Creates the s3 bucket if it doesn't exist yet, run automatically before an upload |
|
||||
| `lib/common.sh` | Shared configuration (dump directories, s3 bucket, registry, database names) |
|
||||
|
||||
Dumps end up in `.databasedumps/test/<database>.sql`, which is where `db.Dockerfile` reads them from when an image is built. The s3 commands read their credentials from the `ACCESS_KEY` and `SECRET_KEY` environment variables (e.g. in CI). If neither is set, they fall back to `~/.s3cfg`, which can be found on the toska/dokumentaatio repo.
|
||||
|
||||
## Quick guide to updating a test database
|
||||
|
||||
1. Ensure you have the latest test-db. Just pulling images might not replace your existing volume, so you may need to remove it manually. This command might work: `docker volume rm oodikone-sis-db-data` (replace `oodikone-sis-db-data` with the correct volume name). The new image, including data, should be pulled the next time you launch Oodikone normally.
|
||||
|
||||
2. Do your dev work as usual with the test database, for example, using `npm run both`. Edit data as needed, (for example, run the updater via the frontend), and then run tests to see if they pass (follow the instructions in the [README](../README.md) to run the tests more quickly).
|
||||
|
||||
3. If you're updating `sis-db`:
|
||||
|
||||
- Modify the scheduler limit in [config.js](../updater/sis-updater-scheduler/src/config.js) by setting `module.exports.DEV_SCHEDULE_COUNT = null`.
|
||||
- Follow the `sis-updater-worker` logs to ensure everything is running smoothly (`docker logs --follow oodikone-sis-updater-worker`).
|
||||
|
||||
4. When you're satisfied with the database, create a dump:
|
||||
|
||||
- Use the command: `docker exec -i <dbname> pg_dump -Fc -U postgres <dbname> > anonyymioodi/<dbname>.sqz`.
|
||||
|
||||
5. Shut down the environment with `npm run docker:down`.
|
||||
|
||||
6. Navigate to this folder: `cd anonyymioodi`.
|
||||
|
||||
7. If you need to update the PostgreSQL version, do it now by changing the version in this directory's `docker-compose.yml`. The version should match the one used on the server to avoid compatibility issues.
|
||||
|
||||
8. Create a new container from the dump: `./create_container_from_dump.sh <dbname>`.
|
||||
|
||||
9. Commit and push it to the Toska registry: `./commit_and_push_to_toska_hub.sh <dbname>`.
|
||||
|
||||
10. Bring down the environment with `docker compose down`.
|
||||
|
||||
## Generate new sis-importer-db and sis-db completely from scratch
|
||||
|
||||
> [!CAUTION]
|
||||
> The more the data in `sis-importer-db` and `sis-db` changes, the more tests might break. Because of this, it's highly recommended to make as few changes as possible (for example, avoid changing the degree programs whose students are used in the test data).
|
||||
>
|
||||
> If you only need to make small changes, follow the quick guide above.
|
||||
|
||||
### Sis-importer-db
|
||||
|
||||
1. To generate a sample dump from test Sisu data, follow the instructions at [importer-db-staging-sampletaker](https://github.com/UniversityOfHelsinkiCS/sis-importer/tree/master/importer-db-staging-sampletaker) inside the `sis-importer` repo.
|
||||
|
||||
2. Copy `sis-importer-db.sqz` into this folder.
|
||||
|
||||
3. Run the following commands (in this folder) to create a new image from the dump and push it to the Toska registry:
|
||||
|
||||
```bash
|
||||
./create_container_from_dump.sh sis-importer-db
|
||||
./commit_and_push_to_toska_hub.sh sis-importer-db
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Sis-db
|
||||
|
||||
1. To ensure the newest `sis-importer-db` image is used:
|
||||
|
||||
- Delete all `sis-importer-db` images you have locally (list images with `docker images`, delete with `docker rmi <image ID>`).
|
||||
- Also, delete the old `sis-importer-db` volume (`docker volume rm sis-importer-db-data`).
|
||||
|
||||
2. Start Oodikone and Updater with `npm run both` (this will pull the newest `sis-importer-db` image you just pushed in the previous step).
|
||||
|
||||
3. Truncate all tables in `sis-db` **_except for `migrations` and `semesters`_** (the easiest way is to use Adminer).
|
||||
|
||||
4. Modify the scheduler limit in [config.js](../updater/sis-updater-scheduler/src/config.js) by setting `module.exports.DEV_SCHEDULE_COUNT = null`.
|
||||
|
||||
5. Navigate to <http://localhost:3000/updater> and click the three buttons (Update meta, Update students, Update curriculums) one by one. This will populate `sis-db` with data from the new `sis-importer-db`. You might want to do each update a couple of times, as the database tables affect each other, and not all necessary data might be generated on the first try.
|
||||
|
||||
6. Follow the `sis-updater-worker` logs to ensure everything is running smoothly (`docker logs --follow oodikone-sis-updater-worker`). If you see any errors, try clicking the buttons again.
|
||||
|
||||
7. Run all the tests to ensure everything is working as expected (follow the instructions in the [README](../README.md) to run the tests more quickly). If any tests fail, update the test files accordingly.
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Build database images from the dumps in .databasedumps/test and push them to
|
||||
# the Toska registry.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source-path=SCRIPTDIR
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR"/lib/common.sh
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $(basename "$0") <database>... | all
|
||||
|
||||
Builds an image of each given database and pushes it to $REGISTRY.
|
||||
The dump is read from .databasedumps/test/<database>.sql, so run
|
||||
dump_local_db.sh or download_dump_from_s3.sh first.
|
||||
|
||||
Databases: ${DATABASES[*]}
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
usage
|
||||
die "No database given"
|
||||
fi
|
||||
|
||||
build_and_push_image() {
|
||||
local database="$1"
|
||||
local image="$REGISTRY/$database:latest"
|
||||
local dump_file="$TEST_DUMP_DIR/$database.sql"
|
||||
|
||||
[[ -f "$dump_file" ]] || die "No dump found at $dump_file, run dump_local_db.sh or download_dump_from_s3.sh first"
|
||||
|
||||
infomsg "Building $image"
|
||||
docker build "$PROJECT_ROOT" -f "$PROJECT_ROOT/db.Dockerfile" -t "$image" --build-arg DB_NAME="$database"
|
||||
|
||||
infomsg "Pushing $image"
|
||||
docker push "$image"
|
||||
successmsg "Pushed $image"
|
||||
}
|
||||
|
||||
resolve_databases "$@"
|
||||
|
||||
for database in "${selected_databases[@]}"; do
|
||||
build_and_push_image "$database"
|
||||
done
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# === Config ===
|
||||
|
||||
# Quit if failing
|
||||
set -euo pipefail
|
||||
|
||||
# Print messages and logs that are not script output to stderr
|
||||
msg() {
|
||||
echo >&2 -e "${1-}"
|
||||
}
|
||||
|
||||
# Some special types of messages with colours
|
||||
successmsg() {
|
||||
msg "${GREEN}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
errormsg() {
|
||||
msg "${RED}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
infomsg() {
|
||||
msg "${BLUE}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
warningmsg() {
|
||||
msg "${ORANGE}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
|
||||
# Setup colors for messages if running interactive shell
|
||||
if [[ -t 2 ]] && [[ "${TERM-}" != "dumb" ]]; then
|
||||
NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m'
|
||||
else
|
||||
NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE=''
|
||||
fi
|
||||
|
||||
args=("$@")
|
||||
if [[ ${#args[@]} -ne 1 ]]; then
|
||||
errormsg "No database given, exiting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
infomsg "Committing and pushing to registry"
|
||||
database=${args[0]}
|
||||
docker commit "oodikone-$database" "registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/$database"
|
||||
docker push "registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/$database"
|
||||
successmsg "Succesfully pushed"
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# === Config ===
|
||||
|
||||
# Quit if failing
|
||||
set -euo pipefail
|
||||
|
||||
# Print messages and logs that are not script output to stderr
|
||||
msg() {
|
||||
echo >&2 -e "${1-}"
|
||||
}
|
||||
|
||||
# Some special types of messages with colours
|
||||
successmsg() {
|
||||
msg "${GREEN}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
errormsg() {
|
||||
msg "${RED}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
infomsg() {
|
||||
msg "${BLUE}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
warningmsg() {
|
||||
msg "${ORANGE}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
|
||||
# Setup colors for messages if running interactive shell
|
||||
if [[ -t 2 ]] && [[ "${TERM-}" != "dumb" ]]; then
|
||||
NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m'
|
||||
else
|
||||
NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE=''
|
||||
fi
|
||||
|
||||
# === Create images ===
|
||||
|
||||
retry () {
|
||||
sleep 5
|
||||
for i in {1..60}; do
|
||||
"$@" && break || warningmsg "Retry attempt $i failed, waiting..." && sleep 5;
|
||||
done
|
||||
}
|
||||
|
||||
args=("$@")
|
||||
if [[ ${#args[@]} -ne 1 ]]; then
|
||||
errormsg "No database given, exiting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
infomsg "Restoring PostgreSQL dumps from backups. This might take a while."
|
||||
database="${args[0]}"
|
||||
docker compose down --remove-orphans
|
||||
docker compose up -d "$database"
|
||||
|
||||
database_dump="$database.sqz"
|
||||
infomsg "Attempting to restore database $database from dump $database_dump"
|
||||
retry docker compose exec "$database" pg_isready --dbname="$database"
|
||||
docker exec -i "oodikone-$database" /bin/bash -c "pg_restore --username=postgres \
|
||||
--format=custom --dbname=$database --no-owner" < "$database_dump"
|
||||
successmsg "Database $database succesfully created"
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Create the s3 bucket used for the test database dumps, if it doesn't exist yet.
|
||||
# Doing nothing when the bucket is already there, so this is safe to run every time.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source-path=SCRIPTDIR
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR"/lib/common.sh
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $(basename "$0")
|
||||
|
||||
Creates the bucket $S3_BUCKET if it doesn't exist yet, and does nothing if it does.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
require_s3_config
|
||||
|
||||
# s3cmd info exits with an error when the bucket doesn't exist
|
||||
if s3 info "$S3_BUCKET" > /dev/null 2>&1; then
|
||||
infomsg "Bucket $S3_BUCKET already exists"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
warningmsg "Bucket $S3_BUCKET doesn't exist, creating it"
|
||||
s3 mb "$S3_BUCKET"
|
||||
successmsg "Created bucket $S3_BUCKET"
|
||||
@@ -1,59 +0,0 @@
|
||||
x-defaults: &defaults
|
||||
restart: unless-stopped
|
||||
|
||||
x-healthcheck-defaults: &healthcheck-defaults
|
||||
interval: 30s # Normal healthcehck interval
|
||||
timeout: 5s
|
||||
retries: 10 # Retries until SIGKILl
|
||||
start_interval: 3s # How often healthcheck runs while starting
|
||||
start_period: 15s # How long startup period is
|
||||
|
||||
services:
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-kone-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.10
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
user-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-user-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.10
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
sis-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-sis-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.10
|
||||
shm_size: 2gb
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
sis-importer-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-sis-importer-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-importer-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.10
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
<<: *healthcheck-defaults
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Download database dumps from s3 into .databasedumps/s3, and copy them to
|
||||
# .databasedumps/test where db.Dockerfile reads them from.
|
||||
#
|
||||
# Dumps are uploaded as <db_name>-<git_branch>-<datetime>.sql (see
|
||||
# upload_dump_to_s3.sh), so the newest matching dump is the one downloaded.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source-path=SCRIPTDIR
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR"/lib/common.sh
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $(basename "$0") [-b|--branch <branch>] <database>... | all
|
||||
|
||||
Downloads the newest dump of each given database from $S3_TARGET.
|
||||
Databases: ${DATABASES[*]}
|
||||
|
||||
Options:
|
||||
-b, --branch <branch> Only consider dumps uploaded from this git branch
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
branch=""
|
||||
arguments=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-b | --branch)
|
||||
branch="${2-}"
|
||||
[[ -n "$branch" ]] || {
|
||||
usage
|
||||
die "Missing value for $1"
|
||||
}
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
-*)
|
||||
usage
|
||||
die "Unknown option: $1"
|
||||
;;
|
||||
*)
|
||||
arguments+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Newest object in s3 for the given database, or empty if there are none
|
||||
newest_dump_in_s3() {
|
||||
local database="$1"
|
||||
local prefix="$S3_TARGET/$database-"
|
||||
if [[ -n "$branch" ]]; then prefix="$prefix$(sanitize_branch_name "$branch")-"; fi
|
||||
|
||||
# s3cmd ls prints "<date> <time> <size> <object>", so sorting by the first two
|
||||
# columns in reverse puts the newest dump first
|
||||
s3 ls "$prefix" | sort -rk1,2 | awk 'NR == 1 { print $4 }'
|
||||
}
|
||||
|
||||
download_database() {
|
||||
local database="$1"
|
||||
local dump_file="$S3_DUMP_DIR/$database.sql"
|
||||
local newest_dump
|
||||
newest_dump="$(newest_dump_in_s3 "$database")"
|
||||
|
||||
[[ -n "$newest_dump" ]] || die "No dumps found for $database in $S3_TARGET${branch:+ (branch $branch)}"
|
||||
|
||||
infomsg "Downloading $newest_dump"
|
||||
s3 get --force "$newest_dump" "$dump_file"
|
||||
cp --remove-destination "$dump_file" "$TEST_DUMP_DIR/$database.sql"
|
||||
successmsg "Downloaded $newest_dump to $dump_file"
|
||||
}
|
||||
|
||||
if [[ ${#arguments[@]} -eq 0 ]]; then
|
||||
usage
|
||||
die "No database given"
|
||||
fi
|
||||
|
||||
resolve_databases "${arguments[@]}"
|
||||
require_s3_config
|
||||
make_dump_dirs
|
||||
|
||||
for database in "${selected_databases[@]}"; do
|
||||
download_database "$database"
|
||||
done
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Dump one or more databases from the running docker containers into
|
||||
# .databasedumps/local_containers, and copy them to .databasedumps/test where
|
||||
# db.Dockerfile reads them from.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source-path=SCRIPTDIR
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR"/lib/common.sh
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $(basename "$0") <database>... | all
|
||||
|
||||
Dumps the given databases from their running containers.
|
||||
Databases: ${DATABASES[*]}
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
usage
|
||||
die "No database given"
|
||||
fi
|
||||
|
||||
dump_database() {
|
||||
local database="$1"
|
||||
local container_name="oodikone-$database"
|
||||
local database_name
|
||||
database_name="$(postgres_database_name "$database")"
|
||||
local dump_file="$LOCAL_DUMP_DIR/$database.sql"
|
||||
|
||||
infomsg "Dumping $database from container $container_name"
|
||||
# Dump to a temporary file first, so that a failed dump doesn't overwrite the previous one
|
||||
docker exec -i "$container_name" pg_dump -Fp -U postgres -d "$database_name" > "$dump_file.tmp"
|
||||
mv "$dump_file.tmp" "$dump_file"
|
||||
cp --remove-destination "$dump_file" "$TEST_DUMP_DIR/$database.sql"
|
||||
successmsg "Dumped $database to $dump_file"
|
||||
}
|
||||
|
||||
resolve_databases "$@"
|
||||
make_dump_dirs
|
||||
|
||||
for database in "${selected_databases[@]}"; do
|
||||
dump_database "$database"
|
||||
done
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Shared configuration and helpers for the anonyymioodi database scripts.
|
||||
# Meant to be sourced by the other scripts in this folder, not run on its own.
|
||||
|
||||
# The configuration below is used by the sourcing scripts, not by this file
|
||||
# shellcheck disable=SC2034
|
||||
|
||||
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
||||
source "$PROJECT_ROOT"/scripts/utils.sh
|
||||
|
||||
# Dumps taken from running docker containers
|
||||
LOCAL_DUMP_DIR="$PROJECT_ROOT/.databasedumps/local_containers"
|
||||
# Dumps downloaded from s3
|
||||
S3_DUMP_DIR="$PROJECT_ROOT/.databasedumps/s3"
|
||||
# Where db.Dockerfile copies the .sql files from when an image is built
|
||||
TEST_DUMP_DIR="$PROJECT_ROOT/.databasedumps/test"
|
||||
|
||||
# Used as a fallback when ACCESS_KEY and SECRET_KEY aren't set in the environment
|
||||
S3_CONFIG_FILE=~/.s3cfg
|
||||
S3_HOST="s3.datacloud.helsinki.fi"
|
||||
S3_BUCKET="s3://oodikone"
|
||||
# Dumps live under a prefix of their own inside the bucket
|
||||
S3_TARGET="$S3_BUCKET/test-dbs"
|
||||
# Dumps older than this are pruned from s3, except for the newest dump of each database
|
||||
S3_DUMP_RETENTION_DAYS=30
|
||||
|
||||
REGISTRY="registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi"
|
||||
|
||||
# Databases, named after their docker compose services
|
||||
DATABASES=("user-db" "kone-db" "sis-db" "sis-importer-db" "jami-db")
|
||||
|
||||
# Set by resolve_databases, read by the scripts sourcing this file
|
||||
selected_databases=()
|
||||
|
||||
# The name of the postgres database inside the container. It matches the service
|
||||
# name for every database except jami-db.
|
||||
postgres_database_name() {
|
||||
local database="$1"
|
||||
if [[ "$database" == "jami-db" ]]; then
|
||||
echo "postgres"
|
||||
else
|
||||
echo "$database"
|
||||
fi
|
||||
}
|
||||
|
||||
# Validates the given database names and expands "all" to every database.
|
||||
# The result is set to the global array $selected_databases, because dying
|
||||
# inside a subshell wouldn't stop the calling script.
|
||||
resolve_databases() {
|
||||
selected_databases=()
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
die "No database given, expected one or more of: ${DATABASES[*]} all"
|
||||
fi
|
||||
|
||||
for argument in "$@"; do
|
||||
if [[ "$argument" == "all" ]]; then
|
||||
selected_databases=("${DATABASES[@]}")
|
||||
return
|
||||
fi
|
||||
|
||||
# Check if given databases are correct ones
|
||||
local known=false
|
||||
for database in "${DATABASES[@]}"; do
|
||||
if [[ "$argument" == "$database" ]]; then known=true; fi
|
||||
done
|
||||
$known || die "Unknown database: $argument. Expected one or more of: ${DATABASES[*]} all"
|
||||
|
||||
selected_databases+=("$argument")
|
||||
done
|
||||
}
|
||||
|
||||
# Dumps in s3 are named after the branch they were uploaded from, so the branch
|
||||
# name has to be usable as a part of a file name. E.g. feature/foo -> feature-foo.
|
||||
# feature/foo would result in a new bucket "feature" with a sub-bucket "foo"
|
||||
sanitize_branch_name() {
|
||||
echo "${1//[^A-Za-z0-9._-]/-}"
|
||||
}
|
||||
|
||||
current_branch_name() {
|
||||
local branch
|
||||
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
if [[ "$branch" == "HEAD" ]]; then # Detached head, use the commit instead
|
||||
branch="$(git rev-parse --short HEAD)"
|
||||
fi
|
||||
sanitize_branch_name "$branch"
|
||||
}
|
||||
|
||||
has_s3_env_credentials() {
|
||||
[[ -n "${ACCESS_KEY-}" && -n "${SECRET_KEY-}" ]]
|
||||
}
|
||||
|
||||
# Credentials come from ACCESS_KEY and SECRET_KEY when both are set, and from
|
||||
# $S3_CONFIG_FILE when neither is
|
||||
require_s3_config() {
|
||||
command -v s3cmd > /dev/null || die "s3cmd not found, please install it first"
|
||||
|
||||
if [[ -n "${ACCESS_KEY-}" || -n "${SECRET_KEY-}" ]]; then
|
||||
has_s3_env_credentials || die "Only one of ACCESS_KEY and SECRET_KEY is set, set both or neither"
|
||||
return
|
||||
fi
|
||||
|
||||
[[ -f "$S3_CONFIG_FILE" ]] || die "Set ACCESS_KEY and SECRET_KEY, or add $S3_CONFIG_FILE. The \".s3cfg\" config file can be found on toska/dokumentaatio repo"
|
||||
}
|
||||
|
||||
s3_env_config() {
|
||||
cat <<EOF
|
||||
[default]
|
||||
access_key = $ACCESS_KEY
|
||||
secret_key = $SECRET_KEY
|
||||
host_base = $S3_HOST
|
||||
host_bucket = $S3_HOST
|
||||
use_https = True
|
||||
check_ssl_certificate = True
|
||||
EOF
|
||||
}
|
||||
|
||||
# Runs s3cmd with the credentials chosen by require_s3_config. The environment
|
||||
# credentials are passed through process substitution, so that they aren't
|
||||
# written to disk or visible in the process list.
|
||||
s3() {
|
||||
if has_s3_env_credentials; then
|
||||
s3cmd -c <(s3_env_config) "$@"
|
||||
else
|
||||
s3cmd -c "$S3_CONFIG_FILE" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# The dump directories are created lazily by the scripts that write into them
|
||||
make_dump_dirs() {
|
||||
mkdir -p "$LOCAL_DUMP_DIR" "$S3_DUMP_DIR" "$TEST_DUMP_DIR"
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Front end for the test database scripts in this folder. Run it without
|
||||
# arguments for an interactive menu, or give it an action and a database to run
|
||||
# it directly, e.g. in CI.
|
||||
#
|
||||
# Each action is just a call to one of the scripts next to this one, so they can
|
||||
# also be used on their own.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Here BASH_SOURCE[0] is used instead of $0 because it works
|
||||
# consistently when ran normally and when sourced
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source-path=SCRIPTDIR
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR"/lib/common.sh
|
||||
|
||||
ACTIONS=("download" "dump" "upload" "push" "prune" "release" "rebuild")
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $(basename "$0") [<action> <database>... | all]
|
||||
|
||||
Runs without arguments for an interactive menu.
|
||||
|
||||
Actions:
|
||||
download Download the newest dumps from s3
|
||||
dump Dump the databases from the running docker containers
|
||||
|
||||
upload Upload the current dumps to s3, then prune old dumps
|
||||
push Build images from the current dumps and push them to the registry
|
||||
prune Delete dumps older than $S3_DUMP_RETENTION_DAYS days from s3, keeping the newest one
|
||||
|
||||
release Dump from the running containers, then upload, prune and push (shorthand)
|
||||
rebuild Download the dumps from s3, then push (shorthand)
|
||||
|
||||
Databases: ${DATABASES[*]} all
|
||||
EOF
|
||||
}
|
||||
|
||||
# === Actions ===
|
||||
|
||||
download_dump() {
|
||||
"$SCRIPT_DIR"/download_dump_from_s3.sh "$1"
|
||||
}
|
||||
|
||||
dump_database() {
|
||||
"$SCRIPT_DIR"/dump_local_db.sh "$1"
|
||||
}
|
||||
|
||||
upload_dump() {
|
||||
"$SCRIPT_DIR"/upload_dump_to_s3.sh "$1"
|
||||
}
|
||||
|
||||
prune_dumps() {
|
||||
"$SCRIPT_DIR"/prune_s3_dumps.sh "$1"
|
||||
}
|
||||
|
||||
push_image() {
|
||||
"$SCRIPT_DIR"/build_and_push_image.sh "$1"
|
||||
}
|
||||
|
||||
run_action() {
|
||||
local action="$1"
|
||||
local database="$2"
|
||||
|
||||
case "$action" in
|
||||
download) download_dump "$database" ;;
|
||||
dump) dump_database "$database" ;;
|
||||
upload)
|
||||
upload_dump "$database"
|
||||
prune_dumps "$database"
|
||||
;;
|
||||
push) push_image "$database" ;;
|
||||
prune) prune_dumps "$database" ;;
|
||||
release)
|
||||
dump_database "$database"
|
||||
upload_dump "$database"
|
||||
prune_dumps "$database"
|
||||
push_image "$database"
|
||||
;;
|
||||
rebuild)
|
||||
download_dump "$database"
|
||||
push_image "$database"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
die "Unknown action: $action"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
run_action_for_all() {
|
||||
local action="$1"
|
||||
shift
|
||||
|
||||
# Check if command was a proper command
|
||||
local known_action=false
|
||||
for known in "${ACTIONS[@]}"; do
|
||||
if [[ "$action" == "$known" ]]; then known_action=true; fi
|
||||
done
|
||||
if ! $known_action; then
|
||||
usage
|
||||
die "Unknown action: $action"
|
||||
fi
|
||||
|
||||
resolve_databases "$@"
|
||||
for database in "${selected_databases[@]}"; do
|
||||
run_action "$action" "$database"
|
||||
done
|
||||
successmsg "Action $action done for: ${selected_databases[*]}"
|
||||
}
|
||||
|
||||
# === Interactive menu ===
|
||||
|
||||
ask_and_run() {
|
||||
local action database
|
||||
|
||||
PS3="Select action: "
|
||||
select action in "${ACTIONS[@]}" "quit"; do
|
||||
[[ -n "${action-}" ]] || continue
|
||||
if [[ "$action" == "quit" ]]; then exit 0; fi
|
||||
break
|
||||
done
|
||||
|
||||
PS3="Select database: "
|
||||
select database in "${DATABASES[@]}" "all"; do
|
||||
[[ -n "${database-}" ]] || continue
|
||||
break
|
||||
done
|
||||
|
||||
run_action_for_all "$action" "$database"
|
||||
}
|
||||
|
||||
# === Run ===
|
||||
|
||||
if [[ "${1-}" == "-h" || "${1-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
while true; do
|
||||
ask_and_run
|
||||
echo
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
usage
|
||||
die "Both an action and a database are required"
|
||||
fi
|
||||
|
||||
run_action_for_all "$@"
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Delete old database dumps from s3. Dumps older than the retention period are
|
||||
# removed, but the newest master dump of each database is always kept, so that a
|
||||
# database which hasn't changed in a while can still be downloaded. Dumps from
|
||||
# other branches aren't kept, so that old branches don't accumulate dumps.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source-path=SCRIPTDIR
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR"/lib/common.sh
|
||||
|
||||
# The newest dump of each database from this branch is never pruned
|
||||
MASTER_BRANCH="master"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $(basename "$0") [-n|--dry-run] [-d|--days <days>] <database>... | all
|
||||
|
||||
Deletes the dumps in $S3_TARGET older than the given number of days
|
||||
(default $S3_DUMP_RETENTION_DAYS). The newest $MASTER_BRANCH dump of each database is
|
||||
always kept, or the newest dump of any branch if the database has no $MASTER_BRANCH dumps.
|
||||
|
||||
Databases: ${DATABASES[*]}
|
||||
|
||||
Options:
|
||||
-n, --dry-run Print the dumps that would be deleted instead of deleting them
|
||||
-d, --days <days> Delete dumps older than this many days
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
dry_run=false
|
||||
days="$S3_DUMP_RETENTION_DAYS"
|
||||
arguments=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-n | --dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
-d | --days)
|
||||
days="${2-}"
|
||||
[[ "$days" =~ ^[0-9]+$ ]] || {
|
||||
usage
|
||||
die "Expected a number of days for $1, got: $days"
|
||||
}
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
-*)
|
||||
usage
|
||||
die "Unknown option: $1"
|
||||
;;
|
||||
*)
|
||||
arguments+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#arguments[@]} -eq 0 ]]; then
|
||||
usage
|
||||
die "No database given"
|
||||
fi
|
||||
|
||||
prune_database() {
|
||||
local database="$1"
|
||||
local deleted=0
|
||||
|
||||
# s3cmd ls prints "<date> <time> <size> <object>", so sorting by the first two
|
||||
# columns in reverse puts the newest dump first
|
||||
local dumps
|
||||
dumps="$(s3 ls "$S3_TARGET/$database-" | sort -rk1,2)"
|
||||
|
||||
# Find the newest master dump or in the case of it missing, the newest dump in general
|
||||
local kept
|
||||
kept="$(awk -v pattern="/$database-$MASTER_BRANCH-[0-9]+-[0-9]+[.]sql$" '$4 ~ pattern { print $4; exit }' <<< "$dumps")"
|
||||
[[ -n "$kept" ]] || kept="$(awk 'NR == 1 { print $4 }' <<< "$dumps")"
|
||||
|
||||
# Remove any dumps older than the dump retention date (or -d)
|
||||
# except for master for which at least one dump is saved
|
||||
while read -r date time _ dump; do
|
||||
[[ -n "$dump" && "$dump" != "$kept" && "$date $time" < "$cutoff" ]] || continue
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
infomsg "Dry run, would delete $dump"
|
||||
else
|
||||
s3 del "$dump"
|
||||
fi
|
||||
deleted=$((deleted + 1))
|
||||
done <<< "$dumps"
|
||||
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
successmsg "Would have pruned $deleted dump(s) of $database older than $days days"
|
||||
else
|
||||
successmsg "Pruned $deleted dump(s) of $database older than $days days"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_databases "${arguments[@]}"
|
||||
require_s3_config
|
||||
|
||||
cutoff="$(date -u -d "-$days days" '+%Y-%m-%d %H:%M')"
|
||||
|
||||
for database in "${selected_databases[@]}"; do
|
||||
prune_database "$database"
|
||||
done
|
||||
@@ -1,103 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This script will first make db dumps of the test environment and upload the dumps to staging
|
||||
|
||||
|
||||
. ../scripts/utils.sh
|
||||
set -euo pipefail
|
||||
|
||||
main() {
|
||||
declare -r db_names="sis-db kone-db user-db"
|
||||
declare -A db_url_names
|
||||
db_url_names["sis-db"]="SIS_DB_URL"
|
||||
db_url_names["kone-db"]="DB_URL_KONE"
|
||||
db_url_names["user-db"]="DB_URL_USER"
|
||||
|
||||
declare -r dump_directory="dumps"
|
||||
|
||||
# Takes the first db-tools pod
|
||||
check_oc_logged_staging
|
||||
db_tools_name=$(oc get pods -l app=db-tools --output jsonpath='{.items[0].metadata.name}')
|
||||
readonly db_tools_name
|
||||
|
||||
|
||||
create_dumps
|
||||
upload_and_migrate_dumps
|
||||
}
|
||||
|
||||
|
||||
|
||||
function create_dumps() {
|
||||
echo "Starting test/anon containers..."
|
||||
(npm run docker:down && npm run both) || die "Failed to start containers"
|
||||
echo
|
||||
echo "Waiting for containers to start..."
|
||||
sleep 10 # wait for containers to start
|
||||
|
||||
# Create dumps for all dbs
|
||||
echo
|
||||
echo "Creating dumps for $db_names"
|
||||
echo
|
||||
|
||||
# Create dir if not exists
|
||||
if [ ! -d $dump_directory ]; then mkdir -p $dump_directory; fi
|
||||
|
||||
for db in $db_names
|
||||
do
|
||||
create_dump_from_db "$db"
|
||||
if [ ! -f "$dump_directory/$db.sqz" ]; then die "Db dump not created"; fi
|
||||
done
|
||||
|
||||
echo "Dumps can be found in $PWD/\*.sqz"
|
||||
}
|
||||
|
||||
|
||||
# $1 db-name
|
||||
function create_dump_from_db() {
|
||||
echo "Dumping db $1..."
|
||||
|
||||
docker exec "oodikone-$1" pg_dump -Fc -U postgres "$1" > "$dump_directory/$1.sqz" || die "Failed to dump db $1"
|
||||
}
|
||||
|
||||
|
||||
function check_oc_logged_staging() {
|
||||
echo -n "Checking if oc is logged into toska staging..."
|
||||
[[ $(oc config current-context) == "toska/api-ocp-test-0-k8s-it-helsinki-fi"* ]] || die "\nNot logged in to toska staging environment: $(oc config current-context)"
|
||||
echo " OK"
|
||||
}
|
||||
|
||||
|
||||
function upload_and_migrate_dumps() {
|
||||
|
||||
local target_dump_directory="$db_tools_name:/tmp/oodikone-staging-dumps"
|
||||
echo -n "Dumping $dump_directory/ to $target_dump_directory..."
|
||||
oc rsync "$dump_directory/" "$target_dump_directory" 1> /dev/null # show errors
|
||||
echo " OK"
|
||||
echo
|
||||
|
||||
for db in $db_names
|
||||
do
|
||||
migrate_dumps "$db"
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
function migrate_dumps() {
|
||||
local db_url_b64
|
||||
local db_url_raw
|
||||
local db_url
|
||||
|
||||
db_url_b64=$(oc get secrets -l app.kubernetes.io/instance=oodikone --output jsonpath="{.items[0].data.${db_url_names[$1]}}")
|
||||
db_url_raw=$(echo "$db_url_b64" | base64 --decode)
|
||||
db_url="${db_url_raw%\?*}"
|
||||
|
||||
[[ $db_url != *"oodikone_*_test"* ]] || die "Trying to use non-test database: $db_url"
|
||||
|
||||
echo "Migrating $1 to url: $db_url"
|
||||
|
||||
oc exec "$db_tools_name" -- pg_restore --clean --no-owner -d "$db_url" "/tmp/oodikone-staging-dumps/$1.sqz"
|
||||
}
|
||||
|
||||
|
||||
# Run script
|
||||
main
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Upload database dumps from .databasedumps/test to s3, naming the objects
|
||||
# <database>-<git_branch>-<datetime>.sql so that dumps don't overwrite each other.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source-path=SCRIPTDIR
|
||||
# shellcheck source=lib/common.sh
|
||||
source "$SCRIPT_DIR"/lib/common.sh
|
||||
|
||||
usage() {
|
||||
cat >&2 <<EOF
|
||||
Usage: $(basename "$0") [-n|--dry-run] <database>... | all
|
||||
|
||||
Uploads the dumps in .databasedumps/test to $S3_TARGET
|
||||
as <database>-<git_branch>-<datetime>.sql. Run dump_local_db.sh first to
|
||||
create the dumps.
|
||||
|
||||
Databases: ${DATABASES[*]}
|
||||
|
||||
Options:
|
||||
-n, --dry-run Print the s3cmd command instead of running it
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
dry_run=false
|
||||
arguments=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-n | --dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
-*)
|
||||
usage
|
||||
die "Unknown option: $1"
|
||||
;;
|
||||
*)
|
||||
arguments+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#arguments[@]} -eq 0 ]]; then
|
||||
usage
|
||||
die "No database given"
|
||||
fi
|
||||
|
||||
upload_database() {
|
||||
local database="$1"
|
||||
local dump_file="$TEST_DUMP_DIR/$database.sql"
|
||||
|
||||
[[ -f "$dump_file" ]] || die "No dump found at $dump_file, run dump_local_db.sh first"
|
||||
[[ -s "$dump_file" ]] || die "Dump is empty: $dump_file"
|
||||
|
||||
local datetime
|
||||
datetime="$(date +%Y%m%d-%H%M%S)"
|
||||
local target="$S3_TARGET/$database-$branch-$datetime.sql"
|
||||
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
infomsg "Dry run, would run: s3cmd put $dump_file $target"
|
||||
return
|
||||
fi
|
||||
|
||||
infomsg "Pushing $dump_file to $target"
|
||||
s3 put "$dump_file" "$target"
|
||||
successmsg "Uploaded $dump_file to $target"
|
||||
}
|
||||
|
||||
resolve_databases "${arguments[@]}"
|
||||
require_s3_config
|
||||
|
||||
if [[ "$dry_run" == false ]]; then
|
||||
"$SCRIPT_DIR"/create_s3_bucket.sh
|
||||
fi
|
||||
|
||||
branch="$(current_branch_name)"
|
||||
|
||||
for database in "${selected_databases[@]}"; do
|
||||
upload_database "$database"
|
||||
done
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This script is used to setup oodikone with interactive cli. Base for script:
|
||||
# https://betterdev.blog/minimal-safe-bash-script-template/
|
||||
|
||||
# === Config ===
|
||||
|
||||
# Fail immediately if script fails, unbound variables are referenced
|
||||
# or command inside pipe fails. -E ensures cleanup trap fires in rare ERR cases.
|
||||
set -euoE pipefail
|
||||
|
||||
# Set up constants
|
||||
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
# Source functions for setup
|
||||
source "$PROJECT_ROOT"/scripts/functions_for_setup.sh
|
||||
|
||||
# Try to run cleanup function if things fail.
|
||||
trap cleanup SIGINT SIGTERM ERR EXIT
|
||||
|
||||
# Run docker compose down on cleanup
|
||||
cleanup() {
|
||||
trap - SIGINT SIGTERM ERR EXIT
|
||||
warningmsg "Trying to run docker compose down and remove orphans"
|
||||
docker compose --profile oodikone --profile updater down --remove-orphans
|
||||
}
|
||||
|
||||
# === CLI ===
|
||||
|
||||
show_welcome() {
|
||||
local cashmoneyyellow
|
||||
cashmoneyyellow=$(tput setaf 221)
|
||||
local normal
|
||||
normal=$(tput sgr0)
|
||||
if [ "$(tput cols)" -gt "76" ]; then
|
||||
while IFS="" read -r p || [ -n "$p" ]; do
|
||||
printf '%40s\n' "${cashmoneyyellow}$p${normal}"
|
||||
done < "$PROJECT_ROOT"/scripts/assets/logo.txt
|
||||
fi
|
||||
infomsg "Welcome to Oodikone CLI!"
|
||||
msg "This tool helps you in managing the project configuration. If you are new to
|
||||
Oodikone development, you should probably run \"Set up oodikone from scratch.\" which
|
||||
will take care of setting up Oodikone for you. See README for more details."
|
||||
msg ""
|
||||
}
|
||||
|
||||
set_custom_select_prompt() {
|
||||
PS3="Please enter your choice: "
|
||||
|
||||
options=(
|
||||
"Set up Oodikone from scratch"
|
||||
"Reset all data"
|
||||
"Reset single database"
|
||||
"Restore data from dumps"
|
||||
"Docker system prune"
|
||||
"Nuke local node_modules"
|
||||
"Quit"
|
||||
)
|
||||
}
|
||||
|
||||
# Run scripts and set the prompt
|
||||
show_welcome
|
||||
init_dirs
|
||||
get_s3_config
|
||||
set_custom_select_prompt
|
||||
|
||||
while true; do
|
||||
select opt in "${options[@]}"; do
|
||||
case $opt in
|
||||
"Set up Oodikone from scratch")
|
||||
set_up_oodikone_from_scratch;;
|
||||
"Reset all data")
|
||||
reset_all_data;;
|
||||
"Reset single database")
|
||||
reset_single_database
|
||||
set_custom_select_prompt # Set prompt back to initial values
|
||||
;;
|
||||
"Restore data from dumps")
|
||||
restore_data_from_dumps;;
|
||||
"Docker system prune")
|
||||
docker_prune;;
|
||||
"Nuke local node_modules")
|
||||
nuke_node_modules;;
|
||||
"Quit")
|
||||
break 2;;
|
||||
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
|
||||
";;
|
||||
esac
|
||||
break
|
||||
done
|
||||
done
|
||||
@@ -1,19 +0,0 @@
|
||||
FROM postgres:15.10
|
||||
|
||||
# Remember to define this variable
|
||||
ARG DB_NAME
|
||||
ENV DB_NAME=$DB_NAME
|
||||
|
||||
ENV PGDATA=/data
|
||||
ENV POSTGRES_USER=postgres
|
||||
ENV POSTGRES_PASSWORD=postgres
|
||||
ENV POSTGRES_HOST_AUTH_METHOD=trust
|
||||
ENV POSTGRES_DB=$DB_NAME
|
||||
|
||||
# RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends pgcli
|
||||
|
||||
COPY ./.databasedumps/test/$DB_NAME.sql /docker-entrypoint-initdb.d/
|
||||
|
||||
EXPOSE 5432
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=10 --start-interval=3s --start-period=15s \
|
||||
CMD pg_isready -U postgres -h 127.0.0.1
|
||||
@@ -1,243 +0,0 @@
|
||||
name: oodikone
|
||||
|
||||
x-defaults: &defaults
|
||||
restart: unless-stopped
|
||||
|
||||
x-healthcheck-defaults: &healthcheck-defaults
|
||||
interval: 30s # Normal healthcehck interval
|
||||
timeout: 5s
|
||||
retries: 10 # Retries until SIGKILl
|
||||
start_interval: 3s # How often healthcheck runs while starting
|
||||
start_period: 30s # How long startup period is
|
||||
|
||||
services:
|
||||
adminer:
|
||||
<<: *defaults
|
||||
container_name: oodikone-adminer
|
||||
environment:
|
||||
ADMINER_DESIGN: pepa-linha
|
||||
image: adminer:5.5.1
|
||||
ports:
|
||||
- 5050:8080
|
||||
profiles:
|
||||
- adminer
|
||||
volumes:
|
||||
- ./adminer-plugins:/var/www/html/plugins-enabled
|
||||
|
||||
backend:
|
||||
<<: *defaults
|
||||
container_name: oodikone-backend
|
||||
image: oodikone-backend:staging
|
||||
build:
|
||||
context: services
|
||||
dockerfile: backend/Dockerfile
|
||||
depends_on:
|
||||
kone-db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
sis-db:
|
||||
condition: service_healthy
|
||||
user-db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CI: 'true'
|
||||
DB_URL_USER: postgres://postgres@user-db:5432/user-db
|
||||
DB_URL_KONE: postgres://postgres@kone-db:5432/kone-db
|
||||
FRONT_URL: http://localhost:1337
|
||||
REDIS: redis
|
||||
SECRET_TOKEN: dev
|
||||
SIS_DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
SIS_UPDATER_URL: http://sis-updater-scheduler:8082
|
||||
IMPORTER_DB_API_URL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/importer'
|
||||
JAMI_URL: 'http://jami:3003/'
|
||||
CRYPT_KEY: 'e16730fce263d5270ba8dcf84d068a32556542455e71331880d6c1c5e4db9b4e'
|
||||
ports:
|
||||
- 8080:8080
|
||||
profiles:
|
||||
- oodikone
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/health'] # In CI/prod the routing happens at a different level, hence no /api/ needed
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
frontend:
|
||||
<<: *defaults
|
||||
container_name: oodikone-frontend
|
||||
image: oodikone-frontend:staging
|
||||
build:
|
||||
context: services
|
||||
dockerfile: frontend/Dockerfile
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 5000:5000
|
||||
profiles:
|
||||
- oodikone
|
||||
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-kone-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 4321:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
user-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-user-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 4320:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
redis:
|
||||
<<: *defaults
|
||||
container_name: oodikone-redis
|
||||
image: redis:7.2.6
|
||||
command: >
|
||||
sh -c 'redis-server --appendonly yes && redis-cli flushall'
|
||||
ports:
|
||||
- 6379:6379
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping', '|', 'grep', 'PONG']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
sis-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-sis-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
shm_size: 2gb
|
||||
ports:
|
||||
- 12345:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
sis-importer-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-sis-importer-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db
|
||||
profiles:
|
||||
- updater
|
||||
ports:
|
||||
- 12346:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
jami:
|
||||
<<: *defaults
|
||||
container_name: oodikone-jami
|
||||
image: quay.io/toska/jami:production
|
||||
depends_on:
|
||||
jami-db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- npm_config_cache=/tmp
|
||||
- PORT=3003
|
||||
- NODE_ENV=test # previously was development which borks CI
|
||||
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/postgres
|
||||
ports:
|
||||
- 3003:3003
|
||||
|
||||
jami-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-jami-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/jami-db
|
||||
environment:
|
||||
- PGDATA=/data
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
ports:
|
||||
- 5433:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
# This is still needed to make backend calls correctly route from localhost:5000/api to localhost:8080
|
||||
nginx:
|
||||
container_name: oodikone-nginx
|
||||
image: nginx:latest
|
||||
depends_on:
|
||||
frontend:
|
||||
condition: service_started
|
||||
backend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 1337:80
|
||||
profiles:
|
||||
- oodikone
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
|
||||
# Following services + sis-importer-db are used to run sis-updater-worker when anon sis-db needs to be updated. Could probably also use production version, but this certainly works.
|
||||
sis-updater-scheduler:
|
||||
restart: no
|
||||
container_name: oodikone-updater-scheduler
|
||||
build:
|
||||
context: updater/sis-updater-scheduler
|
||||
dockerfile: ./Dockerfile
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
sis-importer-db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CI: 'true'
|
||||
REDIS_HOST: redis
|
||||
SECRET_TOKEN: dev
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
SIS_IMPORTER_PASSWORD: postgres
|
||||
SIS_IMPORTER_USER: postgres
|
||||
SCHEDULE_IMMEDIATE: '${SCHEDULE_IMMEDIATE}'
|
||||
EXIT_AFTER_IMMEDIATES: '${EXIT_AFTER_IMMEDIATES}'
|
||||
ports:
|
||||
- 8082:8082
|
||||
profiles:
|
||||
- updater
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8082/health']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
sis-updater-worker:
|
||||
<<: *defaults
|
||||
# Disabled so that container can be scaled
|
||||
# container_name: oodikone-updater-worker
|
||||
build:
|
||||
context: updater/sis-updater-worker
|
||||
dockerfile: ./Dockerfile
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
sis-db:
|
||||
condition: service_healthy
|
||||
sis-importer-db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
CI: 'true'
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_HOST: redis
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
SIS_IMPORTER_PASSWORD: postgres
|
||||
SIS_IMPORTER_USER: postgres
|
||||
SIS_PASSWORD: postgres
|
||||
profiles:
|
||||
- updater
|
||||
@@ -1,75 +0,0 @@
|
||||
# This compose file is used update test dbs
|
||||
|
||||
x-defaults: &defaults
|
||||
restart: unless-stopped
|
||||
volumes: !reset [] # Remove previous volume definitions
|
||||
|
||||
x-healthcheck-defaults: &healthcheck-defaults
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
interval: 30s # Normal healthcehck interval
|
||||
timeout: 5s
|
||||
retries: 10 # Retries until SIGKILl
|
||||
start_interval: 3s # How often healthcheck runs while starting
|
||||
start_period: 15s # How long startup period is
|
||||
|
||||
services:
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-kone-db
|
||||
build:
|
||||
context: .
|
||||
dockerfile: db.Dockerfile
|
||||
args:
|
||||
DB_NAME: kone-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
healthcheck:
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
user-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-user-db
|
||||
build:
|
||||
context: .
|
||||
dockerfile: db.Dockerfile
|
||||
args:
|
||||
DB_NAME: user-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
healthcheck:
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
sis-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-sis-db
|
||||
build:
|
||||
context: .
|
||||
dockerfile: db.Dockerfile
|
||||
args:
|
||||
DB_NAME: sis-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
shm_size: 2gb
|
||||
healthcheck:
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
sis-importer-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-sis-importer-db
|
||||
build:
|
||||
context: .
|
||||
dockerfile: db.Dockerfile
|
||||
args:
|
||||
DB_NAME: sis-importer-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-importer-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
healthcheck:
|
||||
<<: *healthcheck-defaults
|
||||
@@ -1,25 +0,0 @@
|
||||
name: oodikone
|
||||
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
DB_URL_USER: postgres://postgres@user-db:5432/user-db-real
|
||||
DB_URL_KONE: postgres://postgres@kone-db:5432/kone-db-real
|
||||
SIS_DB_URL: postgres://postgres@sis-db:5432/sis-db-real
|
||||
|
||||
redis:
|
||||
volumes:
|
||||
- redis-data-real:/data
|
||||
|
||||
sis-updater-scheduler:
|
||||
environment:
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db-real
|
||||
|
||||
sis-updater-worker:
|
||||
environment:
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db-real
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db-real
|
||||
|
||||
volumes:
|
||||
redis-data-real:
|
||||
name: oodikone-redis-data-real
|
||||
@@ -1,29 +0,0 @@
|
||||
name: oodikone
|
||||
|
||||
x-defaults: &defaults
|
||||
restart: unless-stopped
|
||||
|
||||
x-healthcheck-defaults: &healthcheck-defaults
|
||||
interval: 30s # Normal healthcehck interval
|
||||
timeout: 5s
|
||||
retries: 10 # Retries until SIGKILl
|
||||
start_interval: 3s # How often healthcheck runs while starting
|
||||
start_period: 30s # How long startup period is
|
||||
|
||||
services:
|
||||
sis-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-sis-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db
|
||||
pull_policy: always
|
||||
volumes: !reset [] # Remove previous volume definitions
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
shm_size: 2gb
|
||||
ports:
|
||||
- 12345:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
@@ -1,274 +0,0 @@
|
||||
name: oodikone
|
||||
|
||||
x-defaults: &defaults
|
||||
restart: unless-stopped
|
||||
|
||||
x-build_defaults: &build_defaults
|
||||
dockerfile: dev.Dockerfile
|
||||
|
||||
x-user_defaults: &user_defaults
|
||||
# Run containers as user:group defined in .env
|
||||
# If run as $(id -u):$(id -g) will reduce permission errors when mounting
|
||||
# Defaults to registry.access.redhat.com/ubi10/nodejs-24-minimal's default user:group
|
||||
user: '${DOCKER_UID:-1001}:${DOCKER_GID:-0}'
|
||||
|
||||
x-healthcheck-defaults: &healthcheck-defaults
|
||||
interval: 30s # Normal healthcehck interval
|
||||
timeout: 5s
|
||||
retries: 10 # Retries until SIGKILl
|
||||
start_interval: 3s # How often healthcheck runs while starting
|
||||
start_period: 30s # How long startup period is
|
||||
|
||||
services:
|
||||
adminer:
|
||||
<<: *defaults
|
||||
container_name: oodikone-adminer
|
||||
environment:
|
||||
ADMINER_DESIGN: pepa-linha
|
||||
image: adminer:5.5.1
|
||||
ports:
|
||||
- 5050:8080
|
||||
volumes:
|
||||
- ./adminer-plugins:/var/www/html/plugins-enabled
|
||||
|
||||
backend:
|
||||
<<: [*defaults, *user_defaults]
|
||||
container_name: oodikone-backend
|
||||
build:
|
||||
dockerfile: backend/dev.Dockerfile
|
||||
context: services
|
||||
depends_on:
|
||||
kone-db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
sis-db:
|
||||
condition: service_healthy
|
||||
user-db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
tty: true
|
||||
environment:
|
||||
DB_URL_KONE: postgres://postgres@kone-db:5432/kone-db
|
||||
DB_URL_USER: postgres://postgres@user-db:5432/user-db
|
||||
FRONT_URL: http://localhost:3000
|
||||
REDIS: redis
|
||||
SECRET_TOKEN: dev
|
||||
SIS_DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
SIS_UPDATER_URL: http://sis-updater-scheduler:8082
|
||||
IMPORTER_DB_API_URL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/importer'
|
||||
JAMI_URL: 'http://jami:3003/'
|
||||
ports:
|
||||
- 8080:8080
|
||||
profiles:
|
||||
- oodikone
|
||||
volumes:
|
||||
- ./services/backend/:/opt/app-root/backend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
- shared-nodemod:/opt/app-root/shared/node_modules
|
||||
- backend-nodemod:/opt/app-root/backend/node_modules
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/api/health']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
frontend:
|
||||
<<: [*defaults, *user_defaults]
|
||||
container_name: oodikone-frontend
|
||||
build:
|
||||
dockerfile: frontend/dev.Dockerfile
|
||||
context: services
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 3000:3000
|
||||
profiles:
|
||||
- oodikone
|
||||
volumes:
|
||||
- ./services/frontend/:/opt/app-root/frontend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
- shared-nodemod:/opt/app-root/shared/node_modules
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-kone-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db
|
||||
profiles:
|
||||
- oodikone
|
||||
ports:
|
||||
- 4321:5432
|
||||
volumes:
|
||||
- kone-db-data:/data
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
user-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-user-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db
|
||||
profiles:
|
||||
- oodikone
|
||||
ports:
|
||||
- 4320:5432
|
||||
volumes:
|
||||
- user-db-data:/data
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
redis:
|
||||
<<: *defaults
|
||||
container_name: oodikone-redis
|
||||
command: 'redis-server --appendonly yes'
|
||||
image: redis:7.2.6
|
||||
ports:
|
||||
- 6379:6379
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping', '|', 'grep', 'PONG']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
sis-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-sis-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db
|
||||
volumes:
|
||||
- sis-db-data:/data
|
||||
shm_size: 2gb
|
||||
ports:
|
||||
- 12345:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
sis-importer-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-sis-importer-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-importer-db
|
||||
profiles:
|
||||
- updater
|
||||
volumes:
|
||||
- sis-importer-db-data:/data
|
||||
ports:
|
||||
- 12346:5432
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
jami:
|
||||
<<: *defaults
|
||||
container_name: oodikone-jami
|
||||
image: quay.io/toska/jami:production
|
||||
depends_on:
|
||||
jami-db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- npm_config_cache=/tmp
|
||||
- PORT=3003
|
||||
- NODE_ENV=development
|
||||
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
|
||||
ports:
|
||||
- 3003:3003
|
||||
|
||||
jami-db:
|
||||
<<: *defaults
|
||||
container_name: oodikone-jami-db
|
||||
image: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/jami-db
|
||||
environment:
|
||||
- PGDATA=/data
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
ports:
|
||||
- 5433:5432
|
||||
volumes:
|
||||
- jami-data:/data
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres', '-h', '127.0.0.1']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
sis-updater-worker:
|
||||
<<: [*defaults, *user_defaults]
|
||||
container_name: oodikone-updater-worker
|
||||
build:
|
||||
<<: *build_defaults
|
||||
context: updater/sis-updater-worker
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
sis-db:
|
||||
condition: service_healthy
|
||||
sis-importer-db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_HOST: redis
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
SIS_IMPORTER_PASSWORD: postgres
|
||||
SIS_IMPORTER_USER: postgres
|
||||
SIS_PASSWORD: postgres
|
||||
profiles:
|
||||
- updater
|
||||
volumes:
|
||||
- ./updater/sis-updater-worker/:/opt/app-root/src
|
||||
- sis-updater-worker-nodemod:/opt/app-root/src/node_modules
|
||||
|
||||
sis-updater-scheduler:
|
||||
<<: [*defaults, *user_defaults]
|
||||
container_name: oodikone-updater-scheduler
|
||||
build:
|
||||
<<: *build_defaults
|
||||
context: updater/sis-updater-scheduler
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
sis-importer-db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_HOST: redis
|
||||
SECRET_TOKEN: dev
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
SIS_IMPORTER_PASSWORD: postgres
|
||||
SIS_IMPORTER_USER: postgres
|
||||
SCHEDULE_IMMEDIATE: '${SCHEDULE_IMMEDIATE}'
|
||||
EXIT_AFTER_IMMEDIATES: '${EXIT_AFTER_IMMEDIATES}'
|
||||
ports:
|
||||
- 8082:8082
|
||||
profiles:
|
||||
- updater
|
||||
volumes:
|
||||
- ./updater/sis-updater-scheduler/:/opt/app-root/src
|
||||
- sis-updater-scheduler-nodemod:/opt/app-root/src/node_modules
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8082/health']
|
||||
<<: *healthcheck-defaults
|
||||
|
||||
volumes:
|
||||
backend-nodemod:
|
||||
name: oodikone-backend-nodemod
|
||||
frontend-nodemod:
|
||||
name: oodikone-frontend-nodemod
|
||||
shared-nodemod:
|
||||
name: oodikone-shared-nodemod
|
||||
kone-db-data:
|
||||
name: oodikone-kone-db-data
|
||||
redis-data:
|
||||
name: oodikone-redis-data
|
||||
sis-db-data:
|
||||
name: oodikone-sis-db-data
|
||||
sis-importer-db-data:
|
||||
name: oodikone-sis-importer-db-data
|
||||
sis-updater-scheduler-nodemod:
|
||||
name: oodikone-updater-scheduler-nodemod
|
||||
sis-updater-worker-nodemod:
|
||||
name: oodikone-updater-worker-nodemod
|
||||
user-db-data:
|
||||
name: oodikone-user-db-data
|
||||
jami-data:
|
||||
name: oodikone-jami-data
|
||||
@@ -1,3 +0,0 @@
|
||||
# Documentation
|
||||
|
||||
Oodikone public documentation. For internal documentation, see Toska GitLab.
|
||||
@@ -1,69 +0,0 @@
|
||||
# Access rights in Oodikone
|
||||
|
||||
## Roles
|
||||
|
||||
Roles are defined [here](../services/backend/src/config/roles.ts). Criteria for each role can be found [here](../services/backend/src/services/userService.ts). Currently these roles exist:
|
||||
|
||||
- admin
|
||||
- courseStatistics
|
||||
- facultyStatistics
|
||||
- fullSisuAccess
|
||||
- studyGuidanceGroups
|
||||
- teachers
|
||||
|
||||
The roles `facultyStatistics` and `studyGuidanceGroups` simply enable certain views (_Faculties_ and _Guidance groups_ respectively).
|
||||
|
||||
### courseStatistics
|
||||
|
||||
- Can see statistics of every course but year statistics of ≤ 5 students are hidden
|
||||
- If the user has [full rights](#programmes) to **any** programme, they can also see exact statistics even if there are ≤ 5 students (the same behavior as with `admin` or `fullSisuAccess` roles)
|
||||
|
||||
### teachers
|
||||
|
||||
- By default, the role only allows seeing the statistics of course providers (degree programmes) they have full rights to (see [Programmes](#programmes))
|
||||
- This role always needs to be given and removed manually
|
||||
- If the user is a member of the `hy-dekaanit` or `hy-varadekaanit-opetus` IAM group (or is an admin), they can see leaderboards, information about individual teachers, and statistics of all course providers. They don't need to be given the `teachers` role manually.
|
||||
|
||||
### Other roles
|
||||
|
||||
| | Everybody\* | Admin | fullSisuAccess |
|
||||
| -------------------- | :---------: | :---: | :------------: |
|
||||
| Front page | ✅ | ✅ | ✅ |
|
||||
| Feedback | ✅ | ✅ | ✅ |
|
||||
| University | ✅ | ✅ | ✅ |
|
||||
| Language center view | | ✅ | |
|
||||
| Faculties | | ✅ | ✅ |
|
||||
| Updater | | ✅ | |
|
||||
| Users | | ✅ | |
|
||||
| Programmes | | ✅ | ✅ |
|
||||
| Students | | ✅ | ✅ |
|
||||
| Teachers | | ✅ | |
|
||||
| Guidance groups | | | |
|
||||
| Custom population | | ✅ | ✅ |
|
||||
| Completed courses | ✅ | ✅ | ✅ |
|
||||
| Close to graduation | | ✅ | ✅ |
|
||||
| Courses | | ✅ | ✅ |
|
||||
|
||||
\* = anyone who can log in to Oodikone, check the [IAM groups](#iam-groups) section for more information
|
||||
|
||||
## Programmes
|
||||
|
||||
There are two types of programme rights: limited and full. Limited rights mean that the user cannot see individual students of those programmes, while a user with full rights can.
|
||||
|
||||
Manually given programme rights are **always** full rights. Some of the programme rights are based on user's IAM groups: some only qualify for limited rights, some for full rights.
|
||||
|
||||
## IAM groups
|
||||
|
||||
A user must be a member of either the `grp-oodikone-basic-users` or `grp-oodikone-users` IAM group to be able to log in to Oodikone. The following IAM groups are used to give roles:
|
||||
|
||||
| IAM group | Role given based on the group |
|
||||
| ------------------------ | ----------------------------- |
|
||||
| grp-oodikone-basic-users | courseStatistics |
|
||||
| grp-oodikone-users | facultyStatistics |
|
||||
| grp-toska | admin |
|
||||
|
||||
`grp-oodikone-basic-users`: basically the whole staff (hy-\*-allstaff)
|
||||
|
||||
`grp-oodikone-users`: more limited (e.g. _koulutussuunnittelijat_, _ospa_, _opintoasiainpäälliköt_, _(vara)dekaanit_, _johtoryhmä_)
|
||||
|
||||
`grp-kielikeskus-esihenkilot`: gives access to the language center view
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB |
@@ -1,96 +0,0 @@
|
||||
# Combined programmes
|
||||
|
||||
At the moment only programmes KH90_001 and MH90_001 have a combined view.
|
||||
|
||||
The made changes do not have any effects on faculty or trends views. **Needs manual work** is added into those parts that needs visit, if this functionality is added to other programmes.
|
||||
|
||||
Basic rule of thumb is that for the master/licentiate programme I used names `combinedProgramme` and in some rare cases `secondProgramme` as whole or part of the variable names.
|
||||
|
||||
## Degree programme
|
||||
|
||||
**Needs manual work** in the function `getCombinedProgrammeId` in `services/frontend/src/common/index.js` if new programmes is added. For showing the both programme data correctly, the filtering mechanism is changed to add both programmes and it is not caring for which programme user has acual rights or IAMrights. However, the links for the class stats level are hidden in studytrack tables, if the user has no rights for the programme.
|
||||
|
||||
- Search by class
|
||||
- **Needs manual work** Combine programmes here
|
||||
- Overview
|
||||
- **Needs manual work**
|
||||
|
||||
**Redis nightly degree programme update**
|
||||
|
||||
- Updates automatically combined programme stats. **Needs manual work** here `services/backend/src/services/studyprogrammeHelpers.js` in the constant `combinedStudyprogrammes`. This might needs refactoring if bachelor programme can have many separate master programmes. Then remember to change function `refreshNewOverview` in `services/backend/src/events.js` accordingly to handle lists.
|
||||
|
||||
**NOTE:** `combinedStudyprogrammes` is also used to fetch course modules (mandatory courses). If the logic of this constant is changed in `studyProgrammeHelpers.js` -> the needed changes must be done also here `services/backend/src/services/programmeModules.js`. While writing this, the cleverer idea of doing this might have been to send `code` in form `KHxx_xxx+MHxx_xxx` and do the splitting into two codes in the backend as done in Study guidance groups logic. Changes were done here 'services/backend/src/routes/population.js', acual place is near `getFilteredAssociations` function.
|
||||
|
||||
**Basic information**
|
||||
|
||||
All things listed below should work automatically, if new combinations are done.
|
||||
|
||||
- Credits produced by the studyprogramme
|
||||
- credits are computed for both programmes separately
|
||||
- Average graduation times
|
||||
- For bachelor + master/licentiate studyright the time is the whole study time starting from bachelor
|
||||
- _Students of the programme_ and _Graduated and thesis writers of the programme_
|
||||
- separate fields for started, graduated and thesis writers
|
||||
|
||||
**Studytracks and class statistics**
|
||||
|
||||
- _Students of the studyprogramme by starting year_
|
||||
- Graduated field separated for both programmes
|
||||
- All = currently enrolled + absent + passive + graduated licentiate
|
||||
- _Progress of students of the studyprogramme by starting year_
|
||||
- computed for whole bachelor + master/licentiate studyright period
|
||||
- _Average graduation times_
|
||||
- bachelor + master/licentiate graph: start is bachelor studyright start
|
||||
|
||||
**Programme courses**
|
||||
|
||||
- Fetch all courses for both programmes and show them in one table.
|
||||
|
||||
**Degree courses**
|
||||
|
||||
- Possibility to add criteria for whole bachelor-master study time
|
||||
- For eläinlääkis this criteria is saved by bachelor programme code. Other cases may need other approaches.
|
||||
In eläinlääkis this done by adding the criteria for 6 years -> the whole study time criteria is saved with the bachelor programme code. That is, same criteria is found, if bachelor or combined programme view is shown. This approach might need extra thinking for different programmes.
|
||||
- Show own tables for both programmes
|
||||
- To fetch data **needs manual work** here `services/backend/src/services/studyprogrammeHelpers.js` in the constant `combinedStudyprogrammes`.
|
||||
|
||||
**Tags**
|
||||
|
||||
- Possibility to add tags for combined programme.
|
||||
|
||||
## Class statistics view
|
||||
|
||||
**Credit accumulation**
|
||||
|
||||
- Show graduations for both programmes
|
||||
|
||||
**Credit statistics**
|
||||
|
||||
- Bachelor and bachelor+master combo have own graphs.
|
||||
|
||||
**Courses of class**
|
||||
|
||||
- Show courses for both programmes
|
||||
|
||||
**Students**
|
||||
|
||||
- **General tab** - Additional fields for graduation and hops
|
||||
- **Courses tab** - show all courses for both programmes. The courses are fetched with same logic as for Degree programmes.
|
||||
- **Tags tab** - show tabs related to this combination.
|
||||
- **Progress tab** - possibility to have progress for whole bachelor-master study time. **See Degree courses approach** to add criteria. **May need future manual work**, depending on the programme id with which the data is saved on the database.
|
||||
|
||||
## Filters
|
||||
|
||||
- **Hops (study plan)** - possibility filter out credits separate for both programmes or for both at the same time
|
||||
|
||||
- **Graduated from programme** - possibility to filter out students based on graduation either in bachelor's or master's.
|
||||
|
||||
- **Studyright status** - active or passive based on the chosen degree
|
||||
|
||||
- **Studytrack filter** (needs programme code) is not touched at the moment, thus **may need future manual work**.
|
||||
|
||||
## Study guidance groups
|
||||
|
||||
- Backend: student data is fetched by bachelor degree programme code in `/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
|
||||
- Frontend: things are done in the same way as with class stats.
|
||||
- **Need manual work**: `./oodikone/services/frontend/src/redux/elementdetails.js` add combined programme codes (both bachelor and master) to `combinedProgrammeCodes` and modify `combinedOptions` list accordingly in `useFilterAndFormattedElementDetails()` function.
|
||||
@@ -1,78 +0,0 @@
|
||||
# Databases
|
||||
|
||||
Oodikone uses three main PostgreSQL databases, each for a different purpose. This document contains a simplified overview of each database and the relations between models.
|
||||
|
||||
Use Adminer or refer to the actual database for a detailed view of each model and their attributes.
|
||||
|
||||
## Storage
|
||||
|
||||
### sis-db
|
||||
|
||||
The `sis-db` database contains information originating from Sisu.
|
||||
|
||||
```mermaid
|
||||
---
|
||||
title: sis-db
|
||||
---
|
||||
erDiagram
|
||||
enrollment }o--|| course : ""
|
||||
enrollment }o--|| semesters : ""
|
||||
enrollment }o--|| student : ""
|
||||
credit_teachers }o--|| teacher : ""
|
||||
course_types
|
||||
curriculum_periods
|
||||
credit_teachers }o--|| credit : ""
|
||||
credit }o--|| course : ""
|
||||
credit }o--|| semesters : ""
|
||||
credit }o--|| credit_types : ""
|
||||
credit }o--|| student : ""
|
||||
studyplan }o--|| student : ""
|
||||
studyplan }o--|| sis_study_rights : ""
|
||||
sis_study_rights }o--|| studyright_extents : ""
|
||||
sis_study_rights }o--|| organization : ""
|
||||
sis_study_rights }o--|| student : ""
|
||||
sis_study_right_elements }o--|| sis_study_rights : ""
|
||||
course_providers }o--|| organization : ""
|
||||
course_providers }o--|| course : ""
|
||||
programme_module_children }o--|| programme_modules : "references parent"
|
||||
programme_module_children }o--|| programme_modules : "references child"
|
||||
programme_modules }o--o| organization : "belong to"
|
||||
```
|
||||
|
||||
### kone-db
|
||||
|
||||
The `kone-db` database contains information used in various features of Oodikone. This functionality is native to Oodikone and not necessarily directly related to Sisu.
|
||||
|
||||
```mermaid
|
||||
---
|
||||
title: kone-db
|
||||
---
|
||||
erDiagram
|
||||
custom_population_searches
|
||||
excluded_courses
|
||||
open_uni_population_searches
|
||||
progress_criteria
|
||||
study_guidance_group_tags
|
||||
degree_programme_pins
|
||||
tag ||--o{ tag_student : "belongs to"
|
||||
tag
|
||||
tag_student
|
||||
```
|
||||
|
||||
### user-db
|
||||
|
||||
The `user-db` database contains information about users of Oodikone.
|
||||
|
||||
```mermaid
|
||||
---
|
||||
title: user-db
|
||||
---
|
||||
erDiagram
|
||||
users
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
### Redis
|
||||
|
||||
Redis is used in the backend to cache calculation results.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 106 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 72 KiB |
@@ -1,127 +0,0 @@
|
||||
# How to update the database schema
|
||||
|
||||
These instructions explain how to update the schemas for the following databases used by Oodikone:
|
||||
|
||||
- `sis-db`
|
||||
- `user-db`
|
||||
- `kone-db`
|
||||
|
||||
The steps to **add, remove, or modify columns** are mostly the same for each database, but the file paths and container names differ. Always **double-check** that you have the correct function name, column name, data type, and other relevant details in your migration file.
|
||||
|
||||
> [!NOTE]
|
||||
> If you need to change the `sis-importer-db` schema, see the instructions in the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips).
|
||||
|
||||
## General guidelines
|
||||
|
||||
### File names
|
||||
|
||||
- Name new migration files using the format `YYYYMMDD_XX_description.js`.
|
||||
- The `XX` part is a migration number (starting from `00` each day).
|
||||
- Example: `20250122_00_add_column_to_table.js`.
|
||||
|
||||
### Model vs. column names
|
||||
|
||||
- Use **camelCase** in your model definitions.
|
||||
- Use **snake_case** in the actual database columns.
|
||||
- If a model has `underscored: true`, Sequelize automatically converts camelCase to snake_case in all queries. Otherwise, specify the column name explicitly.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
|
||||
|
||||
### Verifying the migration locally
|
||||
|
||||
- Migrations are run automatically whenever the server restarts.
|
||||
- After creating a migration, ensure the relevant service is running:
|
||||
- For `sis-db`: `npm run both:real` or `npm run updater:real`
|
||||
- For `user-db`/`kone-db`: `npm run both:real` or `npm run oodikone:real`
|
||||
- **Connect to the database container** and check:
|
||||
|
||||
```bash
|
||||
docker exec -it <container-name> psql -U postgres <database-name>-real
|
||||
```
|
||||
|
||||
Replace `<container-name>` and `<database-name>` with the actual values (e.g., `sis-db`, `user-db`, or `kone-db`).
|
||||
|
||||
- In the psql prompt, verify your migration:
|
||||
|
||||
```sql
|
||||
SELECT * FROM migrations;
|
||||
```
|
||||
|
||||
Also confirm that the new (or updated) column exists in the appropriate table and has the correct type.
|
||||
|
||||
### Reverting a migration locally
|
||||
|
||||
If you need to **revert a migration locally**, follow these steps:
|
||||
|
||||
1. Locate the `migrator.up()` function in the code:
|
||||
- For `user-db` and `kone-db`: [`services/backend/src/database/connection.ts`](../services/backend/src/database/connection.ts)
|
||||
- For `sis-db`: [`updater/sis-updater-worker/src/db/connection.js`](../updater/sis-updater-worker/src/db/connection.js)
|
||||
1. Change `migrator.up()` to `migrator.down()` and save the file. The latest migration will be reverted automatically when the server reloads.
|
||||
1. **Important:** After the migration is reverted, change `migrator.down()` back to `migrator.up()` to ensure future migrations run correctly.
|
||||
1. For more instructions, see the [umzug documentation](https://github.com/sequelize/umzug?tab=readme-ov-file#usage).
|
||||
|
||||
### Deploying changes to production
|
||||
|
||||
- **Push changes** to the `master` branch and wait for the workflow to finish.
|
||||
- For **`sis-db`** migrations (in the updater):
|
||||
- You **do not** need to create a new release. Once tests pass, changes to the updater go directly to production.
|
||||
- For **`user-db`** and **`kone-db`** migrations (in the backend):
|
||||
- A new release must be made before the changes go to production.
|
||||
- After deployment, check logs (for example, in OpenShift) to confirm the migration ran successfully.
|
||||
- Optionally, use the `db-cli` tool to inspect the production database. See `how_to_db_cli.md` (in GitLab) for instructions.
|
||||
|
||||
## `sis-db`
|
||||
|
||||
> [!NOTE]
|
||||
> These instructions assume that needed data is already in the `sis-importer-db`. If not, you must add the data there first. See the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips) for more details.
|
||||
|
||||
1. **Update the updater model:**
|
||||
|
||||
- Go to `updater/sis-updater-worker/src/db/models/` and choose the model you want to change.
|
||||
- Add or modify the column (with its **type** and any other necessary fields).
|
||||
- Check the correct data type in the importer.
|
||||
- Use camelCase in the model, snake_case in the database.
|
||||
|
||||
1. **Update the backend model:**
|
||||
|
||||
- Go to `services/backend/src/models/`.
|
||||
- The models are in different formats (JavaScript in the updater, TypeScript in the backend), but follow existing examples.
|
||||
|
||||
1. **Create a migration file:**
|
||||
|
||||
- Go to `updater/sis-updater-worker/src/db/migrations/`.
|
||||
- Name your file using the format described above (e.g. `20250122_00_add_column_to_table.js`).
|
||||
- **Remember**: The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
|
||||
|
||||
1. **Run and verify the migration:**
|
||||
|
||||
- Ensure that the updater is running (`npm run both:real` or `npm run updater:real`).
|
||||
- Use the steps in [General guidelines](#general-guidelines) to verify.
|
||||
|
||||
1. **Push to master and deploy:**
|
||||
- Your migration will go to production automatically after tests pass.
|
||||
- Check logs in production (OpenShift) or use `db-cli` to confirm success.
|
||||
|
||||
## `user-db` and `kone-db`
|
||||
|
||||
1. **Update the model:**
|
||||
|
||||
- Go to `services/backend/src/models/kone` (for `kone-db`) or `services/backend/src/models/user.ts` (the only model for user-db)
|
||||
- Add or modify the column, type, and any other needed fields. Follow existing models for structure.
|
||||
|
||||
1. **Create a migration file:**
|
||||
|
||||
- Go to `services/backend/src/database/`.
|
||||
- Use `migrations_kone` (for `kone-db`) or `migrations_user` (for `user-db`).
|
||||
- Name the file using the same `YYYYMMDD_XX_description.js` format.
|
||||
- **Remember**: The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
|
||||
|
||||
1. **Run and verify the migration:**
|
||||
|
||||
- Make sure Oodikone is running (`npm run both:real` or `npm run oodikone:real`).
|
||||
- Follow the same verification steps as in [General Guidelines](#general-guidelines).
|
||||
|
||||
1. **Push to master and release:**
|
||||
- Wait for the CI workflow to pass.
|
||||
- **Create a new release**; then check the production logs (in OpenShift) to confirm the migrationor use `db-cli`.
|
||||
@@ -1,84 +0,0 @@
|
||||
# Numeric types
|
||||
|
||||
Data used in Oodikone contains some numeric types (or _magic numbers_) that are not self-explanatory. The values of the numeric types are gathered here for convenience and quick reference.
|
||||
|
||||
## Extent code
|
||||
|
||||
Appears in model SISStudyRight (db: `sis_study_rights`) as _extentCode_ (db: `extent_code`). Values are defined in the database table `studyright_extents`.
|
||||
|
||||
| value | meaning |
|
||||
| ----: | :-------------------------------------------------- |
|
||||
| 1 | Bachelor's degree |
|
||||
| 2 | Master's degree |
|
||||
| 3 | Licentiate |
|
||||
| 4 | Doctor |
|
||||
| 5 | Bachelor's and Master's degree |
|
||||
| 6 | Continuing education |
|
||||
| 7 | Exchange studies |
|
||||
| 9 | Open University studies |
|
||||
| 13 | Non-degree pedagogical studies for teachers |
|
||||
| 14 | Contract training |
|
||||
| 16 | Studies for secondary school students |
|
||||
| 18 | Specialisation studies |
|
||||
| 22 | Non-degree programme for special education teachers |
|
||||
| 23 | Specialist training in medicine and dentistry |
|
||||
| 31 | Summer and winter school |
|
||||
| 34 | Exchange studies (postgraduate studies) |
|
||||
| 99 | Non-Degree studies |
|
||||
|
||||
## Phase
|
||||
|
||||
Appears in model SISStudyRightElement (db: `sis_study_right_elements`) as _phase_ (db: `phase`).
|
||||
|
||||
In Finland it is common for new students to be awarded the study right to both a bachelor's and a master's programme when they are admitted to the university. In Oodikone these two-phase study rights are marked with the [extent code](#extent-code) 5. In the study right elements of these study rights, phase 2 marks the second phase of the study right.
|
||||
|
||||
For example: Student is awarded the study right in the bachelor's programme in computer science with the extent code 5. The study right element for study right in the bachelor's programme is marked with phase 1. After graduating from the bachelor's programme, the student continues their studies in the master's programme in computer science. The new study right element for the study right in the master's programme is marked with phase 2.
|
||||
|
||||
All study right elements for other study rights (extent code other than 5) are marked with phase 1.
|
||||
|
||||
The level of the programme (bachelor's, master's etc.) of the study right can be directly checked from degreeProgrammeType (db: `degree_programme_type`) in model SISStudyRightElement (db: `sis_study_right_elements`).
|
||||
|
||||
| value | meaning |
|
||||
| ----: | :------- |
|
||||
| 1 | any |
|
||||
| 2 | master's |
|
||||
|
||||
## Enrollment type
|
||||
|
||||
This information is used in the model SISStudyRight (db: `sis_study_rights`), in the field _semesterEnrollments_ (db: `semester_enrollments`).
|
||||
|
||||
The `semester_enrollments` column is an array of JSON objects. Each object contains:
|
||||
|
||||
- `type` (the type of enrollment),
|
||||
- `semester` (the code for a semester),
|
||||
- and sometimes `statutoryAbsence` (this field is included only when the enrollment type is 2).
|
||||
|
||||
The `type` field represents one of the following enrollment types. The `semester` field refers to a semester code, defined as `semestercode` in the model Semester.
|
||||
|
||||
| value | meaning | notes |
|
||||
| ----: | :------ | :------------------------------- |
|
||||
| 1 | Present | |
|
||||
| 2 | Absent | |
|
||||
| 3 | Passive | neither 1 or 2 has been declared |
|
||||
|
||||
## Credit type code
|
||||
|
||||
Appears in model Credit (db: `credit`) as _credittypecode_ (db: `credittypecode`). Values are defined in the database table `credit_types`.
|
||||
|
||||
| value | meaning |
|
||||
| ----: | :--------------- |
|
||||
| 4 | Completed |
|
||||
| 7 | Improved (grade) |
|
||||
| 9 | Transferred |
|
||||
| 10 | Failed |
|
||||
|
||||
## Gender code
|
||||
|
||||
Appears in model Student (db: `student`) as _gender_code_ (db: `gender_code`).
|
||||
|
||||
| value | meaning |
|
||||
| ----: | :------ |
|
||||
| 0 | Unknown |
|
||||
| 1 | Male |
|
||||
| 2 | Female |
|
||||
| 3 | Other |
|
||||
@@ -1,50 +0,0 @@
|
||||
# Resources
|
||||
|
||||
A collection of useful tools, documentation and other resources.
|
||||
|
||||
## Code style and maintainability
|
||||
|
||||
- [ESLint documentation](https://eslint.org/docs/v8.x)
|
||||
- [qlty.sh](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone)
|
||||
- Qlty.sh replaced Code climate which was introduced by Riku
|
||||
- The usefulness of qlty.sh is sometimes questionable
|
||||
|
||||
## Database
|
||||
|
||||
- [pgcli](https://www.pgcli.com)
|
||||
- A useful commandline tool to interact with the database
|
||||
- Can be installed from some package managers or optionally via `$ pip install pgcli`
|
||||
- [Postgres documentation](https://www.postgresql.org/docs/15/index.html)
|
||||
- [Sequelize documentation](https://sequelize.org/docs/v6/)
|
||||
|
||||
## GitHub
|
||||
|
||||
- [GitHub documentation](https://docs.github.com/en)
|
||||
- [GitHub Actions](https://docs.github.com/en/actions)
|
||||
- [Mermaid.js documentation](https://mermaid.js.org/intro/syntax-reference.html)
|
||||
|
||||
## TypeScript
|
||||
|
||||
- [TypeScript documentation](https://www.typescriptlang.org/docs)
|
||||
- [`tsconfig` reference](https://www.typescriptlang.org/tsconfig)
|
||||
- [TypeScript instructions for Sequelize](https://sequelize.org/docs/v6/other-topics/typescript)
|
||||
- [`sequelize-typescript` instructions](https://github.com/sequelize/sequelize-typescript?tab=readme-ov-file#readme)
|
||||
|
||||
## User interface
|
||||
|
||||
- [Material UI documentation](https://mui.com/material-ui/getting-started/)
|
||||
- [Material Icons](https://mui.com/material-ui/material-icons/)
|
||||
- [MUI X Date pickers](https://mui.com/x/react-date-pickers/)
|
||||
- [Material React Table documentation](https://www.material-react-table.com/docs)
|
||||
|
||||
<!--
|
||||
|
||||
## Visual Studio Code
|
||||
|
||||
_Todo: add relevant plugins_
|
||||
|
||||
-->
|
||||
|
||||
## Web technologies
|
||||
|
||||
- [MDN Web Docs](https://developer.mozilla.org/en-US/)
|
||||
@@ -1,3 +0,0 @@
|
||||
# Tietosuoja
|
||||
|
||||
Oodikoneessa noudatetaan Helsingin yliopiston [yleistä linjausta](https://www.helsinki.fi/fi/tutustu-meihin/tietosuoja#opiskelijoiden-henkil-tietojen-k-sittely--title) opiskelijoiden henkilötietojen käsittelystä.
|
||||
@@ -1,175 +0,0 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const studentSet1 = ['433237', '457144', '458090', '474270', '479440']
|
||||
const coursesSet1 = ['TKT10001', 'TKT10002', 'TKT10003', 'TKT10004']
|
||||
|
||||
const hasLanded = async (page: Page) => {
|
||||
await expect(page.getByRole('button', { name: 'Search completed courses of students' })).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Here you can search by a list of student and course numbers to see whether students have completed certain courses yet'
|
||||
)
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
const openSearch = async (page: Page) => {
|
||||
await init(page, '/completedcoursessearch')
|
||||
await hasLanded(page)
|
||||
}
|
||||
|
||||
const openCompletedCoursesModal = async (page: Page) => {
|
||||
await page.getByRole('button', { name: 'Search completed courses of students' }).click()
|
||||
}
|
||||
|
||||
const selectSavedCourselist = async (page: Page, name: string) => {
|
||||
const searchInput = page.getByTestId('history-search').getByRole('combobox')
|
||||
await searchInput.fill(name)
|
||||
await page.getByRole('option', { name }).click()
|
||||
}
|
||||
|
||||
const deleteAllPreviousSearches = async (page: Page) => {
|
||||
const historySearch = page.getByTestId('history-search').getByRole('combobox')
|
||||
|
||||
while (await historySearch.isVisible()) {
|
||||
await historySearch.click()
|
||||
const options = page.getByRole('option')
|
||||
if ((await options.count()) === 0) break
|
||||
await options.first().click()
|
||||
await page.getByRole('button', { name: 'Delete' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeDisabled()
|
||||
}
|
||||
await expect(page.getByText('You have no previous searches.')).toBeVisible()
|
||||
}
|
||||
|
||||
const createCourseList = async (page: Page, courseCodes: string[], courseListName: string) => {
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await page.getByTestId('course-list-input').getByRole('textbox').fill(courseCodes.join('\n'))
|
||||
await page.getByTestId('search-name').getByRole('textbox').fill(courseListName)
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
}
|
||||
|
||||
const generateCourseListName = () => `TEST-course-list-${Date.now()}`
|
||||
|
||||
test.describe('When search modal is opened', () => {
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
if (await page.getByTestId('history-search').getByRole('combobox').isVisible()) {
|
||||
await deleteAllPreviousSearches(page)
|
||||
}
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('Modal opens correctly', async ({ page }) => {
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await expect(page.getByRole('dialog')).toContainText('Search completed courses of students')
|
||||
await expect(
|
||||
page.getByText('Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Insert one or more courses, separated by a space, a newline, a comma, or a semicolon.')
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Insert name for this course list if you wish to save it')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Modal gets the correct course codes and student numbers from the URL', async ({ page }) => {
|
||||
await init(
|
||||
page,
|
||||
'/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&studentList=433237&studentList=457144'
|
||||
)
|
||||
await hasLanded(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await expect(page.getByTestId('student-no-input').getByRole('textbox')).toHaveValue('433237, 457144')
|
||||
await expect(page.getByTestId('course-list-input').getByRole('textbox')).toHaveValue('TKT10001, TKT10002')
|
||||
})
|
||||
|
||||
test('Returns empty table and notification for invalid input', async ({ page }) => {
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await page.getByTestId('student-no-input').getByRole('textbox').fill('1')
|
||||
await page.getByTestId('course-list-input').getByRole('textbox').fill('1')
|
||||
await page.getByRole('button', { name: 'Search' }).click()
|
||||
await expect(page.getByTestId('rights-notification')).toContainText(
|
||||
'The information for the following students could not be displayed'
|
||||
)
|
||||
await expect(page.getByTestId('rights-notification')).toContainText('1')
|
||||
const table = page.getByTestId('completed-courses-table-div')
|
||||
await expect(table).toContainText('Student number')
|
||||
await expect(table.getByText('1', { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test.describe('When a search with correct data is executed', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await page.getByTestId('student-no-input').getByRole('textbox').fill(studentSet1.join('\n'))
|
||||
await page.getByTestId('course-list-input').getByRole('textbox').fill(coursesSet1.join('\n'))
|
||||
await page.getByRole('button', { name: 'Search' }).click()
|
||||
})
|
||||
|
||||
test('Pushes the query to url', async ({ page }) => {
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(
|
||||
`/completedcoursessearch\\?courseList=${coursesSet1.join('&courseList=')}&studentList=${studentSet1.join('&studentList=')}`
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
test('Finds correct students and courses', async ({ page }) => {
|
||||
const table = page.getByTestId('completed-courses-table-div')
|
||||
for (const studentNumber of studentSet1) await expect(table).toContainText(studentNumber)
|
||||
for (const courseCode of coursesSet1) await expect(table).toContainText(courseCode)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Courselist saving-related functions work', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
if (await page.getByTestId('history-search').getByRole('combobox').isVisible()) {
|
||||
await deleteAllPreviousSearches(page)
|
||||
} else {
|
||||
await expect(page.getByText('You have no previous searches.')).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('Course list can be saved', async ({ page }) => {
|
||||
const courseList = generateCourseListName()
|
||||
const courses = ['CSM14204', 'TKT10004']
|
||||
await createCourseList(page, courses, courseList)
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await selectSavedCourselist(page, courseList)
|
||||
await expect(page.getByTestId('course-list-input').getByRole('textbox')).toHaveValue(courses.join(', '))
|
||||
})
|
||||
|
||||
test('Course list can be deleted', async ({ page }) => {
|
||||
const courseList = generateCourseListName()
|
||||
await createCourseList(page, ['CSM14204', 'TKT10004'], courseList)
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await selectSavedCourselist(page, courseList)
|
||||
await page.getByRole('button', { name: 'Delete' }).click()
|
||||
await expect(page.getByText('You have no previous searches.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Course list can be updated', async ({ page }) => {
|
||||
const courseList = generateCourseListName()
|
||||
await createCourseList(page, ['CSM14204', 'TKT10004'], courseList)
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await selectSavedCourselist(page, courseList)
|
||||
await page.getByTestId('course-list-input').getByRole('textbox').pressSequentially(',TKT10001')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await openSearch(page)
|
||||
await openCompletedCoursesModal(page)
|
||||
await selectSavedCourselist(page, courseList)
|
||||
await expect(page.getByTestId('course-list-input').getByRole('textbox')).toHaveValue('CSM14204, TKT10004, TKT10001')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,197 +0,0 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const students1 = ['433237', '457144', '458090', '465136']
|
||||
const students2 = ['474270', '479440', '470391', '474789']
|
||||
const nonExistentStudentNumbers = ['123', 'X', '-']
|
||||
|
||||
const fillForm = async (page: Page, studentNumbers: string[], separator: string) => {
|
||||
// NOTE: This helper uses pressSequentially instead of the normal fill so that it can append to a textfield
|
||||
await page.getByTestId('student-number-input').getByRole('textbox').pressSequentially(studentNumbers.join(separator))
|
||||
}
|
||||
|
||||
const search = async (page: Page) => {
|
||||
await page.getByTestId('search-button').click()
|
||||
}
|
||||
|
||||
const searchFor = async (page: Page, studentNumbers: string[], separator: string) => {
|
||||
await fillForm(page, studentNumbers, separator)
|
||||
await search(page)
|
||||
}
|
||||
|
||||
const hasLanded = async (page: Page) => {
|
||||
await expect(page.getByText('Credit accumulation')).toBeVisible()
|
||||
await expect(page.getByText('Programme distribution')).toBeVisible()
|
||||
await expect(page.getByText('Courses of population')).toBeVisible()
|
||||
await expect(page.getByText('Students (4)')).toBeVisible()
|
||||
}
|
||||
|
||||
const containsAmountOfStudents = async (page: Page, amount = 0) => {
|
||||
await expect(page.getByText(`Credit accumulation (for ${amount} students)`)).toBeVisible()
|
||||
await expect(page.getByText(`Students (${amount})`)).toBeVisible()
|
||||
}
|
||||
|
||||
const containsSpecificStudents = async (page: Page, studentNumbers: string[]) => {
|
||||
const studentsTab = page.getByText(`Students (${studentNumbers.length})`)
|
||||
await studentsTab.click()
|
||||
await Promise.all(studentNumbers.map(async number => await expect(page.getByText(number)).toBeVisible()))
|
||||
}
|
||||
|
||||
const checkRightsNotification = async (page: Page, studentNumbers: string[]) => {
|
||||
const notification = page.getByTestId('rights-notification')
|
||||
await expect(notification).toContainText(
|
||||
'The following students information could not be displayed. This could be either because they do not exist, or you do not have the right to view their information.'
|
||||
)
|
||||
await Promise.all(studentNumbers.map(number => expect(notification.locator('li').getByText(number)).toBeVisible()))
|
||||
}
|
||||
|
||||
const fillName = async (page: Page) => {
|
||||
const name = `TEST-${Date.now()}`
|
||||
await expect(page.getByText('Insert a name for this custom population if you wish to save it')).toBeVisible()
|
||||
await page.getByTestId('custom-population-name-input').getByRole('textbox').fill(name)
|
||||
return name
|
||||
}
|
||||
|
||||
const selectSavedPopulation = async (page: Page, name: string) => {
|
||||
const searchInput = page.getByTestId('history-search').getByRole('combobox')
|
||||
await searchInput.fill(name)
|
||||
await searchInput.press('Enter')
|
||||
}
|
||||
|
||||
const save = async (page: Page, name: string) => {
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
// Wait for saved custom pop to be visible
|
||||
await page.getByTestId('history-search').click()
|
||||
await expect(page.getByText(name)).toBeVisible()
|
||||
}
|
||||
|
||||
const deleteAllSearches = async (page: Page) => {
|
||||
if (await page.getByTestId('history-search').isVisible()) {
|
||||
const searchInput = page.getByTestId('history-search')
|
||||
await searchInput.click()
|
||||
const options = page.getByRole('cell')
|
||||
|
||||
while (await options.count()) {
|
||||
await options.first().click()
|
||||
await page.getByRole('button', { name: 'Delete' }).click()
|
||||
await searchInput.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Custom population tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/custompopulation')
|
||||
await expect(page).toHaveURL(/\/custompopulation/)
|
||||
await expect(page.getByRole('heading', { name: 'Custom population', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
await init(page, '/custompopulation')
|
||||
await deleteAllSearches(page)
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test.describe('Custom population searching', () => {
|
||||
test('Finds a proper population', async ({ page }) => {
|
||||
await searchFor(page, students1, '\n')
|
||||
await hasLanded(page)
|
||||
await containsAmountOfStudents(page, students1.length)
|
||||
await containsSpecificStudents(page, students1)
|
||||
})
|
||||
|
||||
test("Doesn't return non-existing students", async ({ page }) => {
|
||||
await searchFor(page, [...students1, ...nonExistentStudentNumbers], ' ')
|
||||
await hasLanded(page)
|
||||
await containsAmountOfStudents(page, students1.length)
|
||||
await containsSpecificStudents(page, students1)
|
||||
await checkRightsNotification(page, nonExistentStudentNumbers)
|
||||
})
|
||||
|
||||
test("Doesn't find empty custom population", async ({ page }) => {
|
||||
await searchFor(page, nonExistentStudentNumbers, ',')
|
||||
await checkRightsNotification(page, nonExistentStudentNumbers)
|
||||
await expect(page.getByText('Credit accumulation')).not.toBeVisible()
|
||||
await expect(page.getByText('Programme distribution')).not.toBeVisible()
|
||||
await expect(page.getByText('Courses of population')).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back to search form' })).toBeVisible()
|
||||
})
|
||||
|
||||
test("Doesn't return students user has no right to", async ({ page }) => {
|
||||
const studentsForEduBachStudents = ['014990067', '013069465', '014853890']
|
||||
await searchFor(page, studentsForEduBachStudents, ';;')
|
||||
await checkRightsNotification(page, studentsForEduBachStudents)
|
||||
await expect(page.getByText('Credit accumulation')).not.toBeVisible()
|
||||
await expect(page.getByText('Programme distribution')).not.toBeVisible()
|
||||
await expect(page.getByText('Courses of population')).not.toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back to search form' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Custom population by programme', () => {
|
||||
test('Finds students of programmes matching a prefix', async ({ page }) => {
|
||||
await page.getByTestId('search-mode').click()
|
||||
await expect(page.getByTestId('search-button')).toBeDisabled()
|
||||
|
||||
await page.getByTestId('custom-population-programme-prefix-input').getByRole('textbox').fill('KH50')
|
||||
await page.getByTestId('custom-population-add-by-prefix-button').click()
|
||||
await expect(page.getByText('KH50_001')).toBeVisible()
|
||||
|
||||
await page.getByTestId('population-year-selector').click()
|
||||
await page.getByText('2020 - 2021').click()
|
||||
|
||||
await page.getByTestId('search-button').click()
|
||||
await expect(page.getByText('Credit accumulation')).toBeVisible()
|
||||
await expect(page.getByText('Courses of population')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back to search form' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Custom population search saving', () => {
|
||||
test('Saves a custom population search', async ({ page }) => {
|
||||
const name = await fillName(page)
|
||||
await fillForm(page, students1, ',\n')
|
||||
await save(page, name)
|
||||
|
||||
await selectSavedPopulation(page, name)
|
||||
await search(page)
|
||||
await expect(page.getByText(`Custom population: ${name}`)).toBeVisible()
|
||||
await containsAmountOfStudents(page, students1.length)
|
||||
await containsSpecificStudents(page, students1)
|
||||
|
||||
await page.goto('/custompopulation')
|
||||
await expect(page.getByRole('heading', { name: 'Custom population', exact: true })).toBeVisible()
|
||||
await selectSavedPopulation(page, name)
|
||||
await search(page)
|
||||
await expect(page.getByText(`Custom population: ${name}`)).toBeVisible()
|
||||
await containsAmountOfStudents(page, students1.length)
|
||||
await containsSpecificStudents(page, students1)
|
||||
})
|
||||
|
||||
test('Updates a custom population search', async ({ page }) => {
|
||||
const name = await fillName(page)
|
||||
await fillForm(page, students1, ' ')
|
||||
await save(page, name)
|
||||
|
||||
await selectSavedPopulation(page, name)
|
||||
await search(page)
|
||||
await expect(page.getByText(`Custom population: ${name}`)).toBeVisible()
|
||||
await containsAmountOfStudents(page, students1.length)
|
||||
await containsSpecificStudents(page, students1)
|
||||
await page.getByRole('button', { name: 'Back to search form' }).click()
|
||||
await selectSavedPopulation(page, name)
|
||||
await fillForm(page, ['\n', ...students2], ', ')
|
||||
await save(page, name)
|
||||
|
||||
await page.goto('/custompopulation')
|
||||
await expect(page.getByRole('heading', { name: 'Custom population', exact: true })).toBeVisible()
|
||||
await selectSavedPopulation(page, name)
|
||||
await search(page)
|
||||
await expect(page.getByText(`Custom population: ${name}`)).toBeVisible()
|
||||
await containsAmountOfStudents(page, students1.length + students2.length)
|
||||
await containsSpecificStudents(page, [...students1, ...students2])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,872 +0,0 @@
|
||||
import { test, expect, Page } from '@playwright/test'
|
||||
import { checkTableStats, init, testMockTime } from './support/commands'
|
||||
|
||||
const getEmptyYears = (isAcademicYear = false) => {
|
||||
const today = new Date(testMockTime)
|
||||
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
|
||||
const years = []
|
||||
for (let year = latestYear; year >= 2024; year--) {
|
||||
if (isAcademicYear) {
|
||||
years.push(`${year} - ${year + 1}`)
|
||||
} else {
|
||||
years.push(year)
|
||||
}
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
const tagName = `tag-${new Date().getTime()}`
|
||||
const selectYear = async (page: Page, year: number) => {
|
||||
await page.getByLabel('Associated start year (optional)').fill(year.toString())
|
||||
}
|
||||
|
||||
const deleteTag = async (page: Page, tagName: string) => {
|
||||
await page.getByTestId(`delete-tag-${tagName}-button`).click()
|
||||
await expect(page.getByText('Delete tag', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Are you sure you want to delete tag')).toBeVisible()
|
||||
await page.getByTestId('confirm-delete-tag-button').click()
|
||||
}
|
||||
|
||||
test.describe('Degree programme overview', () => {
|
||||
test.describe('Degree programme selector', () => {
|
||||
test('Degree programme search filter', async ({ page }) => {
|
||||
await init(page, '/study-programme', 'admin')
|
||||
await expect(page.getByText('Tietojenkäsittelytieteen kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
|
||||
await page.getByTestId('study-programme-filter').click()
|
||||
await page.getByPlaceholder('Type here to filter degree programmes').fill('Tietojenkäsittelytieteen')
|
||||
|
||||
await expect(page.getByText('Tietojenkäsittelytieteen kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
test.describe('Basic information tab works for basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('year-toggle').click() // NOTE: Tests are written for calendar years
|
||||
})
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
test('Basic information tab loads', async ({ page }) => {
|
||||
await expect(page.getByTestId('students-of-the-study-programme-section')).toBeVisible()
|
||||
await expect(page.getByTestId('credits-produced-by-the-study-programme-section')).toBeVisible()
|
||||
await expect(page.getByTestId('graduated-and-thesis-writers-of-the-programme-section')).toBeVisible()
|
||||
await expect(page.getByTestId('programmes-before-or-after-section')).toBeVisible()
|
||||
await expect(page.getByTestId('average-graduation-times-section')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Basic information contains correct students', async ({ page }) => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Cancelled, Transferred Away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 8, 8, 26, 2, 0, 0],
|
||||
[2022, 25, 26, 47, 0, 1, 3],
|
||||
[2021, 29, 32, 48, 1, 0, 2],
|
||||
[2020, 26, 27, 12, 0, 1, 3],
|
||||
[2019, 28, 34, 1, 0, 0, 1],
|
||||
[2018, 40, 45, 0, 0, 0, 1],
|
||||
[2017, 41, 47, 0, 0, 0, 0],
|
||||
]
|
||||
await checkTableStats(page, tableContents, 'students-of-the-study-programme')
|
||||
})
|
||||
|
||||
test('Basic information contains correct credits', async ({ page }) => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 0, 0, 0, 222, 0],
|
||||
[2022, 3235, 3205, 0, 0, 30, 0, 0, 209, 0],
|
||||
[2021, 5133, 5108, 0, 0, 25, 0, 0, 428, 25],
|
||||
[2020, 5801, 5796, 0, 0, 5, 0, 0, 94, 10],
|
||||
[2019, 5305, 5305, 0, 0, 0, 0, 0, 162, 0],
|
||||
[2018, 3442, 3432, 0, 0, 10, 0, 0, 21, 0],
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 0, 189, 0],
|
||||
]
|
||||
await checkTableStats(page, tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
test('Basic information contains correct thesis writers and graduates', async ({ page }) => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 26, 5],
|
||||
[2022, 47, 13],
|
||||
[2021, 48, 76],
|
||||
[2020, 12, 23],
|
||||
[2019, 1, 19],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
await checkTableStats(page, tableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
test('Special study rights can be excluded and basic data changes accordingly', async ({ page }) => {
|
||||
await page.getByTestId('study-right-toggle').click()
|
||||
const years = getEmptyYears()
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Cancelled]
|
||||
...years.map(year => [year, 0, 0, 0, 0]),
|
||||
[2023, 8, 8, 24, 2],
|
||||
[2022, 25, 26, 43, 0],
|
||||
[2021, 29, 32, 47, 1],
|
||||
[2020, 26, 27, 11, 0],
|
||||
[2019, 28, 34, 1, 0],
|
||||
[2018, 40, 45, 0, 0],
|
||||
[2017, 41, 47, 0, 0],
|
||||
]
|
||||
await checkTableStats(page, studentTableContents, 'students-of-the-study-programme')
|
||||
const graduatedTableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 24, 5],
|
||||
[2022, 43, 12],
|
||||
[2021, 47, 69],
|
||||
[2020, 11, 22],
|
||||
[2019, 1, 18],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
await checkTableStats(page, graduatedTableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
test('Year can be changed to academic year, and data changes accordingly', async ({ page }) => {
|
||||
await page.getByTestId('year-toggle').click()
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Cancelled, Transferred away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 4, 1, 0, 0],
|
||||
['2022 - 2023', 25, 26, 35, 1, 0, 0],
|
||||
['2021 - 2022', 29, 32, 58, 1, 1, 5],
|
||||
['2020 - 2021', 26, 27, 30, 0, 0, 3],
|
||||
['2019 - 2020', 28, 34, 7, 0, 1, 1],
|
||||
['2018 - 2019', 40, 45, 0, 0, 0, 1],
|
||||
['2017 - 2018', 41, 47, 0, 0, 0, 0],
|
||||
]
|
||||
await checkTableStats(page, studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const creditTableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 160, 160, 0, 0, 0, 0, 0, 67, 0],
|
||||
['2022 - 2023', 2725, 2720, 0, 0, 5, 0, 0, 337, 0],
|
||||
['2021 - 2022', 4092, 4042, 0, 0, 50, 0, 0, 198, 0],
|
||||
['2020 - 2021', 5420, 5415, 0, 0, 5, 0, 0, 321, 25],
|
||||
['2019 - 2020', 6043, 6043, 0, 0, 0, 0, 0, 101, 10],
|
||||
['2018 - 2019', 4856, 4851, 0, 0, 5, 0, 0, 107, 0],
|
||||
['2017 - 2018', 2350, 2345, 0, 0, 5, 0, 0, 26, 0],
|
||||
]
|
||||
await checkTableStats(page, creditTableContents, 'credits-produced-by-the-study-programme')
|
||||
// await page.getByTestId('year-toggle').click()
|
||||
})
|
||||
|
||||
test('Basic information graphs render', async ({ page }) => {
|
||||
const graph = page.getByTestId('students-of-the-study-programme-line-graph-section')
|
||||
await expect(graph.getByText('Started studying')).toBeVisible()
|
||||
await expect(graph.getByText('Accepted')).toBeVisible()
|
||||
await expect(graph.getByText('Graduated')).toBeVisible()
|
||||
await expect(graph.getByText('Cancelled')).toBeVisible()
|
||||
await expect(graph.getByText('Transferred away')).toBeVisible()
|
||||
await expect(graph.getByText('Transferred to')).toBeVisible()
|
||||
|
||||
const creditsBarChart = page.getByTestId('credits-produced-by-the-study-programme-stacked-bar-chart-section')
|
||||
await expect(creditsBarChart.getByText('Degree students')).toBeVisible()
|
||||
await expect(creditsBarChart.getByText('Transferred')).toBeVisible()
|
||||
await expect(creditsBarChart.getByText('5796')).toBeVisible()
|
||||
await expect(creditsBarChart.getByText('428')).toBeVisible()
|
||||
|
||||
const thesisBarChart = page.getByTestId('graduated-and-thesis-writers-of-the-programme-bar-chart-section')
|
||||
await expect(thesisBarChart.getByText('Graduated students')).toBeVisible()
|
||||
await expect(thesisBarChart.getByText('Wrote thesis')).toBeVisible()
|
||||
await expect(thesisBarChart.getByText('47')).toBeVisible()
|
||||
await expect(thesisBarChart.getByText('76')).toBeVisible()
|
||||
|
||||
await page.getByTestId('graduation-mode-selector').getByTestId('select-median').click()
|
||||
await expect(page.getByTestId('unset-median-bar-chart').getByText('47 graduated')).toBeVisible()
|
||||
|
||||
await page.getByTestId('unset-median-bar-chart').getByText('47 graduated').hover()
|
||||
await expect(page.locator('.grad-vals').getByText('47 students graduated in year 2022')).toBeVisible()
|
||||
await expect(page.getByText('median study time: 6 semesters')).toBeVisible()
|
||||
await expect(page.getByText('24 graduated on time')).toBeVisible()
|
||||
await expect(page.getByText('12 graduated max year overtime')).toBeVisible()
|
||||
await expect(page.getByText('11 graduated over year late')).toBeVisible()
|
||||
|
||||
const programmesBarChart = page.getByTestId('programmes-before-or-after-stacked-bar-chart-section')
|
||||
await expect(programmesBarChart.getByText('Tietojenkäsittelytieteen maisteriohjelma')).toBeVisible()
|
||||
await expect(programmesBarChart.getByText('Datatieteen maisteriohjelma')).toBeVisible()
|
||||
await expect(programmesBarChart.getByText('Matematiikan ja tilastotieteen maisteriohjelma')).toBeVisible()
|
||||
|
||||
const row = page
|
||||
.getByTestId('programmes-before-or-after-data-table')
|
||||
.locator('tr')
|
||||
.filter({ hasText: 'Matematiikan ja tilastotieteen maisteriohjelma' })
|
||||
await expect(row.getByText('33')).toBeVisible()
|
||||
await expect(row.getByText('20')).toBeVisible()
|
||||
await expect(row.getByText('17')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Graduation times of master programmes', () => {
|
||||
test('are split into three graphs', async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matematiikan ja tilastotieteen maisteriohjelma' }).click()
|
||||
await page.getByTestId('year-toggle').click() // Tests are written for calendar year
|
||||
|
||||
await page.getByTestId('graduation-mode-selector').getByTestId('select-average').click()
|
||||
|
||||
const masterChart = page
|
||||
.getByTestId('unset-graduation-times-section')
|
||||
.filter({ hasText: 'Master study right', hasNotText: 'Bachelor +' })
|
||||
await expect(masterChart).toBeVisible()
|
||||
await expect(masterChart.getByText('2 graduated')).toBeVisible()
|
||||
|
||||
await masterChart.getByText('2 graduated').hover()
|
||||
await expect(page.locator('.grad-vals').getByText('2 students graduated in year 2021')).toBeVisible()
|
||||
await expect(page.getByText('average study time: 4.5 semesters')).toBeVisible()
|
||||
await expect(page.getByText('1 graduated on time')).toBeVisible()
|
||||
await expect(page.getByText('1 graduated max year overtime')).toBeVisible()
|
||||
await expect(page.getByText('0 graduated over year late')).toBeVisible()
|
||||
|
||||
const bachelorMasterChart = page
|
||||
.getByTestId('unset-graduation-times-section')
|
||||
.filter({ hasText: 'Bachelor + master study right' })
|
||||
await expect(bachelorMasterChart).toBeVisible()
|
||||
await expect(bachelorMasterChart.getByText('11 graduated')).toBeVisible()
|
||||
|
||||
await bachelorMasterChart.getByText('11 graduated').hover()
|
||||
await expect(page.locator('.grad-vals').getByText('11 students graduated in year 2023')).toBeVisible()
|
||||
await expect(page.getByText('average study time: 11.18 semesters')).toBeVisible()
|
||||
await expect(page.getByText('4 graduated on time')).toBeVisible()
|
||||
await expect(page.getByText('6 graduated max year overtime')).toBeVisible()
|
||||
await expect(page.getByText('1 graduated over year late')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Study tracks and class statistics tab works for basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('StudyTracksAndClassStatisticsTab').click()
|
||||
})
|
||||
|
||||
test.describe('Info boxes', () => {
|
||||
test('Study track overview section', async ({ page }) => {
|
||||
await page.getByTestId('study-track-overview-info-box-button').hover()
|
||||
await expect(
|
||||
page.getByTestId('study-track-overview-info-box-content').getByText('Opiskelijat, joiden')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Progress of students section', async ({ page }) => {
|
||||
await page.getByTestId('progress-of-students-info-box-button').hover()
|
||||
await expect(
|
||||
page.getByTestId('progress-of-students-info-box-content').getByText('Kuvaa koulutusohjelmassa')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Average graduation times section', async ({ page }) => {
|
||||
await page.getByTestId('average-graduation-times-info-box-button').click()
|
||||
await expect(
|
||||
page.getByTestId('average-graduation-times-info-box-content').getByText('Yksittäinen palkki')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('All sections are visible', async ({ page }) => {
|
||||
await expect(page.getByTestId('study-track-selector-section')).toBeVisible()
|
||||
await expect(page.getByTestId('study-track-overview-section')).toBeVisible()
|
||||
await expect(page.getByTestId('progress-of-students-section')).toBeVisible()
|
||||
await expect(page.getByTestId('average-graduation-times-section')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Students of the degree programme are shown correctly', async ({ page }) => {
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Cancelled, Has recent attainments, Men, Women, Other/Unknown, Finland, Other]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 0, 0, 8, 0, 0, 0, 5, 3, 0, 8, 1],
|
||||
['2022 - 2023', 26, 25, 0, 0, 24, 2, 1, 0, 19, 7, 0, 25, 1],
|
||||
['2021 - 2022', 37, 29, 0, 0, 33, 4, 0, 0, 29, 8, 0, 35, 4],
|
||||
['2020 - 2021', 30, 26, 0, 0, 11, 19, 0, 0, 15, 15, 0, 29, 3],
|
||||
['2019 - 2020', 35, 28, 0, 0, 8, 27, 0, 0, 22, 13, 0, 35, 2],
|
||||
['2018 - 2019', 46, 40, 0, 0, 6, 40, 1, 0, 26, 20, 0, 45, 1],
|
||||
['2017 - 2018', 47, 41, 0, 0, 5, 42, 1, 0, 31, 16, 0, 47, 0],
|
||||
['Total', 229, 197, 0, 0, 95, 134, 3, 0, 147, 82, 0, 224, 12],
|
||||
]
|
||||
await checkTableStats(page, tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
test('Years in the students table can be expanded and study track data will be shown', async ({ page }) => {
|
||||
const totalStats: [string, ...Array<number>] = ['2020 - 2021', 30, 26, 0, 0, 11, 19, 0, 0, 15, 15, 0, 29, 3]
|
||||
const table = page.getByTestId('study-tracks-and-class-statistics-data-table').locator('tbody')
|
||||
const row = table.locator('tr').filter({ hasText: '2020 - 2021' })
|
||||
|
||||
await expect(row.locator('td')).toHaveCount(14)
|
||||
await expect(row.locator('td')).toHaveText(totalStats.map(n => n.toString()))
|
||||
|
||||
const studyTrackStats: [string, ...Array<number>][] = [
|
||||
['Ekonometria (MAT-EKO)', 2, 2, 0, 0, 0, 2, 0, 0, 1, 1, 0, 2, 0],
|
||||
['Matematiikka (MAT-MAT)', 13, 10, 0, 0, 1, 12, 0, 0, 7, 6, 0, 12, 3],
|
||||
['Tietojenkäsittelyteoria (MAT-TIE)', 2, 1, 0, 0, 1, 1, 0, 0, 2, 0, 0, 2, 0],
|
||||
['Tilastotiede (MAT-TIL)', 4, 4, 0, 0, 0, 4, 0, 0, 1, 3, 0, 4, 0],
|
||||
]
|
||||
|
||||
await row.getByTestId('show-study-tracks-button').click()
|
||||
|
||||
await Promise.all(
|
||||
studyTrackStats.map(async ([studyTrack, ...stats]) => {
|
||||
await expect(page.getByText(studyTrack)).toBeVisible()
|
||||
const studyTrackRow = table.locator('tr').filter({ hasText: studyTrack })
|
||||
|
||||
await expect(studyTrackRow.locator('td')).toHaveCount(14)
|
||||
await expect(studyTrackRow.locator('td')).toHaveText([studyTrack, ...stats].map(n => n.toString()))
|
||||
})
|
||||
)
|
||||
|
||||
await row.getByTestId('show-study-tracks-button').click()
|
||||
await Promise.all(
|
||||
studyTrackStats.map(async ([studyTrack]) => {
|
||||
await expect(page.getByText(studyTrack)).not.toBeVisible()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Population link button works for', () => {
|
||||
test('a single year', async ({ page }) => {
|
||||
await page.getByTestId('2023-population-link-button').click()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2023 - 2024, 8 students')).toBeVisible()
|
||||
})
|
||||
|
||||
test('total', async ({ page }) => {
|
||||
await page.getByTestId('total-population-link-button').click()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2017 - 2026, 227 students')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Links to class statistics page with study track info included work', async ({ page }) => {
|
||||
const table = page.getByTestId('study-tracks-and-class-statistics-data-table').locator('tbody')
|
||||
const row = table.locator('tr').filter({ hasText: '2022 - 2023' })
|
||||
|
||||
await row.getByTestId('show-study-tracks-button').click()
|
||||
await table.locator('tr').filter({ hasText: 'Matematiikka (MAT-MAT)' }).getByRole('link').click()
|
||||
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Studytrack MAT-MAT')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2022 - 2023, 26 students')).toBeVisible()
|
||||
await expect(page.getByText('Showing 3 out of 26 students')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test('Student progress data is shown correctly', async ({ page }) => {
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits, Graduated]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 26, 9, 8, 4, 3, 0, 0, 0, 2],
|
||||
['2021 - 2022', 37, 8, 4, 9, 8, 4, 0, 0, 4],
|
||||
['2020 - 2021', 30, 2, 1, 2, 5, 0, 0, 1, 19],
|
||||
['2019 - 2020', 35, 1, 0, 2, 1, 0, 0, 4, 27],
|
||||
['2018 - 2019', 46, 0, 1, 1, 2, 0, 0, 2, 40],
|
||||
['2017 - 2018', 47, 0, 1, 3, 0, 0, 1, 0, 42],
|
||||
['Total', 229, 28, 15, 21, 19, 4, 1, 7, 134],
|
||||
]
|
||||
await checkTableStats(page, tableContents, 'study-programme-progress')
|
||||
})
|
||||
|
||||
test('Progress section', async ({ page }) => {
|
||||
const barChart = page.getByTestId('programme-progress-bar-chart-section')
|
||||
await expect(barChart.getByText('Less than 30 credits')).toBeVisible()
|
||||
await expect(barChart.getByText('30–60 credits')).toBeVisible()
|
||||
await expect(barChart.getByText('At least 180 credits')).toBeVisible()
|
||||
await expect(barChart.getByText('58.5%')).toBeVisible() // The percentage for total graduated, to check that the graph renders
|
||||
|
||||
await page.getByTestId('programme-progress-bar-chart-section').getByText('58.5%').hover()
|
||||
await expect(page.getByText('Graduated: 134')).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Average graduation times section', () => {
|
||||
test('Shows correct data for all years and one year', async ({ page }) => {
|
||||
// NOTE: This test could be flaky. Many nth and first/last used
|
||||
await expect(page.getByTestId('unset-graduation-times-section').getByText('2019 - 2020')).toBeVisible()
|
||||
await page.getByTestId('unset-graduation-times-section').getByText('21', { exact: true }).hover()
|
||||
await expect(page.getByText('On time: 21')).toBeVisible()
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId('unset-graduation-times-section')
|
||||
.getByText('Click any bar on the chart to open a study track level breakdown for that year')
|
||||
).toBeVisible()
|
||||
await page.getByTestId('unset-graduation-times-section').getByText('15', { exact: true }).nth(1).click() // Skip legend
|
||||
await expect(
|
||||
page
|
||||
.getByTestId('unset-graduation-times-section')
|
||||
.getByText('Click any bar on the chart to open a study track level breakdown for that year')
|
||||
).not.toBeVisible()
|
||||
|
||||
await expect(page.getByText('Year 2020 - 2021 by start year')).toBeVisible()
|
||||
await expect(page.getByText('MAT-MAT')).toBeVisible()
|
||||
|
||||
await page.getByTestId('unsetBreakdownBarChartFaculty').getByText('9', { exact: true }).hover()
|
||||
await expect(page.getByText('Matematiikka')).toBeVisible()
|
||||
await expect(page.getByText('MAT-MAT').last()).toBeVisible()
|
||||
await expect(page.getByText('On time: 9')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows correct average', async ({ page }) => {
|
||||
await page.getByTestId('select-average').click()
|
||||
const section = page.getByTestId('average-graduation-times-section')
|
||||
|
||||
await expect(section.getByText('2020 - 2021')).toBeVisible()
|
||||
await section.getByText('19 graduated').hover()
|
||||
|
||||
await expect(page.getByText('From class of 2020 - 2021, 19/30 students have graduated')).toBeVisible()
|
||||
await expect(page.getByText('average study time: 5.79 semesters')).toBeVisible()
|
||||
await expect(page.getByText('15 graduated on time')).toBeVisible()
|
||||
await expect(page.getByText('3 graduated max year overtime')).toBeVisible()
|
||||
await expect(page.getByText('1 graduated over year late')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Study track can be changed', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// page.waitForTimeout('@stQuery')
|
||||
await expect(page.getByText('All students of the programme')).toBeVisible()
|
||||
await page.getByTestId('study-track-select').click()
|
||||
await expect(page.getByText('Ekonometria')).toBeVisible()
|
||||
await expect(page.getByText('Tietojenkäsittelyteoria')).toBeVisible()
|
||||
await expect(page.getByText('Tilastotiede')).toBeVisible()
|
||||
await page.getByText('Matematiikka').click()
|
||||
})
|
||||
|
||||
test('Students of the study track are shown correctly', async ({ page }) => {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId('study-track-overview-section')
|
||||
.getByText('Students of the study track MAT-MAT by starting year')
|
||||
).toBeVisible()
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Cancelled, Has recent attainment, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2022 - 2023', 3, 3, 0, 0, 1, 2, 1, 0, 2, 1, 0, 3, 0],
|
||||
['2021 - 2022', 4, 1, 0, 0, 1, 3, 0, 0, 3, 1, 0, 4, 0],
|
||||
['2020 - 2021', 13, 10, 0, 0, 1, 12, 0, 0, 7, 6, 0, 12, 3],
|
||||
['2019 - 2020', 17, 14, 0, 0, 0, 17, 0, 0, 10, 7, 0, 17, 1],
|
||||
['2018 - 2019', 24, 21, 0, 0, 2, 22, 1, 0, 11, 13, 0, 24, 0],
|
||||
['2017 - 2018', 28, 24, 0, 0, 1, 27, 1, 0, 15, 13, 0, 28, 0],
|
||||
['Total', 89, 73, 0, 0, 6, 83, 3, 0, 48, 41, 0, 88, 4],
|
||||
]
|
||||
await checkTableStats(page, tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
test('Links to class statistics page with study track info included work', async ({ page }) => {
|
||||
await page.getByTestId('2020-population-link-button').click()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Studytrack MAT-MAT')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2020 - 2021, 30 students')).toBeVisible()
|
||||
await expect(page.getByText('Showing 10 out of 30 students')).toBeVisible()
|
||||
})
|
||||
|
||||
// FIXME: This feature was implemented. The data is no-good, so maybe reimplement this when it's clean.
|
||||
test.fixme('Info message about missing progress stats is displayed', async ({ page }) => {
|
||||
await expect(page.getByText('Progress of students of the study track MAT-MAT by starting year')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Progress data is currently only available for all students of the degree programme.')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Programme courses tab works for basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('ProgrammeCoursesTab').click()
|
||||
await expect(page.getByTestId('by-credit-type-section')).toBeVisible()
|
||||
await expect(page.getByTestId('by-credit-type-tab')).toBeVisible()
|
||||
await expect(page.getByTestId('by-semester-tab')).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('By credit type tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await expect(page.getByText('Programme courses by credit type')).toBeVisible()
|
||||
await expect(page.getByText('Loading content')).not.toBeVisible()
|
||||
await page.getByText('Total students').click()
|
||||
})
|
||||
|
||||
test('shows correct data', async ({ page }) => {
|
||||
await expect(
|
||||
page
|
||||
.locator('tbody')
|
||||
.locator('tr')
|
||||
.filter({ hasText: 'Johdatus yliopistomatematiikkaan', hasNotText: 'Avoin yo' })
|
||||
.locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT11001', 'Johdatus yliopistomatematiikkaan', 272, 253, 19, 240, 0, 4, 0, 0, 0, 1, 9].map(n =>
|
||||
n.toString()
|
||||
)
|
||||
)
|
||||
await expect(
|
||||
page.locator('tbody').locator('tr').filter({ hasText: 'Raja-arvot', hasNotText: 'Avoin yo' }).locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT11003', 'Raja-arvot', 270, 249, 21, 237, 0, 4, 0, 0, 0, 1, 8].map(n => n.toString())
|
||||
)
|
||||
await expect(
|
||||
page
|
||||
.locator('tbody')
|
||||
.locator('tr')
|
||||
.filter({ hasText: 'Differentiaalilaskenta', hasNotText: 'Avoin yo' })
|
||||
.locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT11004', 'Differentiaalilaskenta', 262, 248, 14, 230, 0, 4, 0, 0, 0, 1, 14].map(n =>
|
||||
n.toString()
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
test('year toggle works', async ({ page }) => {
|
||||
await page.getByTestId('year-toggle').click()
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('tbody')
|
||||
.locator('tr')
|
||||
.filter({ hasText: 'Johdatus yliopistomatematiikkaan', hasNotText: 'Avoin yo' })
|
||||
.locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT11001', 'Johdatus yliopistomatematiikkaan', 278, 259, 19, 240, 0, 4, 0, 0, 0, 1, 15].map(n =>
|
||||
n.toString()
|
||||
)
|
||||
)
|
||||
await expect(
|
||||
page.locator('tbody').locator('tr').filter({ hasText: 'Raja-arvot', hasNotText: 'Avoin yo' }).locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT11003', 'Raja-arvot', 274, 253, 21, 237, 0, 4, 0, 0, 0, 1, 12].map(n => n.toString())
|
||||
)
|
||||
await expect(
|
||||
page
|
||||
.locator('tbody')
|
||||
.locator('tr')
|
||||
.filter({ hasText: 'Differentiaalilaskenta', hasNotText: 'Avoin yo' })
|
||||
.locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT11004', 'Differentiaalilaskenta', 264, 250, 14, 230, 0, 4, 0, 0, 0, 1, 16].map(n =>
|
||||
n.toString()
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
test('time range selection works', async ({ page }) => {
|
||||
await page.getByTestId('from-year-select').click()
|
||||
await page.getByTestId('from-year-select-option-2020').click()
|
||||
await page.getByTestId('to-year-select').click()
|
||||
await page.getByTestId('to-year-select-option-2021').click()
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('tbody')
|
||||
.locator('tr')
|
||||
.filter({ hasText: 'Vektorianalyysi I', hasNotText: 'Avoin yo' })
|
||||
.filter({ hasNotText: 'Vektorianalyysi II' })
|
||||
.locator('td')
|
||||
).toHaveText(
|
||||
['Course', 'MAT21003', 'Vektorianalyysi I', 104, 97, 7, 89, 0, 1, 0, 0, 0, 0, 10].map(n => n.toString())
|
||||
)
|
||||
await expect(
|
||||
page.locator('tbody').locator('tr').filter({ hasText: 'Sarjat', hasNotText: 'Avoin yo' }).locator('td')
|
||||
).toHaveText(['Course', 'MAT21002', 'Sarjat', 83, 77, 6, 70, 0, 1, 0, 0, 0, 0, 7].map(n => n.toString()))
|
||||
await expect(
|
||||
page.locator('tbody').locator('tr').filter({ hasText: 'Raja-arvot', hasNotText: 'Avoin yo' }).locator('td')
|
||||
).toHaveText(['Course', 'MAT11003', 'Raja-arvot', 80, 73, 7, 69, 0, 2, 0, 0, 0, 1, 1].map(n => n.toString()))
|
||||
})
|
||||
|
||||
test("'Show credits/Show students' toggle works", async ({ page }) => {
|
||||
const headers = page.getByRole('columnheader')
|
||||
await expect(headers).toHaveCount(17)
|
||||
await expect(headers).toHaveText([
|
||||
'Type',
|
||||
'Code',
|
||||
'Name',
|
||||
'Total students',
|
||||
'Breakdown of total',
|
||||
'Breakdown of passed',
|
||||
'Not included in total or passed',
|
||||
'Passed',
|
||||
'Not completed',
|
||||
'Degree students',
|
||||
'Open university students (with hetu)',
|
||||
'Open university students (without hetu)',
|
||||
'Exchange students',
|
||||
'Other university students',
|
||||
'Separate studies',
|
||||
'Other students',
|
||||
'Students with transferred credits',
|
||||
])
|
||||
|
||||
await page.getByTestId('show-credits-students-toggle').click()
|
||||
await expect(headers).toHaveCount(13)
|
||||
await expect(headers).toHaveText([
|
||||
'Type',
|
||||
'Code',
|
||||
'Name',
|
||||
'Total credits',
|
||||
'Credits produced by',
|
||||
'Transferred credits',
|
||||
'Degree students',
|
||||
'Open university (hetu)',
|
||||
'Open university (no hetu)',
|
||||
'Exchange students',
|
||||
'Other universities',
|
||||
'Separate studies',
|
||||
'Other',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('By semester tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.getByTestId('by-semester-tab').click()
|
||||
})
|
||||
|
||||
// TODO: Implement proper tests
|
||||
test('can be opened', async ({ page }) => {
|
||||
await expect(page.getByText('Programme courses by semester')).toBeVisible()
|
||||
await expect(page.getByText('From')).toBeVisible()
|
||||
await expect(page.getByText('Until')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Degree courses tab works for basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('DegreeCoursesTab').click()
|
||||
})
|
||||
|
||||
test.describe('Curriculum section', () => {
|
||||
test('is shown in both settings tabs', async ({ page }) => {
|
||||
for (const tab of ['criteria', 'visibility']) {
|
||||
await page.getByTestId(`${tab}-tab`).click()
|
||||
const panel = page.getByTestId(`${tab}-panel`)
|
||||
await expect(panel.getByTestId('curriculum-section')).toBeVisible()
|
||||
await expect(panel.getByText('Select curriculum to edit:')).toBeVisible()
|
||||
await expect(panel.getByTestId('curriculum-picker').getByText('2023–2026')).toBeVisible()
|
||||
}
|
||||
})
|
||||
test.skip('can be changed', () => {
|
||||
return
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Credit criteria section', () => {
|
||||
test('info box', async ({ page }) => {
|
||||
await expect(page.getByTestId('credit-criteria-section')).toBeVisible()
|
||||
await page.getByTestId('credit-criteria-info-box-button').click()
|
||||
await expect(page.getByTestId('credit-criteria-info-box-content').getByText('Tässä asetetut')).toBeVisible()
|
||||
})
|
||||
|
||||
test.skip('changing the credit criteria works', () => {
|
||||
// TODO: Test using the inputs
|
||||
// TODO: Button status
|
||||
// TODO: Test "previously set to _" text changing when saving
|
||||
})
|
||||
})
|
||||
|
||||
test('Degree course table', async ({ page }) => {
|
||||
await expect(page.getByTestId('criteria-panel').getByTestId('degree-course-table')).toBeVisible()
|
||||
await page.getByTestId('visibility-tab').click()
|
||||
await expect(page.getByTestId('visibility-panel').getByTestId('degree-course-table')).toBeVisible()
|
||||
// TODO: Test clicking the arrow buttons
|
||||
// TODO: Test toggling visibility for MODULES and COURSES
|
||||
// TODO: Test changing criterion labels
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Tags tab works for basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('TagsTab').click()
|
||||
|
||||
// We need to wait for tag page to load or there is no delete buttons
|
||||
await expect(page.getByText('Create new tag')).toBeVisible()
|
||||
|
||||
// Delete tags until none remain
|
||||
const deleteButtons = page.locator('[data-cy^=delete-tag-]')
|
||||
while ((await deleteButtons.count()) > 0) {
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
await deleteButtons.first().click()
|
||||
await page.getByTestId('confirm-delete-tag-button').click()
|
||||
|
||||
// Helps playwright to wait for the deleted tag acually being removed from DOM
|
||||
await expect(deleteButtons).toHaveCount(count - 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('info box', async ({ page }) => {
|
||||
await page.getByTestId('create-new-tag-info-box-button').click()
|
||||
await expect(page.getByTestId('create-new-tag-info-box-content').getByText('Here you can create')).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Adding tags to populations and removing them works', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await expect(page.getByText('This degree programme does not have any tags yet')).toBeVisible()
|
||||
await page.getByTestId('tag-name-text-field').getByRole('textbox').fill(tagName)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await deleteTag(page, tagName)
|
||||
await expect(page.getByText('This degree programme does not have any tags yet')).toBeVisible()
|
||||
})
|
||||
|
||||
test('can create and delete tags without start year', async ({ page }) => {
|
||||
await page.getByTestId('create-button').click()
|
||||
await expect(page.getByText(tagName)).toBeVisible()
|
||||
await expect(page.getByText('No associated start year')).toBeVisible()
|
||||
})
|
||||
|
||||
test('can create and delete tags with start year', async ({ page }) => {
|
||||
await selectYear(page, 2022)
|
||||
await page.getByTestId('create-button').click()
|
||||
await expect(page.getByText(tagName)).toBeVisible()
|
||||
await expect(page.getByText('Associated start year 2022')).toBeVisible()
|
||||
})
|
||||
|
||||
test.fixme('population link works', async ({ page }) => {
|
||||
await selectYear(page, 2022)
|
||||
await page.getByTestId('create-button').click()
|
||||
await expect(page.getByText(tagName)).toBeVisible()
|
||||
await page.getByTestId('population-link-button').click()
|
||||
// FIXME: If no students are tagged with the tag, it shows "Invalid tag id"
|
||||
await expect(page.getByText(`Tagged with: ${tagName}`)).toBeVisible()
|
||||
await page.goBack()
|
||||
})
|
||||
|
||||
test('can create personal tags', async ({ page }) => {
|
||||
await page.getByTestId('personal-tag-toggle').click()
|
||||
await page.getByTestId('create-button').click()
|
||||
await expect(page.getByText(tagName)).toBeVisible()
|
||||
await expect(page.getByTestId(`${tagName}-visibility-icon`)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Adding tags to students and removing them works', () => {
|
||||
const studentInput = '477806,478275; 478953 479239\n 480080'
|
||||
const studentNumbers = studentInput.match(/[^\s,;]+/g)!
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await init(page, '/study-programme', 'basic', context)
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('TagsTab').click()
|
||||
})
|
||||
|
||||
test('can add tags to students', async ({ page, context }) => {
|
||||
// NOTE: Navigating to each students' page takes time, fix?
|
||||
test.slow()
|
||||
test.setTimeout(120_000) // 2min
|
||||
|
||||
await page.getByTestId('tag-name-text-field').getByRole('textbox').fill(tagName)
|
||||
await selectYear(page, 2022)
|
||||
await page.getByTestId('create-button').click()
|
||||
await expect(page.getByText(tagName)).toBeVisible()
|
||||
await page.getByTestId('add-students-button').click()
|
||||
await page.getByTestId('add-students-text-field').getByRole('textbox').fill(studentInput)
|
||||
await page.getByTestId('add-students-confirm-button').click()
|
||||
|
||||
await expect(page.getByText('Students added to tag')).toBeVisible()
|
||||
|
||||
await page.getByTestId('population-link-button').click()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2022 - 2023')).toBeVisible()
|
||||
await expect(page.getByText(`Tagged with: ${tagName}`)).toBeVisible()
|
||||
await expect(page.getByText('Students (5)')).toBeVisible()
|
||||
|
||||
await page.getByText('Students (5)').click()
|
||||
|
||||
await Promise.all(studentNumbers.map(studentNumber => expect(page.getByText(studentNumber)).toBeVisible()))
|
||||
|
||||
await Promise.all(
|
||||
studentNumbers.map(async studentNumber => {
|
||||
const studentPage = await context.newPage()
|
||||
await studentPage.goto(`/students/${studentNumber}`)
|
||||
await expect(studentPage.getByRole('heading', { name: 'Tags' })).toBeVisible()
|
||||
await expect(studentPage.getByText(tagName)).toBeVisible()
|
||||
await studentPage.close()
|
||||
})
|
||||
)
|
||||
|
||||
await page.goBack()
|
||||
await deleteTag(page, tagName)
|
||||
})
|
||||
|
||||
test('deleting a tag from tag view also removes it from students', async ({ page, context }) => {
|
||||
// NOTE: Navigating to each students' page takes time, fix?
|
||||
test.slow()
|
||||
test.setTimeout(180_000) // 4min
|
||||
|
||||
await expect(page.getByText(tagName)).not.toBeVisible()
|
||||
await Promise.all(
|
||||
studentNumbers.map(async studentNumber => {
|
||||
const studentPage = await context.newPage()
|
||||
await studentPage.goto(`/students/${studentNumber}`)
|
||||
await expect(studentPage.getByRole('heading', { name: 'Tags' })).not.toBeVisible()
|
||||
await expect(studentPage.getByText(tagName)).not.toBeVisible()
|
||||
// await studentPage.close()
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('IAM user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/study-programme', 'onlyiamrights')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
})
|
||||
|
||||
test.skip('year selector', () => {
|
||||
// TODO: Implement this test
|
||||
// Things to test:
|
||||
// - Clicking the button works
|
||||
// - Selecting a study track works
|
||||
// - The button is disabled if only one year is selected
|
||||
// - The slider is not visible if the user does not have correct programme rights
|
||||
})
|
||||
|
||||
test('can access programme and correct tabs are visible', async ({ page }) => {
|
||||
await expect(page.getByText('Basic information')).toBeVisible()
|
||||
await expect(page.getByText('Study tracks and class statistics')).toBeVisible()
|
||||
await expect(page.getByText('Update statistics')).not.toBeVisible()
|
||||
await expect(page.getByText('Degree courses')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('can access basic information', async ({ page }) => {
|
||||
await page.getByTestId('BasicInformationTab').click()
|
||||
await expect(page.getByTestId('students-of-the-study-programme-section')).toBeVisible()
|
||||
await expect(page.getByTestId('credits-produced-by-the-study-programme-section')).toBeVisible()
|
||||
await expect(page.getByTestId('graduated-and-thesis-writers-of-the-programme-section')).toBeVisible()
|
||||
await expect(page.getByTestId('average-graduation-times-section')).toBeVisible()
|
||||
await expect(page.getByTestId('programmes-before-or-after-section')).toBeVisible()
|
||||
})
|
||||
|
||||
test('can access study tracks', async ({ page }) => {
|
||||
await page.getByTestId('StudyTracksAndClassStatisticsTab').click()
|
||||
await expect(page.getByTestId('study-track-overview-section')).toBeVisible()
|
||||
await expect(page.getByTestId('progress-of-students-section')).toBeVisible()
|
||||
await expect(page.getByTestId('average-graduation-times-section')).toBeVisible()
|
||||
})
|
||||
|
||||
test("doesn't see other tabs", async ({ page }) => {
|
||||
await expect(page.getByTestId('DegreeCoursesTab')).not.toBeVisible()
|
||||
await expect(page.getByTestId('TagsTab')).not.toBeVisible()
|
||||
await expect(page.getByTestId('UpdateStatisticsTab')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,195 +0,0 @@
|
||||
import { expect, Page, test } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const facultyName = 'Matemaattis-luonnontieteellinen tiedekunta'
|
||||
|
||||
test.use({ acceptDownloads: true })
|
||||
|
||||
const openFaculty = async (page: Page, userId?: 'admin' | 'basic') => {
|
||||
await init(page, '/faculties', userId)
|
||||
await page.getByRole('link', { name: facultyName }).click()
|
||||
}
|
||||
|
||||
test.describe('Faculty statistics', () => {
|
||||
test.describe('Faculty list', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/faculties')
|
||||
})
|
||||
|
||||
test('contains faculty names and faculty codes', async ({ page }) => {
|
||||
const faculties = [
|
||||
['Teologinen tiedekunta', 'H10'],
|
||||
['Oikeustieteellinen tiedekunta', 'H20'],
|
||||
['Lääketieteellinen tiedekunta', 'H30'],
|
||||
['Humanistinen tiedekunta', 'H40'],
|
||||
['Matemaattis-luonnontieteellinen tiedekunta', 'H50'],
|
||||
['Farmasian tiedekunta', 'H55'],
|
||||
['Bio- ja ympäristötieteellinen tiedekunta', 'H57'],
|
||||
['Kasvatustieteellinen tiedekunta', 'H60'],
|
||||
['Valtiotieteellinen tiedekunta', 'H70'],
|
||||
['Svenska social- och kommunalhögskolan', 'H74'],
|
||||
['Maatalous-metsätieteellinen tiedekunta', 'H80'],
|
||||
['Eläinlääketieteellinen tiedekunta', 'H90'],
|
||||
]
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Faculties', exact: true })).toBeVisible()
|
||||
for (const [name, code] of faculties) {
|
||||
await expect(page.getByText(name, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(code, { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('contains a working link to faculty page', async ({ page }) => {
|
||||
await page.getByRole('link', { name: 'Teologinen tiedekunta' }).click()
|
||||
await expect(page).toHaveURL('/faculties/hy-org-1000000580')
|
||||
await expect(page.getByText('Teologinen tiedekunta', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('H10', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Basic information tab', () => {
|
||||
test('shows the correct tabs for an admin user', async ({ page }) => {
|
||||
await openFaculty(page, 'admin')
|
||||
const tabs = page.getByTestId('faculty-tabs')
|
||||
await expect(tabs).toContainText('Basic information')
|
||||
await expect(tabs).toContainText('Students by starting year')
|
||||
await expect(tabs).toContainText('Graduation times')
|
||||
await expect(tabs).toContainText('Update statistics')
|
||||
})
|
||||
|
||||
test.describe('Basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openFaculty(page)
|
||||
})
|
||||
|
||||
test('info boxes contain the correct information', async ({ page }) => {
|
||||
const infoBoxes = [
|
||||
['students-of-the-faculty-info-box-button', 'students-of-the-faculty-info-box-content', 'Taulukon luvut on'],
|
||||
[
|
||||
'graduated-of-the-faculty-info-box-button',
|
||||
'graduated-of-the-faculty-info-box-content',
|
||||
'Sisältää kyseisenä',
|
||||
],
|
||||
[
|
||||
'thesis-writers-of-the-faculty-info-box-button',
|
||||
'thesis-writers-of-the-faculty-info-box-content',
|
||||
'Sisältää kyseisenä',
|
||||
],
|
||||
[
|
||||
'credits-produced-by-the-faculty-info-box-button',
|
||||
'credits-produced-by-the-faculty-info-box-content',
|
||||
'Sisältää opintopisteet',
|
||||
],
|
||||
]
|
||||
|
||||
for (const [button, content, text] of infoBoxes) {
|
||||
await page.getByTestId(button).click()
|
||||
await expect(page.getByTestId(content)).toContainText(text)
|
||||
}
|
||||
})
|
||||
|
||||
test('shows all graphs and tables', async ({ page }) => {
|
||||
const sections = [
|
||||
'students-of-the-faculty-line-graph-section',
|
||||
'students-of-the-faculty-interactive-data-table',
|
||||
'graduated-of-the-faculty-line-graph-section',
|
||||
'graduated-of-the-faculty-interactive-data-table',
|
||||
'thesis-writers-of-the-faculty-line-graph-section',
|
||||
'thesis-writers-of-the-faculty-interactive-data-table',
|
||||
'credits-produced-by-the-faculty-stacked-bar-chart-section',
|
||||
'credits-produced-by-the-faculty-interactive-data-table',
|
||||
]
|
||||
|
||||
for (const section of sections) await expect(page.getByTestId(section)).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows the correct tabs', async ({ page }) => {
|
||||
const tabs = page.getByTestId('faculty-tabs')
|
||||
await expect(tabs).toContainText('Basic information')
|
||||
await expect(tabs).toContainText('Students by starting year')
|
||||
await expect(tabs).toContainText('Graduation times')
|
||||
await expect(tabs).not.toContainText('Update statistics')
|
||||
})
|
||||
|
||||
test('year toggle works', async ({ page }) => {
|
||||
const tables = [
|
||||
'credits-produced-by-the-faculty-interactive-data-table',
|
||||
'thesis-writers-of-the-faculty-interactive-data-table',
|
||||
'students-of-the-faculty-interactive-data-table',
|
||||
'graduated-of-the-faculty-interactive-data-table',
|
||||
]
|
||||
for (const table of tables) await expect(page.getByTestId(table)).toContainText('2022 - 2023')
|
||||
|
||||
await page.getByTestId('year-toggle').click()
|
||||
for (const table of tables) await expect(page.getByTestId(table)).toContainText('2022')
|
||||
})
|
||||
|
||||
test('programme toggle works', async ({ page }) => {
|
||||
await expect(page.getByTestId('faculty-programmes-shown-info')).not.toBeVisible()
|
||||
await page.getByTestId('programme-toggle').click()
|
||||
await expect(page.getByTestId('faculty-programmes-shown-info')).toBeVisible()
|
||||
})
|
||||
|
||||
test('study rights toggle works', async ({ page }) => {
|
||||
await expect(page.getByTestId('faculty-exclude-specials-info')).not.toBeVisible()
|
||||
await page.getByTestId('study-right-toggle').click()
|
||||
await expect(page.getByTestId('faculty-exclude-specials-info')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Students by starting year tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openFaculty(page)
|
||||
await page.getByText('Students by starting year', { exact: true }).click()
|
||||
})
|
||||
|
||||
test.skip('export button downloads an Excel file', async ({ page }) => {
|
||||
// Firefox does not trigger playwright's download event when downloading a file
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.getByTestId('faculty-student-table-export-button').click()
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toMatch(/^oodikone_H50_programme_stats_\d{4}-\d{2}-\d{2}\.xlsx$/)
|
||||
})
|
||||
|
||||
test('export button exists and works', async ({ page }) => {
|
||||
const exportButton = page.getByTestId('faculty-student-table-export-button')
|
||||
await expect(exportButton).toBeVisible()
|
||||
await expect(exportButton).toBeEnabled()
|
||||
await exportButton.click()
|
||||
})
|
||||
|
||||
test('info box contains the correct information', async ({ page }) => {
|
||||
await page.getByTestId('faculty-student-table-info-box-button').click()
|
||||
await expect(page.getByTestId('faculty-student-table-info-box-content')).toContainText('Opiskelijat, joiden')
|
||||
})
|
||||
|
||||
test('percentage toggle works', async ({ page }) => {
|
||||
const table = page.getByTestId('faculty-student-stats-table')
|
||||
await expect(table).toBeVisible()
|
||||
await expect(table).not.toContainText('92.3 %')
|
||||
await page.getByTestId('percentage-toggle').click()
|
||||
await expect(table).toContainText('92.3 %')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Graduation times tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openFaculty(page)
|
||||
await page.getByText('Graduation times', { exact: true }).click()
|
||||
})
|
||||
|
||||
test('info box contains the correct information', async ({ page }) => {
|
||||
await page.getByTestId('average-graduation-times-info-box-button').click()
|
||||
await expect(page.getByTestId('average-graduation-times-info-box-content')).toContainText(
|
||||
'Opiskelijoiden keskimääräiset'
|
||||
)
|
||||
})
|
||||
|
||||
test('graphs are visible', async ({ page }) => {
|
||||
await expect(page.getByTestId('bachelor-graduation-times-section')).toBeVisible()
|
||||
await expect(page.getByTestId('master-graduation-times-section')).toBeVisible()
|
||||
await expect(page.getByTestId('doctor-graduation-times-section')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,198 +0,0 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const checkFilteredStudentCount = async (page: Page, count: number) => {
|
||||
await expect(page.getByText(`Students (${count})`)).toBeVisible()
|
||||
}
|
||||
|
||||
const notYetImplemented = () => {
|
||||
test.skip(true, 'TODO')
|
||||
}
|
||||
|
||||
const selectDropdownOption = async (page: Page, selector: string, option: string | number) => {
|
||||
// Only open the filter card if it is closed
|
||||
const dropdown = page.getByTestId(`${selector}-selector`)
|
||||
if (!(await dropdown.isVisible())) {
|
||||
await page.getByTestId(`${selector}-header`).click()
|
||||
await expect(dropdown).toBeVisible()
|
||||
}
|
||||
|
||||
await dropdown.click()
|
||||
|
||||
const options = page.getByRole('listbox').getByRole('option')
|
||||
if (typeof option === 'number') await options.nth(option).click()
|
||||
else await options.filter({ hasText: option }).click()
|
||||
await page.keyboard.press('Escape') // Close the search box
|
||||
}
|
||||
|
||||
const setRange = async (page: Page, filter: string, min: number, max: number, expected: number) => {
|
||||
const card = page.getByTestId(`${filter}-filter-card`)
|
||||
await card.getByTestId('FilterRangeStart').locator('input').fill(String(min))
|
||||
await card.getByTestId('FilterRangeEnd').locator('input').fill(String(max))
|
||||
await checkFilteredStudentCount(page, expected)
|
||||
}
|
||||
|
||||
const pathToMathMSc2020 = '/populations?years=2020&programme=MH50_001&semesters=FALL&semesters=SPRING'
|
||||
const defaultMScStudents = 26
|
||||
|
||||
test.describe("Population statistics with a master's programme", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, pathToMathMSc2020)
|
||||
})
|
||||
|
||||
test('Study track filter works', async ({ page }) => {
|
||||
await selectDropdownOption(page, 'studyTrackFilter', 'Matematiikka ja soveltava matematiikka (MAST-MSM)')
|
||||
await checkFilteredStudentCount(page, 15)
|
||||
await page.getByTestId('studyTrackFilter-clear').click()
|
||||
await checkFilteredStudentCount(page, defaultMScStudents)
|
||||
})
|
||||
|
||||
test('Study right type filter works', async ({ page }) => {
|
||||
const filter = page.getByTestId('studyRightTypeFilter-header')
|
||||
await filter.click()
|
||||
for (const [value, count] of [
|
||||
['Bachelor + master', 24],
|
||||
['Master only', 2],
|
||||
['All', defaultMScStudents],
|
||||
] as const) {
|
||||
const option = page
|
||||
.getByTestId('studyRightTypeFilter-filter-card')
|
||||
.getByRole('radio', { name: value, exact: true })
|
||||
await expect(option).not.toBeChecked()
|
||||
await option.click()
|
||||
await expect(option).toBeChecked()
|
||||
await checkFilteredStudentCount(page, count)
|
||||
}
|
||||
await filter.click()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Population Statistics', () => {
|
||||
test.describe.skip('TODO', () => {
|
||||
test('Study right type filter is not visible', async ({ page }) => {
|
||||
await init(page, '/populations?years=2020&programme=KH50_001&semesters=FALL&semesters=SPRING')
|
||||
await expect(page.getByText('Study right type')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Transfer filter works and is set to "not transferred" by default', notYetImplemented)
|
||||
test('Graduation filter works', notYetImplemented)
|
||||
test('Enrollment filter works', notYetImplemented)
|
||||
test('Credit filter works', notYetImplemented)
|
||||
test('Age filter works', notYetImplemented)
|
||||
test('Gender filter works', notYetImplemented)
|
||||
test('Starting year filter works', notYetImplemented)
|
||||
test('Admission type filter works', notYetImplemented)
|
||||
test('Courses filter works', notYetImplemented)
|
||||
test('Filter combinations work', notYetImplemented)
|
||||
test('"Reset All Filters" button works', notYetImplemented)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Population Statistics with Bachelor + Master', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(
|
||||
page,
|
||||
'/populations?years=2017&programme=KH50_001&showBachelorAndMaster=true&semesters=FALL&semesters=SPRING'
|
||||
)
|
||||
})
|
||||
|
||||
test('Graduation filter works', async ({ page }) => {
|
||||
await page.getByTestId('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
for (const [value, count] of [
|
||||
["Graduated with Bachelor's", 42],
|
||||
["Graduated with Master's", 18],
|
||||
["Not graduated with Bachelor's", 5],
|
||||
["Not graduated with Master's", 29],
|
||||
] as const) {
|
||||
const option = page
|
||||
.getByTestId('graduatedFromProgrammeFilter-filter-card')
|
||||
.getByRole('radio', { name: value, exact: true })
|
||||
await expect(option).not.toBeChecked()
|
||||
await option.click()
|
||||
await expect(option).toBeChecked()
|
||||
await checkFilteredStudentCount(page, count)
|
||||
}
|
||||
})
|
||||
|
||||
test('Study right status filter works', async ({ page }) => {
|
||||
await page.getByTestId('studyRightStatusFilter-header').click()
|
||||
for (const [value, count] of [
|
||||
['Active Bachelor study right', 0],
|
||||
['Active Master study right', 0],
|
||||
['Passive Bachelor study right', 5],
|
||||
['Passive Master study right', 24],
|
||||
] as const) {
|
||||
const option = page
|
||||
.getByTestId('studyRightStatusFilter-filter-card')
|
||||
.getByRole('radio', { name: value, exact: true })
|
||||
await expect(option).not.toBeChecked()
|
||||
await option.click()
|
||||
await expect(option).toBeChecked()
|
||||
await checkFilteredStudentCount(page, count)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Course Statistics', () => {
|
||||
test('Grade filter works', notYetImplemented)
|
||||
test('Programme filter works and defaults to "Attainment"', notYetImplemented)
|
||||
test('Age filter works', notYetImplemented)
|
||||
test('Gender filter works', notYetImplemented)
|
||||
test('Starting year filter works', notYetImplemented)
|
||||
test('Filter combinations work', notYetImplemented)
|
||||
})
|
||||
|
||||
test.describe('Custom Population Statistics', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/custompopulation')
|
||||
const students = ['433237', '457144', '458090', '465136', '474270', '479440', '470391', '474789']
|
||||
await page.getByTestId('student-number-input').getByRole('textbox').pressSequentially(students.join('\n'))
|
||||
await page.getByTestId('search-button').click()
|
||||
await expect(page.getByText('Credit accumulation')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Age filter works', async ({ page }) => {
|
||||
await page.getByTestId('ageFilter-header').click()
|
||||
await setRange(page, 'ageFilter', 24, 28, 4)
|
||||
})
|
||||
|
||||
test('Programme filter works and defaults to "Active Study Right"', async ({ page }) => {
|
||||
await page.getByTestId('programmeFilter-header').click()
|
||||
|
||||
await expect(page.getByTestId('programmeFilter-mode-selector').getByRole('combobox')).toHaveText(
|
||||
'Active Study Right'
|
||||
)
|
||||
await selectDropdownOption(page, 'programmeFilter', 'Matemaattisten tieteiden kandiohjelma')
|
||||
await checkFilteredStudentCount(page, 5)
|
||||
})
|
||||
|
||||
test('Gender filter works', async ({ page }) => {
|
||||
for (const [value, count] of [
|
||||
['Female (5)', 5],
|
||||
['Male (3)', 3],
|
||||
['Other (0)', 0],
|
||||
['Unknown (0)', 0],
|
||||
] as const) {
|
||||
await selectDropdownOption(page, 'genderFilter', value)
|
||||
await checkFilteredStudentCount(page, count)
|
||||
}
|
||||
})
|
||||
|
||||
test('Starting year filter works', async ({ page }) => {
|
||||
await selectDropdownOption(page, 'startYearAtUniFilter', 0)
|
||||
await checkFilteredStudentCount(page, 2)
|
||||
})
|
||||
|
||||
test('Courses filter works', async ({ page }) => {
|
||||
const filter = page.getByTestId('courseFilter-header')
|
||||
await filter.click()
|
||||
await page.getByRole('combobox').fill('MAT11005 - Integraalilaskenta')
|
||||
await page.getByRole('option', { name: 'MAT11005 - Integraalilaskenta' }).click()
|
||||
await checkFilteredStudentCount(page, 7)
|
||||
// MAT11005 = hy-CU-117375607
|
||||
await page.getByTestId('courseFilter-hy-CU-117375607-selector').click()
|
||||
await page.getByRole('option', { name: 'Passed' }).click()
|
||||
await checkFilteredStudentCount(page, 6)
|
||||
})
|
||||
})
|
||||
@@ -1,81 +0,0 @@
|
||||
import { expect, test, Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const chooseSemester = async (page: Page, semester: string, fromOrTo: 'from' | 'to') => {
|
||||
await page.getByTestId(`semester-${fromOrTo}`).click()
|
||||
await page.getByTestId(`select-opt-${semester}`).click()
|
||||
// The dropdown unmounts its options on close, so wait for it to be gone before reading the table
|
||||
await expect(page.getByRole('listbox')).toHaveCount(0)
|
||||
}
|
||||
|
||||
const checkNumbers = async (page: Page, numbers: number[], numberOfColumns: number, mode: string) => {
|
||||
const totalRow = page.getByTestId(`ooditable-${mode}`).locator('table tbody tr').first()
|
||||
const cells = totalRow.locator('td')
|
||||
|
||||
await expect(cells).toHaveCount(numberOfColumns)
|
||||
await expect(cells).toHaveText(['Total' + 'All courses total'].concat(numbers.map(number => String(number))))
|
||||
}
|
||||
|
||||
test.describe('When language center is opened', () => {
|
||||
test.describe('as an admin user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/languagecenterview', 'admin')
|
||||
await page.getByTestId('completions-button').click()
|
||||
})
|
||||
|
||||
test.describe('Faculties tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await chooseSemester(page, 'Syksy 2017', 'from')
|
||||
await chooseSemester(page, 'Kevät 2024', 'to')
|
||||
})
|
||||
|
||||
test('Initial view is correct', async ({ page }) => {
|
||||
await expect(page.getByText('All courses total')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('cell', { name: 'AYKK-ENG Academic and Professional Communication in English' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('AYKK-RUKIRJ')).toBeVisible()
|
||||
})
|
||||
|
||||
// FIXME: This changes per updater run
|
||||
// Example case: 550789 - KK-JAOK15
|
||||
test.skip('Faculties tab shows numbers', async ({ page }) => {
|
||||
await checkNumbers(page, [2074, 36, 9, 33, 758, 15, 40, 6, 51, 1, 24, 1059, 42, 0], 15, 'faculties')
|
||||
})
|
||||
|
||||
test.skip('Faculties tab "exceeding" button works', async ({ page }) => {
|
||||
await page.getByTestId('difference-button').click()
|
||||
await checkNumbers(page, [66, 3, 0, 4, 18, 0, 1, 1, 4, 0, 0, 31, 3, 1], 15, 'faculties')
|
||||
})
|
||||
|
||||
test.skip('Faculties tab semester selector changes numbers', async ({ page }) => {
|
||||
await chooseSemester(page, 'Syksy 2020', 'from')
|
||||
await checkNumbers(page, [1182, 28, 6, 27, 498, 4, 10, 2, 36, 0, 9, 535, 27, 0], 15, 'faculties')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Semester tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.getByText('By semesters').click()
|
||||
await chooseSemester(page, 'Syksy 2017', 'from')
|
||||
await chooseSemester(page, 'Kevät 2024', 'to')
|
||||
})
|
||||
|
||||
test.skip('Semester tab shows numbers', async ({ page }) => {
|
||||
await checkNumbers(page, [2074, 69, 26, 298, 58, 343, 98, 437, 138, 310, 90, 123, 73, 10, 1], 16, 'semesters')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with a user with no rights', () => {
|
||||
test('"Access denied" is shown', async ({ page }) => {
|
||||
await init(page, '/languagecenterview', 'norights')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Access denied' })).toBeVisible()
|
||||
await expect(page.getByText("You don't currently have permission to view this page.")).toBeVisible()
|
||||
|
||||
await page.getByText('Special populations').click()
|
||||
await expect(page.getByText('Language center view')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,252 +0,0 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
type CourseData = [
|
||||
courseOrModule: string,
|
||||
code: string,
|
||||
groupId: string,
|
||||
name: string,
|
||||
total: number,
|
||||
passed: number,
|
||||
notCompleted: number,
|
||||
failed: number,
|
||||
enrolledNoGrade: number,
|
||||
]
|
||||
|
||||
const courseData: CourseData[] = [
|
||||
['Course', 'MAT11001', 'hy-CU-117375151', 'Johdatus yliopistomatematiikkaan', 272, 253, 1 + 18, 1, 18],
|
||||
['Course', 'MAT11003', 'hy-CU-117375413', 'Raja-arvot', 270, 249, 1 + 20, 1, 20],
|
||||
['Course', 'MAT11004', 'hy-CU-117375487', 'Differentiaalilaskenta', 262, 248, 1 + 13, 1, 13],
|
||||
['Course', 'MAT21002', 'hy-CU-117375793', 'Sarjat', 256, 250, 0 + 6, 0, 6],
|
||||
['Course', 'MAT11005', 'hy-CU-117375607', 'Integraalilaskenta', 251, 234, 0 + 17, 0, 17],
|
||||
['Course', 'MAT11002', 'hy-CU-117375394', 'Lineaarialgebra ja matriisilaskenta I', 247, 242, 1 + 4, 1, 4],
|
||||
['Course', 'MAT12003', 'hy-CU-117376940', 'Todennäköisyyslaskenta I', 234, 227, 3 + 4, 3, 4],
|
||||
['Course', 'MAT21001', 'hy-CU-117375754', 'Lineaarialgebra ja matriisilaskenta II', 230, 216, 4 + 10, 4, 10],
|
||||
['Course', 'MAT21003', 'hy-CU-117375829', 'Vektorianalyysi I', 228, 202, 3 + 23, 3, 23],
|
||||
['Course', 'MAT20005', 'hy-CU-120230575', 'Akateemiset taidot', 201, 178, 0 + 23, 0, 23],
|
||||
['Module', 'MAT110', 'hy-SM-117412902', 'Matematiikka, perusopinnot', 195, 195, 0 + 0, 0, 0],
|
||||
['Course', 'MAT21014', 'hy-CU-117377242', 'Johdatus logiikkaan I', 188, 170, 0 + 18, 0, 18],
|
||||
]
|
||||
|
||||
const MAT21003_GROUP_ID = 'hy-CU-117375829'
|
||||
|
||||
const yearlyData = [
|
||||
[2017, 1, 0, 1, 0, 1 + 0],
|
||||
[2018, 31, 27, 4, 0, 4 + 0],
|
||||
[2019, 58, 56, 2, 0, 2 + 0],
|
||||
[2020, 63, 62, 1, 0, 1 + 0],
|
||||
[2021, 41, 35, 0, 6, 0 + 6],
|
||||
[2022, 28, 21, 0, 7, 0 + 7],
|
||||
[2023, 21, 1, 0, 20, 0 + 20],
|
||||
] as const
|
||||
|
||||
// Copied from services/backend/src/services/studyProgramme/studyProgrammeHelpers.ts (almost)
|
||||
const getPercentage = (value: any, total: any) => {
|
||||
if (typeof value !== 'number' || typeof total !== 'number') return 'NA'
|
||||
if (total === 0) return 'NA'
|
||||
if (value === 0) return '0.00 %'
|
||||
return `${((value / total) * 100).toFixed(2)} %`
|
||||
}
|
||||
|
||||
const selectCourseStatus = async (page: Page, courseGroupId: string, status: string) => {
|
||||
await page.getByTestId(`courseFilter-${courseGroupId}-selector`).click()
|
||||
await page.getByRole('listbox').getByRole('option', { name: status }).click()
|
||||
}
|
||||
|
||||
const selectYear = async (page: Page, selector: string, option: string) => {
|
||||
await page.getByTestId(selector).click()
|
||||
await page.getByTestId(option).click()
|
||||
}
|
||||
|
||||
test.describe('Numbers should match between', () => {
|
||||
test.slow()
|
||||
test.setTimeout(180_000) // 3min
|
||||
|
||||
test.describe('Programme courses, Course statistics and Course population (no substitutions, all years)', () => {
|
||||
test('in Programme courses', async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('ProgrammeCoursesTab').click()
|
||||
await expect(page.getByText('Programme courses by credit type')).toBeVisible()
|
||||
|
||||
await page.getByRole('columnheader', { name: 'Total students' }).click()
|
||||
|
||||
const rows = page.locator('tbody tr')
|
||||
for (const [index, course] of courseData.entries()) {
|
||||
const row = rows.nth(index)
|
||||
const [courseOrModule, code, , name, total, passed, notCompleted] = course
|
||||
// Not optimal, but not checking all locator("td") matches directly with toHaveText is hard.
|
||||
for (const [fieldIndex, field] of [courseOrModule, code, name, total, passed, notCompleted].entries()) {
|
||||
await expect(row.locator('td').nth(fieldIndex)).toHaveText(String(field))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('in Course statistics', async ({ page }) => {
|
||||
for (const [
|
||||
_courseOrModule,
|
||||
_code,
|
||||
groupId,
|
||||
name,
|
||||
total,
|
||||
passed,
|
||||
_notCompleted,
|
||||
failed,
|
||||
enrolledNoGrade,
|
||||
] of courseData) {
|
||||
await init(page, `/coursestatistics?courses=${groupId}&substitutions=false`, 'basic')
|
||||
await expect(page).toHaveURL(
|
||||
url =>
|
||||
url.pathname === '/coursestatistics' &&
|
||||
url.searchParams.get('courses') === groupId &&
|
||||
url.searchParams.get('substitutions') === 'false'
|
||||
)
|
||||
await expect(page.getByText('Course statistics')).toBeVisible()
|
||||
await expect(page.getByText(name)).toBeVisible()
|
||||
|
||||
await selectYear(page, 'FromYearSelector', 'FromYearSelectorOption2017-2018')
|
||||
|
||||
const row = page.locator('tbody tr').first()
|
||||
await expect(row.locator('td')).toHaveText(
|
||||
[
|
||||
'Total',
|
||||
total,
|
||||
passed,
|
||||
failed,
|
||||
enrolledNoGrade,
|
||||
getPercentage(passed, total),
|
||||
getPercentage(failed + enrolledNoGrade, total),
|
||||
].map(String)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('in Course population', async ({ page }) => {
|
||||
for (const [
|
||||
_courseOrModule,
|
||||
code,
|
||||
groupId,
|
||||
name,
|
||||
total,
|
||||
passed,
|
||||
_notCompleted,
|
||||
failed,
|
||||
enrolledNoGrade,
|
||||
] of courseData) {
|
||||
await init(
|
||||
page,
|
||||
`/coursepopulation?from=${2017 - 1949}&to=${2023 - 1949}&courses=${groupId}&substitutions=false`,
|
||||
'basic'
|
||||
)
|
||||
await expect(page).toHaveURL(
|
||||
url =>
|
||||
url.pathname === '/coursepopulation' &&
|
||||
url.searchParams.get('courses') === groupId &&
|
||||
url.searchParams.get('substitutions') === 'false'
|
||||
)
|
||||
await expect(page.getByText(name)).toBeVisible()
|
||||
|
||||
await page.getByTestId('courseFilter-filter-card').click()
|
||||
await page.getByTestId('courseFilter-selector').click()
|
||||
await page
|
||||
.getByTestId('courseFilter-popper')
|
||||
.getByRole('option', { name: `${code} - ${name}` })
|
||||
.click()
|
||||
|
||||
for (const [status, expected] of [
|
||||
['All', total],
|
||||
['Passed', passed],
|
||||
['Failed', failed],
|
||||
['Enrolled, No Grade', enrolledNoGrade],
|
||||
] as const) {
|
||||
await selectCourseStatus(page, groupId, status)
|
||||
await expect(page.getByText(`Students (${expected})`)).toBeVisible()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Programme courses, Course statistics and Course population (no substitutions, single years, single course)', () => {
|
||||
test('in Programme courses', async ({ page }) => {
|
||||
await init(page, '/study-programme')
|
||||
await page.getByRole('link', { name: 'Matemaattisten tieteiden kandiohjelma' }).click()
|
||||
await page.getByTestId('ProgrammeCoursesTab').click()
|
||||
|
||||
for (const [year, total, passed, _failed, _enrolledNoGrade, notCompleted] of yearlyData) {
|
||||
await selectYear(page, 'to-year-select', `to-year-select-option-${year}`)
|
||||
await selectYear(page, 'from-year-select', `from-year-select-option-${year}`)
|
||||
|
||||
const row = page.locator('tbody tr').filter({ hasText: 'MAT21003' })
|
||||
await expect(row.locator('td').nth(3)).toContainText(String(total))
|
||||
await expect(row.locator('td').nth(4)).toContainText(String(passed))
|
||||
await expect(row.locator('td').nth(5)).toContainText(String(notCompleted))
|
||||
}
|
||||
})
|
||||
|
||||
test('in Course statistics', async ({ page }) => {
|
||||
for (const [year, total, passed, failed, enrolledNoGrade] of yearlyData) {
|
||||
await init(page, `/coursestatistics?courses=${MAT21003_GROUP_ID}&substitutions=false`, 'basic')
|
||||
await expect(page).toHaveURL(
|
||||
url =>
|
||||
url.pathname === '/coursestatistics' &&
|
||||
url.searchParams.get('courses') === MAT21003_GROUP_ID &&
|
||||
url.searchParams.get('substitutions') === 'false'
|
||||
)
|
||||
const yearString = `${year}-${year + 1}`
|
||||
|
||||
await selectYear(page, 'ToYearSelector', `ToYearSelectorOption${yearString}`)
|
||||
await selectYear(page, 'FromYearSelector', `FromYearSelectorOption${yearString}`)
|
||||
await expect(page.getByText('Student statistics')).toBeVisible()
|
||||
await expect(page.locator('tbody tr')).toHaveCount(2)
|
||||
|
||||
const row = page.locator('tbody tr').filter({ hasText: 'Total' })
|
||||
|
||||
await expect(row.locator('td')).toHaveText(
|
||||
[
|
||||
'Total',
|
||||
total,
|
||||
passed,
|
||||
failed,
|
||||
enrolledNoGrade,
|
||||
getPercentage(passed, total),
|
||||
getPercentage(failed + enrolledNoGrade, total),
|
||||
].map(String)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('in Course population', async ({ page }) => {
|
||||
for (const [year, _total, passed, failed, enrolledNoGrade] of yearlyData) {
|
||||
await init(
|
||||
page,
|
||||
`/coursepopulation?from=${year - 1949}&to=${year - 1949}&courses=${MAT21003_GROUP_ID}&substitutions=false`,
|
||||
'basic'
|
||||
)
|
||||
await expect(page).toHaveURL(
|
||||
url =>
|
||||
url.pathname === '/coursepopulation' &&
|
||||
url.searchParams.get('courses') === MAT21003_GROUP_ID &&
|
||||
url.searchParams.get('substitutions') === 'false'
|
||||
)
|
||||
await expect(page.getByText('Vektorianalyysi I')).toBeVisible()
|
||||
|
||||
await page.getByTestId('courseFilter-filter-card').click()
|
||||
await page.getByTestId('courseFilter-selector').click()
|
||||
await page
|
||||
.getByTestId('courseFilter-popper')
|
||||
.getByRole('option', { name: 'MAT21003 - Vektorianalyysi I' })
|
||||
.click()
|
||||
|
||||
for (const [status, expected] of [
|
||||
['All', _total],
|
||||
['Passed', passed],
|
||||
['Failed', failed],
|
||||
['Enrolled, No Grade', enrolledNoGrade],
|
||||
] as const) {
|
||||
await selectCourseStatus(page, MAT21003_GROUP_ID, status)
|
||||
await expect(page.getByText(`Students (${expected})`)).toBeVisible()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,493 +0,0 @@
|
||||
import { test, expect, Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
// Now "Class statistics" in UI
|
||||
const selectStudyProgramme = async (page: Page, programme: string) => {
|
||||
await expect(page.getByTestId('population-programme-selector').locator('input')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'Select degree programme'
|
||||
)
|
||||
await page.getByTestId('population-programme-selector-parent').click()
|
||||
await page.getByTestId('population-programme-selector-parent').getByText(programme).click()
|
||||
}
|
||||
|
||||
const selectStudyTrack = async (page: Page, studyTrack: string) => {
|
||||
await expect(page.getByTestId('population-studytrack-selector').locator('input')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'Select study track'
|
||||
)
|
||||
await page.getByTestId('population-studytrack-selector-parent').click()
|
||||
await page.getByTestId('population-studytrack-selector-parent').getByText(studyTrack).click()
|
||||
}
|
||||
|
||||
const getPath = (programme: string) => {
|
||||
return `/populations?months=49&semesters=FALL&semesters=SPRING&programme=${programme}&years=2020`
|
||||
}
|
||||
|
||||
const pathToMathBSc2020 = getPath('KH50_001')
|
||||
const pathToMathMSc2020 = getPath('MH50_001')
|
||||
|
||||
test.describe('Population statistics tests', () => {
|
||||
test.describe('When using basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/populations')
|
||||
})
|
||||
|
||||
test.describe('Population search', () => {
|
||||
test('Info box works', async ({ page }) => {
|
||||
await expect(page.getByTestId('PopulationSearch-info-box-content')).not.toBeVisible()
|
||||
await page.getByTestId('PopulationSearch-info-box-button').hover()
|
||||
await expect(page.getByTestId('PopulationSearch-info-box-content')).toBeVisible()
|
||||
|
||||
await expect(
|
||||
page.getByTestId('PopulationSearch-info-box-content').getByText('Tässä osiossa voi tarkastella')
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByTestId('PopulationSearch-info-box-button').dispatchEvent('mouseout')
|
||||
await expect(page.getByTestId('PopulationSearch-info-box-content')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Form is usable', async ({ page }) => {
|
||||
await expect(page.getByText('Search for class')).toBeVisible()
|
||||
await expect(page.getByText('See class')).toBeDisabled()
|
||||
|
||||
const yearSelect = page.getByTestId('population-year-selector')
|
||||
const yearDecrement = page.getByTestId('population-year-decrement')
|
||||
const yearIncrement = page.getByTestId('population-year-increment')
|
||||
|
||||
await yearSelect.click()
|
||||
await page.getByText('2018 - 2019').click()
|
||||
await expect(yearSelect.getByText('2018 - 2019')).toBeVisible()
|
||||
|
||||
await yearDecrement.click()
|
||||
await expect(yearSelect.getByText('2017 - 2018')).toBeVisible()
|
||||
|
||||
await yearIncrement.click()
|
||||
await expect(yearSelect.getByText('2018 - 2019')).toBeVisible()
|
||||
|
||||
await expect(page.getByTestId('population-studytrack-selector').locator('input')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'No study tracks available'
|
||||
)
|
||||
|
||||
await selectStudyProgramme(page, 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
await expect(page.getByText('See class')).toBeEnabled()
|
||||
|
||||
await selectStudyTrack(page, 'Matematiikka ja soveltava matematiikka')
|
||||
await expect(page.getByText('See class')).toBeEnabled()
|
||||
})
|
||||
|
||||
test.describe('Correct population is shown for programme', () => {
|
||||
test('without study tracks', async ({ page }) => {
|
||||
await selectStudyProgramme(page, 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
|
||||
// Select year 2020 - 2021
|
||||
const yearSelect = page.getByTestId('population-year-selector')
|
||||
await yearSelect.click()
|
||||
await page.getByText('2020 - 2021').click()
|
||||
|
||||
await page.getByText('See class').click()
|
||||
await expect(page.getByText('Matematiikan ja tilastotieteen maisteriohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2020 - 2021, 26 students')).toBeVisible()
|
||||
await expect(page.getByText('Showing 26 out of 26 students')).toBeVisible()
|
||||
|
||||
await expect(page.getByText('Studytrack MAST-MSM')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('with study tracks', async ({ page }) => {
|
||||
await selectStudyProgramme(page, 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
|
||||
// Select year 2020 - 2021
|
||||
const yearSelect = page.getByTestId('population-year-selector')
|
||||
await yearSelect.click()
|
||||
await page.getByText('2020 - 2021').click()
|
||||
|
||||
await selectStudyTrack(page, 'Matematiikka ja soveltava matematiikka')
|
||||
|
||||
await page.getByText('See class').click()
|
||||
await expect(page.getByText('Matematiikan ja tilastotieteen maisteriohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2020 - 2021, 26 students')).toBeVisible()
|
||||
await expect(page.getByText('Showing 15 out of 26 students')).toBeVisible()
|
||||
|
||||
await expect(page.getByText('Studytrack MAST-MSM')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Population statistics is usable on general level', async ({ page }) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
await expect(page.getByTestId('filtered-students')).toBeVisible()
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2020 - 2021, 30 students')).toBeVisible()
|
||||
await expect(page.getByText('Showing 27 out of 30 students')).toBeVisible()
|
||||
|
||||
await expect(page.getByText('Excludes exchange students')).toBeVisible()
|
||||
await expect(page.getByText('Excludes students with non-degree study right')).toBeVisible()
|
||||
await expect(page.getByText('Excludes students who have transferred out of this programme')).toBeVisible()
|
||||
await expect(page.getByTestId('filtered-students')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Advanced settings work', async ({ page }) => {
|
||||
await page.goto(pathToMathMSc2020)
|
||||
|
||||
await expect(page.getByText('Credit accumulation (for 26 students)')).toBeVisible()
|
||||
await expect(page).toHaveURL(/.*semesters=FALL&semesters=SPRING.*/)
|
||||
|
||||
await page.getByTestId('advanced-toggle').click()
|
||||
|
||||
// only spring
|
||||
await page.getByTestId('toggle-fall').click()
|
||||
await page.getByText('Fetch class').click()
|
||||
|
||||
await expect(page.getByText('Credit accumulation (for 17 students)')).toBeVisible()
|
||||
await expect(page).not.toHaveURL(/.*semesters=FALL.*/)
|
||||
|
||||
await page.getByTestId('advanced-toggle').click()
|
||||
await page.getByTestId('toggle-fall').click()
|
||||
await page.getByTestId('toggle-spring').click()
|
||||
await page.getByText('Fetch class').click()
|
||||
|
||||
await expect(page.getByText('Credit accumulation (for 9 students)')).toBeVisible()
|
||||
await expect(page).not.toHaveURL(/.*semesters=SPRING.*/)
|
||||
|
||||
await page.getByTestId('advanced-toggle').click()
|
||||
await page.getByTestId('toggle-spring').click()
|
||||
await page.getByText('Fetch class').click()
|
||||
|
||||
await expect(page.getByText('Credit accumulation (for 26 students)')).toBeVisible()
|
||||
await expect(page).toHaveURL(/.*semesters=FALL&semesters=SPRING.*/)
|
||||
})
|
||||
|
||||
test.describe('Credit statistics', () => {
|
||||
test("'Credits gained' tab shows correct statistics for all students of the class and also students grouped by admission type", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
const totalStudents = 27
|
||||
|
||||
await expect(page.getByText('Credit statistics')).toBeVisible()
|
||||
await page.getByText('Credit statistics').click()
|
||||
|
||||
await expect(page.getByTestId('credits-gained-tab')).toBeVisible()
|
||||
await page.getByTestId('credits-gained-tab').click()
|
||||
|
||||
const limits = [1, 45, 90, 135, 180, null]
|
||||
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
|
||||
|
||||
const getTableData = (selector: string, numbersOfStudents: number[]) => {
|
||||
const studentsInCategory = numbersOfStudents.reduce((acc, val) => acc + val, 0)
|
||||
return ranges.map((range, index) => ({
|
||||
selector: `credits-gained-table-${selector}`,
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
students: numbersOfStudents[index],
|
||||
percentage: `${((numbersOfStudents[index] / studentsInCategory) * 100).toFixed(1)}%`,
|
||||
}))
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('credits-gained-table-All students of the class')).toBeVisible()
|
||||
|
||||
await expect(page.getByTestId('credits-gained-table-All students of the class').locator('th')).toHaveCount(4)
|
||||
await expect(page.getByTestId('credits-gained-table-All students of the class').locator('th')).toContainText([
|
||||
'',
|
||||
'Credits gained between 01.08.2020 and ', // Current date
|
||||
`Number of students` + `(n = ${totalStudents})`,
|
||||
'Percentage of population',
|
||||
])
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
getTableData('All students of the class', [9, 5, 7, 4, 2, 0]),
|
||||
getTableData('Avoin väylä', [0, 1, 3, 2, 0, 0]),
|
||||
getTableData('Muu', [0, 1, 1, 1, 0, 0]),
|
||||
getTableData('Todistusvalinta', [9, 3, 3, 1, 2, 0]),
|
||||
].map(category =>
|
||||
Promise.all(
|
||||
category.map(async ({ selector, start, end, students, percentage }, index) => {
|
||||
let value
|
||||
if (start === null) {
|
||||
value = '0 credits'
|
||||
} else if (end === null) {
|
||||
value = `${start} ≤ credits`
|
||||
} else {
|
||||
value = `${start} ≤ credits < ${end}`
|
||||
}
|
||||
const bodyLocator = page.getByTestId(selector).getByTestId('credits-gained-table-body')
|
||||
await expect(bodyLocator).toBeVisible()
|
||||
const row = bodyLocator.locator('tr').nth(index)
|
||||
await expect(row.locator('td')).toHaveCount(4)
|
||||
await expect(row.locator('td')).toHaveText(['' /* Icon */, value, students.toString(), percentage])
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const numberOfStudentCounts = await page
|
||||
.getByTestId('credits-gained-table-All students of the class')
|
||||
.getByTestId('credits-gained-table-body')
|
||||
.locator('td:nth-child(3)')
|
||||
.allTextContents()
|
||||
const sum = numberOfStudentCounts.reduce((acc, t) => acc + parseInt(t, 10), 0)
|
||||
expect(sum).toBe(totalStudents)
|
||||
})
|
||||
|
||||
test("'Statistics' tab shows correct statistics for all students of the class and also students grouped by admission type", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
await page.getByText('Credit statistics').click()
|
||||
|
||||
await page.getByTestId('credit-statistics-tab').click()
|
||||
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
|
||||
const categories = [
|
||||
{
|
||||
selector: 'All students of the population',
|
||||
data: ['6136.50', '227.28', '197.00', '117.94', '60', '533'],
|
||||
size: 27,
|
||||
},
|
||||
{
|
||||
selector: 'Muu',
|
||||
data: ['580.00', '193.33', '192.00', '36.75', '149', '239'],
|
||||
size: 3,
|
||||
},
|
||||
{
|
||||
selector: 'Todistusvalinta',
|
||||
data: ['3657.50', '203.19', '193.50', '98.35', '60', '460'],
|
||||
size: 18,
|
||||
},
|
||||
{
|
||||
selector: 'Avoin väylä',
|
||||
data: ['1899.00', '316.50', '257.00', '150.43', '150', '533'],
|
||||
size: 6,
|
||||
},
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
categories.map(async ({ selector, data, size }) => {
|
||||
const tableLocator = page.getByTestId(`statistics-table-${selector}`)
|
||||
await expect(tableLocator).toBeVisible()
|
||||
await expect(tableLocator.getByRole('heading', { level: 5, name: selector })).toBeVisible()
|
||||
|
||||
await expect(tableLocator.getByTestId('credit-stats-population-size')).toContainText(`n = ${size}`)
|
||||
|
||||
await Promise.all(
|
||||
rows.map((_, index) => {
|
||||
const row = tableLocator.locator('table tbody tr').nth(index)
|
||||
return expect(row.locator('td')).toHaveText([rows[index], data[index]])
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Courses of class', () => {
|
||||
test('Is displayed and link to individual course stats page works and has substitutions enabled', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
await page.getByText('Courses of class').click()
|
||||
await page.getByTestId('toggle-group-module-MAT110').click()
|
||||
|
||||
await page.getByRole('row', { name: 'MAT11001' }).getByRole('link').click()
|
||||
await expect(page).toHaveURL(/.*coursestatistics.*/)
|
||||
await expect(page.getByText('MAT11001', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('AYMAT11001')).toBeVisible()
|
||||
await expect(page.getByText('57033', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('A57033')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Curriculum selection works', async ({ page }) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
await page.getByText('Courses of class').click()
|
||||
|
||||
await expect(page.getByTestId('curriculum-picker')).toContainText('2020–2023')
|
||||
await expect(page.getByTestId('toggle-group-module-MAT-tyo')).toBeVisible()
|
||||
|
||||
await page.getByTestId('curriculum-picker').click()
|
||||
await page.getByText('2023–2026').click()
|
||||
await expect(page.getByTestId('toggle-group-module-MAT-tyo')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Courses data is changed when curriculum is changed', async ({ page }) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
await page.getByText('Courses of class').click()
|
||||
|
||||
const curriculumPicker = page.getByTestId('curriculum-picker')
|
||||
await expect(curriculumPicker.getByText('2020–2023')).toBeVisible()
|
||||
|
||||
await expect(page.getByText('Students (27)')).toBeVisible()
|
||||
|
||||
await expect(page.getByTestId('toggle-group-module-DIGI-k')).toBeVisible()
|
||||
await page.getByTestId('toggle-group-module-DIGI-k').click()
|
||||
|
||||
await expect(page.getByText('DIGI-100')).toBeVisible()
|
||||
await expect(page.getByTestId('toggle-group-module-DIGI-k')).toBeVisible()
|
||||
|
||||
await curriculumPicker.click()
|
||||
await page.getByText('2023–2026').click()
|
||||
|
||||
await expect(page.getByTestId('toggle-group-module-DIGI-k')).toBeVisible()
|
||||
await expect(page.getByText('DIGI-100')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Courses data is changed when filtered students change', async ({ page }) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
const filterCard = page.getByTestId('graduatedFromProgrammeFilter-filter-card')
|
||||
await filterCard.getByTestId('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
await page.getByRole('radio', { name: 'Graduated', exact: true }).click()
|
||||
await expect(page.getByRole('radio', { name: 'Graduated', exact: true })).toBeChecked()
|
||||
await expect(page.getByText('Students (16)')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Students', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(pathToMathBSc2020)
|
||||
const studentsHeading = page.getByText('Students (27)')
|
||||
const parentHasActive = await studentsHeading.evaluate(el => el.parentElement?.classList.contains('active'))
|
||||
if (!parentHasActive) await page.getByText('Students (27)').click()
|
||||
})
|
||||
|
||||
test("'General tab' is usable", async ({ page }) => {
|
||||
const general = page.getByTestId('ooditable-general')
|
||||
await expect(general).toBeVisible()
|
||||
|
||||
const row = general.locator('tbody').locator('tr').filter({ hasText: '489963' })
|
||||
await expect(row.locator('td')).toHaveCount(24)
|
||||
await expect(row.locator('td')).toHaveText(
|
||||
[
|
||||
'489963' + 'Sisu',
|
||||
'Graduated',
|
||||
241,
|
||||
0,
|
||||
169,
|
||||
0,
|
||||
0,
|
||||
2020,
|
||||
'2020-08-01',
|
||||
'2020-08-01',
|
||||
'2022-06-13',
|
||||
23,
|
||||
'',
|
||||
'Matematiikka',
|
||||
'Matematiikan ja tilastotieteen maisteriohjelma',
|
||||
'Matematiikan ja tilastotieteen maisteriohjelma',
|
||||
'',
|
||||
'Avoin väylä',
|
||||
'Male',
|
||||
'Suomi',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
].map(n => n.toString())
|
||||
)
|
||||
})
|
||||
|
||||
test("'Courses tab' is usable", async ({ page }) => {
|
||||
await page.getByTestId('student-table-tabs').getByText('Courses').click()
|
||||
const courses = page.getByTestId('ooditable-courses')
|
||||
await expect(courses.getByText('MAT11001')).toBeVisible()
|
||||
await expect(courses.getByText('MAT11004')).toBeVisible()
|
||||
})
|
||||
|
||||
test("'Modules tab' Displays correct modules based on the selected programme", async ({ page }) => {
|
||||
await page.getByTestId('student-table-tabs').getByText('Modules').click()
|
||||
const modules = page.getByTestId('ooditable-modules')
|
||||
|
||||
await expect(modules.getByText('MAT011')).toBeVisible()
|
||||
await expect(modules.getByText('MAT110')).toBeVisible()
|
||||
|
||||
await page.getByText('Courses of class').click()
|
||||
await page.getByTestId('curriculum-picker').click()
|
||||
await page.getByText('2023–2026').click()
|
||||
await page.getByText('Courses of class').click()
|
||||
|
||||
await expect(page.getByTestId('ooditable-modules').getByText('MAT011')).not.toBeVisible()
|
||||
await expect(page.getByTestId('ooditable-modules').getByText('MAT110')).toBeVisible()
|
||||
})
|
||||
|
||||
test("Empty 'tags' tab has a link to the page where tags can be created", async ({ page }) => {
|
||||
await page.getByTestId('student-table-tabs').getByText('Tags').click()
|
||||
await expect(page.getByText('No tags defined. You can define them')).toBeVisible()
|
||||
await page.getByRole('link', { name: 'here' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/.*study-programme\/KH50_001\?tab=4.*/)
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Create new tag')).toBeVisible()
|
||||
await expect(page.getByTestId('create-button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('When using admin', () => {
|
||||
test('Student list checking works as intended', async ({ page }) => {
|
||||
await init(page, pathToMathBSc2020, 'admin')
|
||||
const existing = '433237'
|
||||
const nonExisting = '550004'
|
||||
await expect(page.getByText('Students (27)')).toBeVisible()
|
||||
await page.getByText('Students (27)').click()
|
||||
|
||||
await expect(page.getByText(existing)).toBeVisible()
|
||||
await expect(page.getByText(nonExisting)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Check student numbers' }).click()
|
||||
await expect(page.getByText('Check for student numbers')).toBeVisible()
|
||||
await page.getByTestId('check-student-numbers').getByRole('textbox').fill(`${existing}\n${nonExisting}`)
|
||||
await page.getByRole('button', { name: 'Check students' }).click()
|
||||
|
||||
const results = page.locator('#checkstudentsresults')
|
||||
await results.getByTestId('found-title').click()
|
||||
await expect(results.getByTestId('found-data').getByText(existing)).toBeVisible()
|
||||
|
||||
await results.getByTestId('not-found-title').click()
|
||||
await expect(results.getByTestId('not-found-data').getByText(nonExisting)).toBeVisible()
|
||||
|
||||
await results.getByTestId('not-searched-title').click()
|
||||
await expect(results.getByTestId('not-searched-data').getByText('457144')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Close' }).click()
|
||||
await expect(page.getByText('Student numbers in list and in Sisu')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('When using IAM user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, pathToMathBSc2020, 'onlyiamrights')
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma')).toBeVisible()
|
||||
await expect(page.getByText('Class of 2020 - 2021, 30 students')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Population statistics is visible', async ({ page }) => {
|
||||
const card = page.getByTestId('PopulationQueryCard')
|
||||
await expect(card.getByText('Excludes exchange students')).toBeVisible()
|
||||
await expect(card.getByText('Excludes students with non-degree study right')).toBeVisible()
|
||||
await expect(card.getByText('Excludes students who have transferred out of this programme')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Only correct panels are visible', async ({ page }) => {
|
||||
const panelParent = page.getByTestId('panelview-parent')
|
||||
await expect(panelParent.getByText('Credit accumulation (for 27 students)')).toBeVisible()
|
||||
await expect(panelParent.getByText('Credit statistics')).toBeVisible()
|
||||
await expect(panelParent.getByText('Age distribution')).toBeVisible()
|
||||
await expect(panelParent.getByText('Courses of class')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ages cannot be ungrouped', async ({ page }) => {
|
||||
await expect(page.getByText('Age distribution')).toBeVisible()
|
||||
await page.getByText('Age distribution').click()
|
||||
await expect(page.getByText('Group ages')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Age filter is not visible', async ({ page }) => {
|
||||
await expect(page.locator("[data-cy='filtered-students']")).toBeVisible()
|
||||
await expect(page.locator("[data-cy='Age-filter-card']")).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Students tab is not available', async ({ page }) => {
|
||||
await expect(page.getByText('Students (27)')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { userHeaders } from './support/commands'
|
||||
|
||||
type RouteRequirements = {
|
||||
userId?: string
|
||||
roles?: string[]
|
||||
requireProgrammeRights?: boolean
|
||||
programmeRights?: string[]
|
||||
iamGroups?: string[]
|
||||
}
|
||||
|
||||
type UserInfo = {
|
||||
userId?: string
|
||||
roles?: string[]
|
||||
programmeRights?: unknown[]
|
||||
iamGroups?: string[]
|
||||
}
|
||||
|
||||
const baseRoutes: [string, RouteRequirements][] = [
|
||||
['/', {}],
|
||||
['/changelog', {}],
|
||||
['/completedcoursessearch', {}],
|
||||
['/university', {}],
|
||||
['/feedback', {}],
|
||||
['/populations', { roles: ['fullSisuAccess'], programmeRights: [] }],
|
||||
['/study-programme', { roles: ['fullSisuAccess'], programmeRights: [] }],
|
||||
// TODO: This should probably be allowed for user with programmeRight, not only fullSisuAccess
|
||||
['/study-programme/KH50_001', { roles: ['fullSisuAccess'], programmeRights: [] }],
|
||||
['/coursepopulation', { roles: ['fullSisuAccess'], programmeRights: [] }],
|
||||
['/users', { roles: ['admin'] }],
|
||||
['/users/334', { roles: ['admin'] }],
|
||||
['/updater', { roles: ['admin'] }],
|
||||
['/coursestatistics', { roles: ['fullSisuAccess', 'courseStatistics'], programmeRights: [] }],
|
||||
['/faculties', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
|
||||
['/faculties/hy-org-1000000911', { roles: ['fullSisuAccess', 'facultyStatistics'] }],
|
||||
['/students', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
|
||||
['/students/545400', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
|
||||
['/custompopulation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'], programmeRights: [] }],
|
||||
['/teachers', { roles: ['teachers'] }],
|
||||
['/teachers/hy-hlo-1463447', { roles: ['teachers'] }],
|
||||
['/studyguidancegroups', { roles: ['studyGuidanceGroups'] }],
|
||||
['/studyguidancegroups/otm-b3b52ff5-88f6-4d52-bd18-0588023b6322', { userId: '', roles: ['studyGuidanceGroups'] }],
|
||||
['/close-to-graduation', { roles: ['fullSisuAccess', 'studyGuidanceGroups'] }],
|
||||
['/languagecenterview', { iamGroups: ['grp-kielikeskus-esihenkilot'] }],
|
||||
]
|
||||
|
||||
for (const user of userHeaders) {
|
||||
if (user.uid === 'admin') continue
|
||||
test.describe(`${user.displayname} has access only to views that were intended`, () => {
|
||||
let userInfo: UserInfo | undefined
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const response = await request.get('/api/login', { headers: user })
|
||||
userInfo = (await response.json())?.user
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
expect(userInfo).toBeDefined()
|
||||
|
||||
// Inject user headers into all API calls and fail if any of them results in a server error
|
||||
await page.route('**/api/**', async route => {
|
||||
const response = await route.fetch({
|
||||
headers: {
|
||||
...route.request().headers(),
|
||||
...user,
|
||||
},
|
||||
})
|
||||
expect(response.status(), `${route.request().method()} ${route.request().url()}`).toBeLessThan(500)
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' })
|
||||
})
|
||||
|
||||
for (const [route, requirements] of baseRoutes) {
|
||||
test(`Checking access ${route}`, async ({ page }) => {
|
||||
// TODO: JAMI mock needed to make this test deterministic
|
||||
test.skip(user.uid === 'onlyiamrights' && route === '/coursestatistics')
|
||||
|
||||
await page.goto(route)
|
||||
|
||||
const { userId, roles = [], programmeRights = [], iamGroups = [] } = userInfo ?? {}
|
||||
const hasProgrammeRights = programmeRights.length > 0
|
||||
|
||||
const requiredUserId = !requirements.userId || requirements.userId === userId
|
||||
const requiredRoles = !requirements.roles || requirements.roles.some(role => roles.includes(role))
|
||||
const requiredIamGroups =
|
||||
!requirements.iamGroups || requirements.iamGroups.some(group => iamGroups.includes(group))
|
||||
let requiredRights = !requirements.requireProgrammeRights || hasProgrammeRights
|
||||
|
||||
if (requirements.programmeRights) {
|
||||
requiredRights =
|
||||
requirements.programmeRights.length === 0
|
||||
? hasProgrammeRights
|
||||
: roles.includes('fullSisuAccess') ||
|
||||
requirements.programmeRights.every(right => programmeRights.includes(right))
|
||||
}
|
||||
|
||||
// This is defined in frontend ProtectedRoute component.
|
||||
const specialNeedsRoutes = [
|
||||
'populations',
|
||||
'study-programme',
|
||||
'coursepopulation',
|
||||
'coursestatistics',
|
||||
'students',
|
||||
'custompopulation',
|
||||
]
|
||||
|
||||
const mankeli = specialNeedsRoutes.some(path => route.includes(path))
|
||||
? requiredRoles || requiredRights
|
||||
: requiredRoles && requiredRights
|
||||
|
||||
const shouldHaveAccess = mankeli && requiredUserId && requiredIamGroups
|
||||
|
||||
if (shouldHaveAccess) {
|
||||
await expect(page.getByRole('heading', { name: 'Something broke' })).not.toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Access denied' })).not.toBeVisible()
|
||||
} else {
|
||||
await expect(page.getByRole('heading', { name: 'Access denied' })).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const student = {
|
||||
firstNames: 'Varpu Roope',
|
||||
lastName: 'Mårtensson',
|
||||
studentNumber: '550003',
|
||||
sisPersonId: 'hy-hlo-115926826',
|
||||
email: 'sisutestidata134902@testisisudata.fi',
|
||||
}
|
||||
|
||||
const searchFor = async (page: Page, value: string) => {
|
||||
await page.getByTestId('student-search').getByRole('textbox').fill(value)
|
||||
}
|
||||
|
||||
const openStudent = async (page: Page, studentNumber: string) => {
|
||||
await searchFor(page, studentNumber)
|
||||
await page.getByRole('cell', { name: studentNumber }).click()
|
||||
}
|
||||
|
||||
const expectNamesHidden = async (page: Page) => {
|
||||
await expect(page.getByText(student.firstNames)).toHaveCount(0)
|
||||
await expect(page.getByText(student.lastName)).toHaveCount(0)
|
||||
}
|
||||
|
||||
test.describe('Students tests', () => {
|
||||
test.describe('When using basic user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/students')
|
||||
await expect(page).toHaveURL(/\/students/)
|
||||
await expect(page.getByText('Show student names')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Students search form is usable', async ({ page }) => {
|
||||
await searchFor(page, student.lastName)
|
||||
await expect(page.getByText('Student number')).toBeVisible()
|
||||
await expect(page.getByText('Last name')).toHaveCount(0)
|
||||
await expect(page.getByText('First names')).toHaveCount(0)
|
||||
await expect(page.getByText('Started')).toBeVisible()
|
||||
await expect(page.getByText('Credits')).toBeVisible()
|
||||
await expect(page.getByText('Active study rights')).toBeVisible()
|
||||
await expectNamesHidden(page)
|
||||
|
||||
await page.getByTestId('toggleStudentNames').click()
|
||||
await expect(page.getByText(student.firstNames)).toBeVisible()
|
||||
await expect(page.getByText('Last name')).toBeVisible()
|
||||
await expect(page.getByText('First names')).toBeVisible()
|
||||
await expect(page.getByText(student.lastName)).toBeVisible()
|
||||
|
||||
await page.getByTestId('toggleStudentNames').click()
|
||||
await expectNamesHidden(page)
|
||||
})
|
||||
|
||||
test('Search term must be at least 4 characters long', async ({ page }) => {
|
||||
await searchFor(page, student.lastName.slice(0, 3))
|
||||
await expect(page.getByText('Search term is not accurate enough')).toBeVisible()
|
||||
await page.getByTestId('student-search').getByRole('textbox').pressSequentially(student.lastName.slice(3)) // Appends to input
|
||||
await expect(page.locator('table tbody tr')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can search with student number too', async ({ page }) => {
|
||||
await searchFor(page, student.studentNumber)
|
||||
await expect(page.getByText(student.studentNumber)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can get student specific page by clicking student', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await expect(page.getByText('Matemaattisten tieteiden kandiohjelma (01.08.2020–31.07.2027)')).toBeVisible()
|
||||
await expectNamesHidden(page)
|
||||
await expect(page.getByText(student.email, { exact: true })).toHaveCount(0)
|
||||
|
||||
await page.getByTestId('toggleStudentNames').click()
|
||||
await expect(page.getByText(student.lastName)).toBeVisible()
|
||||
await expect(page.getByText(student.firstNames)).toBeVisible()
|
||||
await expect(page.getByText(student.email)).toBeVisible()
|
||||
await page.getByTestId('toggleStudentNames').click()
|
||||
await expectNamesHidden(page)
|
||||
await expect(page.getByText(student.email)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("'Update student' button is not shown", async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await expect(page.getByTestId('student-info-card').getByRole('button', { name: 'Update student' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Can get back to search menu', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await page.goBack()
|
||||
await expect(page.getByText('Student number')).toHaveCount(0)
|
||||
await expect(page.getByText('Credits')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Can jump to course', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
const courseRow = page.locator('tr').filter({ hasText: 'Tilastollinen päättely I (MAT12004)' })
|
||||
await courseRow.locator('a').click()
|
||||
await expect(page).toHaveURL(/\/coursestatistics/)
|
||||
await expect(page.getByText('MAT12004', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('AYMAT12004')).toBeVisible()
|
||||
await expect(page.getByText('57046', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Has correct Sisu link', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await expect(page.getByTestId('sisu-link')).toHaveAttribute(
|
||||
'href',
|
||||
`https://sisu.helsinki.fi/tutor/role/staff/student/${student.sisPersonId}/basic/basic-info`
|
||||
)
|
||||
})
|
||||
|
||||
test('Semester enrollments can be toggled', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
const programmes = [
|
||||
'Oikeustieteen tohtoriohjelma',
|
||||
'Matemaattisten tieteiden kandiohjelma',
|
||||
'Oikeustieteen maisterin koulutusohjelmaOikeusnotaarin koulutusohjelma',
|
||||
]
|
||||
const card = page.getByTestId('student-info-card')
|
||||
await card.getByText('Enrollments').click()
|
||||
await expect(card.locator('table tbody tr')).toHaveCount(3)
|
||||
await expect(card.locator('table tbody tr td:first-child')).toHaveText(programmes)
|
||||
})
|
||||
|
||||
test("Searching with bad name doesn't yield results", async ({ page }) => {
|
||||
await searchFor(page, 'SWAG LITTINEN')
|
||||
await expect(page.getByText('Student number')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("Searching with bad student number doesn't yield results", async ({ page }) => {
|
||||
await searchFor(page, '01114')
|
||||
await expect(page.getByText('Student number')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Can jump to population page', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await page
|
||||
.getByTestId('study-rights-section')
|
||||
.getByText('Matemaattisten tieteiden kandiohjelma')
|
||||
.getByRole('link')
|
||||
.click()
|
||||
await expect(page.getByText('Class of 2020 - 2021, 30 students')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Grade graph works in all three different modes', async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await page.getByRole('tab', { name: 'Grade graph' }).click()
|
||||
await page.getByRole('button', { name: 'Show group mean' }).click()
|
||||
|
||||
const groupSize = page.getByTestId('group-size-input')
|
||||
await expect(groupSize.getByLabel('Group size')).toBeVisible()
|
||||
await expect(groupSize.locator('input')).toHaveValue('5')
|
||||
|
||||
await groupSize.locator('input').fill('10')
|
||||
await expect(groupSize.locator('input')).toHaveValue('10')
|
||||
await page.getByRole('button', { name: 'Show semester mean' }).click()
|
||||
await expect(groupSize.getByLabel('Group size')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Bachelor Honours section', () => {
|
||||
test('Shows qualified student details', async ({ page }) => {
|
||||
await page.goto('/students/495976')
|
||||
await expect(page.getByRole('heading', { name: 'Bachelor Honours' })).toBeVisible()
|
||||
await expect(page.getByTestId('honours-chip-qualified')).toContainText('Qualified for Honours')
|
||||
await page.getByText('Study modules').click()
|
||||
|
||||
const rows = page.getByTestId('main-modules').locator('tbody tr')
|
||||
await expect(rows).toHaveCount(3)
|
||||
await expect(
|
||||
rows.filter({ hasText: 'Matemaattisten tieteiden kandiohjelma (KH50_001)' }).locator('td')
|
||||
).toHaveText(['07.05.2020', 'Matemaattisten tieteiden kandiohjelma (KH50_001)', 'Hyv.'])
|
||||
await expect(rows.filter({ hasText: 'Matematiikka, perusopinnot (MAT110)' }).locator('td')).toHaveText([
|
||||
'08.05.2018',
|
||||
'Matematiikka, perusopinnot (MAT110)',
|
||||
'4',
|
||||
])
|
||||
await expect(rows.filter({ hasText: 'Matematiikka, aineopinnot (MAT210)' }).locator('td')).toHaveText([
|
||||
'25.02.2020',
|
||||
'Matematiikka, aineopinnot (MAT210)',
|
||||
'4',
|
||||
])
|
||||
})
|
||||
|
||||
test('Shows Did not graduate in time', async ({ page }) => {
|
||||
await page.goto('/students/540355')
|
||||
await expect(page.getByTestId('honours-chip-not-qualified')).toHaveText('Not qualified for Honours')
|
||||
await expect(page.getByTestId('honours-chip-error')).toHaveText('Did not graduate in time')
|
||||
})
|
||||
|
||||
test('Shows Module grades too low', async ({ page }) => {
|
||||
await page.goto('/students/547934')
|
||||
await expect(page.getByTestId('honours-chip-not-qualified')).toHaveText('Not qualified for Honours')
|
||||
await expect(page.getByTestId('honours-chip-error')).toHaveText('Module grades too low')
|
||||
})
|
||||
|
||||
test('Shows Might need further inspection', async ({ page }) => {
|
||||
await page.goto('/students/478837')
|
||||
await expect(page.getByTestId('honours-chip-inspection')).toHaveText('Might need further inspection')
|
||||
})
|
||||
|
||||
test('Shows Has not graduated', async ({ page }) => {
|
||||
await page.goto(`/students/${student.studentNumber}`)
|
||||
await expect(page.getByTestId('honours-chip-not-qualified')).toHaveText('Not qualified for Honours')
|
||||
await expect(page.getByTestId('honours-chip-error')).toHaveText('Has not graduated')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('When using admin user', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/students', 'admin')
|
||||
})
|
||||
|
||||
test('Does not crash if student has no study rights or courses', async ({ page }) => {
|
||||
await openStudent(page, '450730')
|
||||
await expect(page.getByText('Credits: 0')).toBeVisible()
|
||||
})
|
||||
|
||||
test("'Update student' button is shown", async ({ page }) => {
|
||||
await openStudent(page, student.studentNumber)
|
||||
await expect(page.getByTestId('student-info-card').getByRole('button', { name: 'Update student' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bachelor Honours section is not shown outside Faculty of Science', async ({ page }) => {
|
||||
await page.goto('/students/453146')
|
||||
await expect(page.getByRole('heading', { name: 'Bachelor Honours' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Study plan courses are highlighted with a blue background', async ({ page }) => {
|
||||
await page.goto('/students/550789')
|
||||
const studyPlan = page.getByTestId('KH40_005-0')
|
||||
await expect(studyPlan).toContainText('Kulttuurien tutkimuksen kandiohjelma')
|
||||
await studyPlan.locator('td').filter({ hasText: 'Kulttuurien tutkimuksen kandiohjelma' }).click()
|
||||
await expect(page.locator('table tbody tr').filter({ hasText: 'Kandidaatintutkielma (KUKA-LIS222)' })).toHaveCSS(
|
||||
'background-color',
|
||||
'rgb(232, 244, 255)'
|
||||
)
|
||||
})
|
||||
|
||||
test('Completed credits are displayed for a matching study plan', async ({ page }) => {
|
||||
await page.goto('/students/550789')
|
||||
const studyPlan = page.getByTestId('KH40_005-0')
|
||||
await expect(studyPlan).toContainText('Kulttuurien tutkimuksen kandiohjelma')
|
||||
await expect(studyPlan).toContainText('185 cr')
|
||||
})
|
||||
|
||||
test('Completion percentage is displayed for an unfinished matching study plan', async ({ page }) => {
|
||||
await page.goto('/students/458723')
|
||||
const studyPlan = page.getByTestId('KH20_001-0')
|
||||
await expect(studyPlan).toContainText('Oikeusnotaarin koulutusohjelma')
|
||||
await expect(studyPlan).toContainText('69% (125 cr)')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,193 +0,0 @@
|
||||
import { expect, Page, test } from '@playwright/test'
|
||||
import { init, type UserId } from './support/commands'
|
||||
|
||||
const studyGuidanceGroups = [
|
||||
{
|
||||
id: 'sgg-cypress-1',
|
||||
name: { fi: 'MAT kandit 2020' },
|
||||
members: [{ personStudentNumber: '433237' }, { personStudentNumber: '457144' }, { personStudentNumber: '458090' }],
|
||||
tags: { studyProgramme: 'KH50_001', year: '2020' },
|
||||
},
|
||||
{
|
||||
id: 'sgg-cypress-2',
|
||||
name: { fi: 'Oma ohjausryhmä' },
|
||||
members: [{ personStudentNumber: '474270' }, { personStudentNumber: '479440' }],
|
||||
tags: { studyProgramme: null, year: null },
|
||||
},
|
||||
]
|
||||
|
||||
const initWithGroups = async (page: Page, path: string, userId: UserId = 'onlystudyguidancegroups') => {
|
||||
await init(page, path, userId)
|
||||
await page.route('**/api/studyguidancegroups', async route => await route.fulfill({ json: studyGuidanceGroups }))
|
||||
await page.reload()
|
||||
}
|
||||
|
||||
test.describe('Study guidance group tests', () => {
|
||||
test.describe('Study guidance group overview page', () => {
|
||||
test('without study guidance groups shows the correct notification', async ({ page }) => {
|
||||
await init(page, '/studyguidancegroups', 'admin')
|
||||
await expect(page.getByRole('heading', { name: 'Study guidance groups' })).toBeVisible()
|
||||
await expect(page.getByText('You do not have access to any study guidance groups.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('without correct rights shows the correct notification', async ({ page }) => {
|
||||
await init(page, '/studyguidancegroups', 'basic')
|
||||
await expect(page.getByRole('heading', { name: 'Access denied' })).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(
|
||||
"You don't currently have permission to view this page. If you believe this is a mistake, please contact oodikone@helsinki.fi."
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('with study guidance groups', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await initWithGroups(page, '/studyguidancegroups')
|
||||
await expect(page.getByRole('heading', { name: 'Study guidance groups' })).toBeVisible()
|
||||
await expect(page.getByText('You do not have access to any study guidance groups')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('shows the correct data', async ({ page }) => {
|
||||
const row = page.getByTestId('study-guidance-group-overview-data-table').locator('tr')
|
||||
|
||||
// NOTE: Only MAT kandit 2020 can be edited
|
||||
await expect(row.filter({ hasText: 'MAT kandit 2020' }).locator('td')).toHaveText([
|
||||
'MAT kandit 2020',
|
||||
'3',
|
||||
'Matemaattisten tieteiden kandiohjelma' + 'Edit',
|
||||
'2020 - 2021' + 'Edit',
|
||||
])
|
||||
await expect(row.filter({ hasText: 'Oma ohjausryhmä' }).locator('td')).toHaveText([
|
||||
'Oma ohjausryhmä',
|
||||
'2',
|
||||
'Add degree programme',
|
||||
'Add starting year',
|
||||
])
|
||||
})
|
||||
|
||||
test('has working links to single study guidance groups', async ({ page }) => {
|
||||
await page.getByTestId('study-guidance-group-link-sgg-cypress-1').click()
|
||||
await expect(page).toHaveURL('/studyguidancegroups/sgg-cypress-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Page for single study guidance group', () => {
|
||||
test.describe('without associated degree programme and year', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await initWithGroups(page, '/studyguidancegroups/sgg-cypress-2')
|
||||
})
|
||||
|
||||
test('shows the correct panes', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'Oma ohjausryhmä' })).toBeVisible()
|
||||
await expect(page.getByTestId('Credit accumulation (for 2 students)')).toBeVisible()
|
||||
await expect(page.getByTestId('Age distribution')).toBeVisible()
|
||||
await expect(page.getByTestId('Courses of population')).toBeVisible()
|
||||
await expect(page.getByText('Students (2)')).toBeVisible()
|
||||
await expect(page.getByTestId('Credit statistics')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('students table has the correct tabs', async ({ page }) => {
|
||||
await page.getByText('Students (2)').click()
|
||||
await expect(page.getByTestId('student-table-tabs').getByRole('tab', { name: 'General' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('general tab of the students table has the correct columns', async ({ page }) => {
|
||||
const columns = [
|
||||
'Student number',
|
||||
'Credits',
|
||||
'Start date',
|
||||
'Degree programmes',
|
||||
'TVEX',
|
||||
'Tags',
|
||||
'Total',
|
||||
'Since 1.1.1970',
|
||||
'University',
|
||||
]
|
||||
await page.getByText('Students (2)').click()
|
||||
await expect(page.getByTestId('ooditable-general').locator('table thead th')).toHaveText(columns)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with associated degree programme and year', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await initWithGroups(page, '/studyguidancegroups/sgg-cypress-1')
|
||||
})
|
||||
|
||||
test('shows the correct labels', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'MAT kandit 2020' })).toBeVisible()
|
||||
await expect(page.getByLabel('Associated degree programme')).toHaveText('Matemaattisten tieteiden kandiohjelma')
|
||||
await expect(page.getByLabel('Associated starting academic')).toHaveText('2020 - 2021')
|
||||
})
|
||||
|
||||
test('shows the correct panes', async ({ page }) => {
|
||||
await expect(page.getByTestId('Credit accumulation (for 3 students)')).toBeVisible()
|
||||
await expect(page.getByTestId('Credit statistics')).toBeVisible()
|
||||
await expect(page.getByTestId('Age distribution')).toBeVisible()
|
||||
await expect(page.getByTestId('Courses of population')).toBeVisible()
|
||||
await expect(page.getByText('Students (3)')).toBeVisible()
|
||||
})
|
||||
|
||||
test("clicking the 'Show credits starting from the associated academic year' filter", async ({ page }) => {
|
||||
const filter = page.getByTestId('ParticipationDate-filter-card')
|
||||
await expect(filter).toHaveAttribute('data-active', 'false')
|
||||
await page.getByTestId('Credit accumulation (for 3 students)').click()
|
||||
await page.getByText('Show credits starting from the associated academic year').click()
|
||||
|
||||
await expect(filter).toHaveAttribute('data-active', 'true')
|
||||
await expect(page.locator('.date-picker').first().locator('input')).toHaveValue('01.08.2020')
|
||||
await expect(page.getByText('Reset All Filters')).toBeVisible()
|
||||
await page.getByText('Show credits starting from the associated academic year').click()
|
||||
await expect(filter).toHaveAttribute('data-active', 'false')
|
||||
})
|
||||
|
||||
test('students table has the correct tabs', async ({ page }) => {
|
||||
await page.getByText('Students (3)').click()
|
||||
await expect(page.getByTestId('student-table-tabs').getByRole('tab')).toHaveText([
|
||||
'General',
|
||||
'Courses',
|
||||
'Modules',
|
||||
'Progress',
|
||||
])
|
||||
})
|
||||
|
||||
test('general tab of the students table has the correct columns', async ({ page }) => {
|
||||
const columns = [
|
||||
'Student number',
|
||||
'Status',
|
||||
'Credits',
|
||||
'Prior to bachelor',
|
||||
'Start date',
|
||||
'Graduation date',
|
||||
'Semesters present',
|
||||
'Study track',
|
||||
'Other programmes',
|
||||
'Transferred From',
|
||||
'Admission Type',
|
||||
'Gender',
|
||||
'Citizenships',
|
||||
'Curriculum period',
|
||||
'Latest attainment date',
|
||||
'TVEX',
|
||||
'Tags',
|
||||
'Total',
|
||||
'In study plan',
|
||||
'Since start in programme',
|
||||
'Credits',
|
||||
'Courses',
|
||||
'University',
|
||||
'Study right',
|
||||
'Programme',
|
||||
]
|
||||
await page.getByText('Students (3)').click()
|
||||
await expect(page.getByTestId('ooditable-general').locator('table thead th')).toHaveText(columns)
|
||||
})
|
||||
})
|
||||
|
||||
test('has a working back button', async ({ page }) => {
|
||||
await initWithGroups(page, '/studyguidancegroups/sgg-cypress-1')
|
||||
await page.getByRole('button', { name: 'Back to groups' }).click()
|
||||
await expect(page).toHaveURL('/studyguidancegroups')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
test.describe('Teachers page tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// login as admin = has teacher rights
|
||||
await init(page, '/teachers', 'admin')
|
||||
expect(page.url()).toContain('/teachers')
|
||||
})
|
||||
|
||||
const teacher1 = 'Luokkanen Liisa Viljami'
|
||||
const teacher2 = 'Perälä Juhani Susanna'
|
||||
|
||||
test('Check Statistics', async ({ page }) => {
|
||||
await page.getByTestId('semester-start').click()
|
||||
await page.getByText('Syksy 2020').click()
|
||||
|
||||
await page.getByTestId('course-providers').click()
|
||||
await page.getByText('Matemaattisten tieteiden kandiohjelma').click()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await page.getByTestId('search-statistics').click()
|
||||
await expect(page.getByRole('columnheader')).toHaveCount(4)
|
||||
await expect(page.getByText('Export to Excel')).toBeVisible()
|
||||
|
||||
const teacher1Row = page.locator('tr').filter({ hasText: teacher1 })
|
||||
await expect(teacher1Row.getByText(teacher1)).toBeVisible()
|
||||
await expect(teacher1Row.getByText('235')).toBeVisible()
|
||||
await expect(teacher1Row.getByText('97.40%')).toBeVisible()
|
||||
|
||||
const teacher2Row = page.locator('tr').filter({ hasText: teacher2 })
|
||||
await expect(teacher2Row.getByText(teacher2)).toBeVisible()
|
||||
await expect(teacher2Row.getByText('395')).toBeVisible()
|
||||
await expect(teacher2Row.getByText('98.78%')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Teacher search works', async ({ page }) => {
|
||||
await page.getByTestId('Search').click()
|
||||
await page
|
||||
.getByTestId('teacher-search')
|
||||
.getByPlaceholder('Search by entering a name or an id')
|
||||
.fill(teacher1.split(' ')[0])
|
||||
await expect(page.getByText(teacher1)).toBeVisible()
|
||||
await expect(page.locator('tbody').locator('tr')).toHaveCount(4)
|
||||
})
|
||||
|
||||
test('Can check teacher page', async ({ page, context }) => {
|
||||
// Re-init with context so the tab opened from the teacher link gets mocked headers too.
|
||||
await init(page, '/teachers', 'admin', context)
|
||||
await page.getByTestId('Search').click()
|
||||
await page.getByTestId('teacher-search').getByPlaceholder('Search by entering a name or an id').fill(teacher2)
|
||||
|
||||
await expect(page.getByText(teacher2)).toBeVisible()
|
||||
expect(await page.getByText(teacher2).getAttribute('href')).toBe('/teachers/hy-hlo-49026530')
|
||||
|
||||
// Change to other tab
|
||||
const pagePromise = context.waitForEvent('page')
|
||||
await page.getByRole('link', { name: teacher2 }).click()
|
||||
const newPage = await pagePromise
|
||||
|
||||
await newPage.waitForURL('**/teachers/hy-hlo-49026530')
|
||||
await expect(newPage.getByRole('heading', { name: teacher2 })).toBeVisible()
|
||||
await newPage.getByText('Syksy 2023').click()
|
||||
await newPage.getByText('Kevät 2019').click()
|
||||
|
||||
await expect(newPage.getByText('MAT12004')).toBeVisible()
|
||||
await expect(newPage.getByText('MAT22002')).toBeVisible()
|
||||
await expect(newPage.getByText('MAT22003')).toBeVisible()
|
||||
|
||||
const courseRow = newPage.locator('tr').filter({ hasText: 'MAT12004' })
|
||||
const rowContent = ['MAT12004', 'Tilastollinen päättely I', '120', '0', '92.31%']
|
||||
await expect(courseRow.locator('td')).toHaveText(rowContent)
|
||||
})
|
||||
|
||||
test('Check leaderboad works', async ({ page }) => {
|
||||
await page.getByTestId('Leaderboard').click()
|
||||
await page.getByTestId('academic-year').click()
|
||||
await page.getByText('2020-2021').click()
|
||||
|
||||
await expect(page.getByRole('columnheader')).toHaveCount(4)
|
||||
await expect(page.getByText('Export to Excel')).toBeVisible()
|
||||
await expect(page.getByText(teacher1)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,90 +0,0 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const progressLevels = ['bachelors', 'bachelor-masters', 'masters', 'doctoral']
|
||||
const graduationTimesLevels = ['bachelor', 'bcMsCombo', 'master', 'doctor']
|
||||
|
||||
const checkProgressBarCharts = async (page: Parameters<typeof init>[0]) => {
|
||||
for (const level of progressLevels) {
|
||||
await expect(page.getByTestId(`faculty-${level}-progress-bar-chart-section`)).toBeVisible({ timeout: 60_000 })
|
||||
}
|
||||
}
|
||||
|
||||
const checkProgressTables = async (page: Parameters<typeof init>[0]) => {
|
||||
for (const level of progressLevels) {
|
||||
await expect(page.getByTestId(`${level}-faculty-progress-table`)).toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
const checkGraduationCharts = async (page: Parameters<typeof init>[0], mode: string) => {
|
||||
for (const level of graduationTimesLevels) {
|
||||
const chart = page.getByTestId(`${level}-${mode}-bar-chart`)
|
||||
await expect(chart).toBeVisible()
|
||||
await expect(chart.getByText('Loading content')).not.toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('University view', () => {
|
||||
test.slow()
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/university')
|
||||
await expect(page.getByRole('heading', { name: 'University', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Faculty progress tab', () => {
|
||||
test('contains all the correct progress bar charts', async ({ page }) => {
|
||||
await checkProgressBarCharts(page)
|
||||
})
|
||||
|
||||
test('contains all the correct progress tables', async ({ page }) => {
|
||||
await checkProgressTables(page)
|
||||
})
|
||||
|
||||
test("'All study rights / Special study rights excluded' toggle works", async ({ page }) => {
|
||||
await page.getByTestId('study-right-toggle').click()
|
||||
await checkProgressBarCharts(page)
|
||||
await checkProgressTables(page)
|
||||
})
|
||||
|
||||
test('info boxes contain correct information', async ({ page }) => {
|
||||
await page.getByTestId('faculty-progress-info-box-button').click()
|
||||
await expect(page.getByTestId('faculty-progress-info-box-content')).toContainText('Kuvaa tiedekuntaan kuuluvien')
|
||||
|
||||
await page.getByTestId('faculty-bachelor-masters-progress-info-box-button').click()
|
||||
await expect(page.getByTestId('faculty-bachelor-masters-progress-info-box-content')).toContainText(
|
||||
'The starting year is the'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Faculty graduations tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.getByTestId('faculty-graduations-tab').click()
|
||||
})
|
||||
|
||||
test.describe('Different modes work', () => {
|
||||
test('Breakdown', async ({ page }) => {
|
||||
await page.getByTestId('graduation-mode-selector').getByTestId('select-breakdown').click()
|
||||
await checkGraduationCharts(page, 'breakdown')
|
||||
})
|
||||
|
||||
test('Median', async ({ page }) => {
|
||||
await page.getByTestId('graduation-mode-selector').getByTestId('select-median').click()
|
||||
await checkGraduationCharts(page, 'median')
|
||||
})
|
||||
|
||||
test('Average', async ({ page }) => {
|
||||
await page.getByTestId('graduation-mode-selector').getByTestId('select-average').click()
|
||||
await checkGraduationCharts(page, 'average')
|
||||
})
|
||||
})
|
||||
|
||||
test('info boxes contain correct information', async ({ page }) => {
|
||||
await page.getByTestId('average-graduation-times-info-box-button').hover()
|
||||
await expect(page.getByTestId('average-graduation-times-info-box-content')).toContainText(
|
||||
'Opiskelijoiden keskimääräiset valmistumisajat'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,100 +0,0 @@
|
||||
import { test, expect, Page } from '@playwright/test'
|
||||
import { init } from './support/commands'
|
||||
|
||||
const visibleLinks = {
|
||||
norights: ['University', 'Faculties', 'Special populations', 'Feedback'],
|
||||
onlycoursestatistics: ['University', 'Courses', 'Special populations', 'Feedback'],
|
||||
basic: [] as string[],
|
||||
admin: [] as string[],
|
||||
}
|
||||
|
||||
visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Programmes', 'Students']
|
||||
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
|
||||
|
||||
const containsLinks = async (page: Page, links: string[]) => {
|
||||
const navBar = page.getByTestId('nav-bar')
|
||||
await Promise.all(links.map(link => expect(navBar.getByText(link)).toBeVisible()))
|
||||
}
|
||||
|
||||
const userButtonWorks = async (page: Page, username: string, mocking = false) => {
|
||||
await page.getByTestId('nav-bar-user-button').click()
|
||||
|
||||
await expect(page.getByText(mocking ? `Mocking as ${username}` : `Logged in as ${username}`)).toBeVisible()
|
||||
await expect(page.getByText('Language')).toBeVisible()
|
||||
await expect(page.getByText('suomi')).toBeVisible()
|
||||
await expect(page.getByText('English')).toBeVisible()
|
||||
await expect(page.getByText('svenska')).toBeVisible()
|
||||
await expect(page.getByText(mocking ? 'Stop mocking' : 'Log out')).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Users tests', () => {
|
||||
test.describe('Using as user with just grp-oodikone-user, no other rights', () => {
|
||||
test('shows correct tabs', async ({ page }) => {
|
||||
await init(page, '', 'norights')
|
||||
await containsLinks(page, visibleLinks.norights)
|
||||
await userButtonWorks(page, 'norights')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Using as coursestatistics user', () => {
|
||||
test('shows correct tabs', async ({ page }) => {
|
||||
await init(page, '', 'onlycoursestatistics')
|
||||
await containsLinks(page, visibleLinks.onlycoursestatistics)
|
||||
await userButtonWorks(page, 'onlycoursestatistics')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Using as basic user', () => {
|
||||
test('shows correct tabs', async ({ page }) => {
|
||||
await init(page, '', 'basic')
|
||||
await containsLinks(page, visibleLinks.basic)
|
||||
await userButtonWorks(page, 'basic')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Using as admin', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await init(page, '/users', 'admin')
|
||||
})
|
||||
|
||||
test('should see more stuff than others', async ({ page }) => {
|
||||
await containsLinks(page, visibleLinks.admin)
|
||||
await userButtonWorks(page, 'admin')
|
||||
})
|
||||
|
||||
test.describe('can mock as other users', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.getByTestId('user-page-button-basic').click()
|
||||
|
||||
await expect(page.getByText('Basic User')).toBeVisible()
|
||||
await expect(page.getByText('IAM Groups')).toBeVisible()
|
||||
await expect(page.getByText('Roles')).toBeVisible()
|
||||
await expect(page.getByText('Degree programme rights')).toBeVisible()
|
||||
|
||||
await page.getByTestId('mock-button').click()
|
||||
})
|
||||
|
||||
test('user button shows mocked user', async ({ page }) => {
|
||||
await userButtonWorks(page, 'basic', true)
|
||||
})
|
||||
|
||||
test("only the mocked user's programmes are visible", async ({ page }) => {
|
||||
await page.route('**/api/populationstatistics/studyprogrammes', async route => {
|
||||
const response = await route.fetch()
|
||||
expect(response.status()).toBeLessThanOrEqual(304)
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
|
||||
await init(page, '/populations', 'basic')
|
||||
await expect(page.getByRole('heading', { name: 'Degree programme' })).toBeVisible()
|
||||
|
||||
const populationProgrammeSelector = page.getByTestId('population-programme-selector')
|
||||
await expect(populationProgrammeSelector.getByPlaceholder('Select degree programme')).toBeVisible()
|
||||
|
||||
const programmeSelectorParent = page.getByTestId('population-programme-selector-parent')
|
||||
await programmeSelectorParent.click()
|
||||
await expect(programmeSelectorParent).toContainText('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
// ! IMPORTANT: here we need to set keys to be all lowercase, since
|
||||
// ! we're replacing headers after they've left browser / frontend.
|
||||
|
||||
import { BrowserContext, expect, Page } from '@playwright/test'
|
||||
|
||||
// All of these users are available in anon user-db
|
||||
const adminUserHeaders = {
|
||||
uid: 'admin',
|
||||
displayname: 'Admin User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-users;grp-oodikone-basic-users;grp-toska',
|
||||
mail: 'grp-toska+mockadminuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-6666666',
|
||||
}
|
||||
|
||||
const basicUserHeaders = {
|
||||
uid: 'basic',
|
||||
displayname: 'Basic User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-users;grp-oodikone-basic-users',
|
||||
mail: 'grp-toska+mockbasicuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-1234567',
|
||||
}
|
||||
|
||||
const onlycoursestatisticsUserHeaders = {
|
||||
uid: 'onlycoursestatistics',
|
||||
displayname: 'Onlycoursestatistics User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-basic-users',
|
||||
mail: 'grp-toska+mockonlycoursestatisticsuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-7654321',
|
||||
}
|
||||
|
||||
const norightsUserHeaders = {
|
||||
uid: 'norights',
|
||||
displayname: 'Norights User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-users',
|
||||
mail: 'grp-toska+mocknorightuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-0000000',
|
||||
}
|
||||
|
||||
const onlyIamRightsUserHeaders = {
|
||||
uid: 'onlyiamrights',
|
||||
displayname: 'Only IAM rights user',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'hy-employees;hy-mltdk-mat-jory',
|
||||
mail: 'grp-toska+mockonlyiamrightsuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-1111111',
|
||||
}
|
||||
|
||||
// Also has element detail of "KH50_005" for the study guidance groups to work
|
||||
const onlyStudyGuidanceGroupsUser = {
|
||||
uid: 'onlystudyguidancegroups',
|
||||
displayname: 'Study Guidance Groups User',
|
||||
'shib-session-id': 'mock-cypress-session',
|
||||
hygroupcn: 'grp-oodikone-users',
|
||||
mail: 'grp-toska+mockonlystudyguidancegroupsuser@helsinki.fi',
|
||||
hypersonsisuid: 'hy-hlo-2222222',
|
||||
}
|
||||
|
||||
export const userHeaders = [
|
||||
adminUserHeaders,
|
||||
basicUserHeaders,
|
||||
onlycoursestatisticsUserHeaders,
|
||||
norightsUserHeaders,
|
||||
onlyIamRightsUserHeaders,
|
||||
onlyStudyGuidanceGroupsUser,
|
||||
]
|
||||
|
||||
export const testMockTime = '2026-03-06'
|
||||
|
||||
export type UserId =
|
||||
| 'admin'
|
||||
| 'basic'
|
||||
| 'onlycoursestatistics'
|
||||
| 'norights'
|
||||
| 'onlyiamrights'
|
||||
| 'onlystudyguidancegroups'
|
||||
|
||||
export const init = async (page: Page, path: string, userId: UserId = 'basic', context?: BrowserContext) => {
|
||||
const headersToUse = userHeaders.find(({ uid }) => uid === userId)
|
||||
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
|
||||
|
||||
// Prefer context-level routing when provided so new tabs inherit header injection.
|
||||
const routeTarget = context ?? page
|
||||
|
||||
await routeTarget.route('**/api/**', async route => {
|
||||
await route.continue({
|
||||
headers: {
|
||||
...route.request().headers(),
|
||||
...headersToUse,
|
||||
'x-test-now': testMockTime,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await page.clock.install({ time: testMockTime })
|
||||
await page.goto(path || '/')
|
||||
}
|
||||
|
||||
export const checkTableStats = async (page: Page, correctStats: (string | number)[][], tableName: string) => {
|
||||
const testId = tableName + '-data-table'
|
||||
await expect(page.getByTestId(testId).getByText('Loading content')).not.toBeVisible()
|
||||
|
||||
const table = page.getByTestId(testId).locator('tbody')
|
||||
await expect(table).toBeVisible()
|
||||
await expect(table.locator('tr > td')).toHaveText(correctStats.flatMap(stats => stats.map(value => value.toString())))
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
|
||||
rewrite_log on;
|
||||
error_log /var/log/nginx/localhost.error_log notice;
|
||||
access_log off;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
proxy_pass http://frontend:5000/;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080/;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Oodikone CLI tool (revision 2.0)
|
||||
# Bash template provided by:
|
||||
# https://betterdev.blog/minimal-safe-bash-script-template/
|
||||
|
||||
set -Eueo pipefail
|
||||
trap cleanup SIGINT SIGTERM ERR EXIT
|
||||
|
||||
# Constants
|
||||
# Resolve relative to the script's own location (not the caller's cwd), so
|
||||
# `ok.sh` behaves the same whether it's run as `./ok.sh` from the repo or
|
||||
# invoked with an absolute/symlinked path from anywhere (e.g. the `ok`
|
||||
# shell function installed via `./ok.sh install`).
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Include scripts from other sources
|
||||
source "$PROJECT_ROOT"/scripts/functions_for_setup.sh
|
||||
|
||||
usage() {
|
||||
cat << EOF # remove the space between << and EOF, this is due to web plugin issue
|
||||
Usage: $(basename "${BASH_SOURCE[0]}") [command]
|
||||
|
||||
Run without a command to open the interactive menu.
|
||||
|
||||
Available commands:
|
||||
|
||||
up, u, r, real Start Oodikone with real data
|
||||
rs, rl Restart Oodikone with real data (down, then up)
|
||||
anon, a Start Oodikone with anonymous data
|
||||
down, d Stop Oodikone
|
||||
|
||||
logs, l Follow oodikone service logs
|
||||
logs:updater, lu Follow sis-updater service logs
|
||||
lint, li Lint the project or the path given as parameter
|
||||
format, fmt, f Format the project or the path given as parameter
|
||||
|
||||
test:backend, tb Run backend unit tests
|
||||
test:frontend, tf Run frontend unit tests
|
||||
test:updater, tu Run updater (worker) unit tests
|
||||
test:services, ts Run backend service tests
|
||||
test:api, ta Run backend api tests
|
||||
test:component, tc Run frontend component tests tests
|
||||
test:e2e, te Run all e2e tests (add --ui to run in headed mode)
|
||||
|
||||
importer, i Open a psql/pgcli shell to the importer database
|
||||
updater Open a psql/pgcli shell to the updater (sis) database
|
||||
install Add the 'ok' shell function to your shell rc file
|
||||
-h, --help Print this help and exit
|
||||
EOF
|
||||
exit
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
trap - SIGINT SIGTERM ERR EXIT
|
||||
msg ""
|
||||
errormsg "(╯°□°)╯︵ ┻━┻"
|
||||
}
|
||||
|
||||
setup_colors() {
|
||||
if [[ -t 2 ]] && [[ -z "${NO_COLOR-}" ]] && [[ "${TERM-}" != "dumb" ]]; then
|
||||
NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m'
|
||||
else
|
||||
NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE=''
|
||||
fi
|
||||
}
|
||||
|
||||
die() {
|
||||
local msg=$1
|
||||
local code=${2-1} # default exit status 1
|
||||
msg "$msg"
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
get_pgclient() {
|
||||
if command -v pgcli >/dev/null 2>&1; then
|
||||
echo "pgcli"
|
||||
else
|
||||
warningmsg "Couldn't verify pgcli installation, using psql as fallback" >&2
|
||||
echo "psql"
|
||||
fi
|
||||
}
|
||||
|
||||
show_welcome() {
|
||||
local cashmoneyyellow
|
||||
cashmoneyyellow=$(tput setaf 221)
|
||||
local normal
|
||||
normal=$(tput sgr0)
|
||||
if [ "$(tput cols)" -gt "76" ]; then
|
||||
while IFS="" read -r p || [ -n "$p" ]; do
|
||||
printf '%40s\n' "${cashmoneyyellow}$p${normal}"
|
||||
done < "$PROJECT_ROOT"/scripts/assets/logo.txt
|
||||
fi
|
||||
msg "Welcome to Oodikone CLI!"
|
||||
msg ""
|
||||
}
|
||||
|
||||
main_menu_opts() {
|
||||
msg "1) Start Oodikone 2) Databases 3) Data refresh 4) Setup q) Quit\n"
|
||||
}
|
||||
|
||||
database_menu_opts() {
|
||||
msg "\nView local databases ($datasource data)"
|
||||
msg "suffix: $dbsuffix"
|
||||
msg "1) Oodikone"
|
||||
msg "2) Importer"
|
||||
msg "3) Kone"
|
||||
msg "4) Toggle data source (test/real)"
|
||||
msg "5) Go back"
|
||||
msg ""
|
||||
}
|
||||
|
||||
database_menu() {
|
||||
# If a single mandatory service is down, an assumption is made that they all are.
|
||||
if ! docker compose ps 2>/dev/null | grep -q 'oodikone-backend'; then
|
||||
msg ""
|
||||
warningmsg "Can't access databases, oodikone is not running!\nPlease run option 1 first with preferred data source."
|
||||
return
|
||||
fi
|
||||
|
||||
|
||||
declare dbsuffix="-real"
|
||||
declare datasource="real"
|
||||
|
||||
toggle_environment() {
|
||||
if [[ $dbsuffix = "" ]];
|
||||
then
|
||||
dbsuffix="-real"
|
||||
datasource="real"
|
||||
else
|
||||
dbsuffix=""
|
||||
datasource="test"
|
||||
fi
|
||||
}
|
||||
|
||||
local pgclient
|
||||
pgclient="$(get_pgclient)"
|
||||
msg "${GREEN}\nUsing $pgclient for database connections${NOFORMAT}"
|
||||
database_menu_opts $datasource
|
||||
|
||||
local userinput
|
||||
while true; do
|
||||
read -r -s -n 1 -p "Please select an option: " userinput
|
||||
|
||||
case ${userinput} in
|
||||
1|o|O)
|
||||
msg "Oodikone-db"
|
||||
$pgclient -h localhost -p 12345 -U postgres -d "sis-db$dbsuffix" -w
|
||||
database_menu_opts
|
||||
;;
|
||||
2|i|I)
|
||||
msg "Importer-db"
|
||||
$pgclient -h localhost -p 12346 -U postgres -d "sis-importer-db$dbsuffix" -w
|
||||
database_menu_opts
|
||||
;;
|
||||
3|k|K)
|
||||
msg "Kone-db"
|
||||
$pgclient -h localhost -p 4321 -U postgres -d "kone-db$dbsuffix" -w
|
||||
database_menu_opts
|
||||
;;
|
||||
4|t|T)
|
||||
msg "Toggling environment..."
|
||||
database_menu_opts
|
||||
;;
|
||||
5|q|Q)
|
||||
msg ""
|
||||
break
|
||||
;;
|
||||
*)
|
||||
true
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
main_loop() {
|
||||
local userinput
|
||||
|
||||
while true; do
|
||||
main_menu_opts
|
||||
read -r -s -n 1 -p "Please select an option: " userinput
|
||||
|
||||
case ${userinput} in
|
||||
1)
|
||||
msg "\nTODO: todo\n";;
|
||||
2)
|
||||
database_menu;;
|
||||
3)
|
||||
msg "\nTODO: todo\n";;
|
||||
4)
|
||||
msg "\nTODO: todo\n";;
|
||||
5|q|Q)
|
||||
break 2;;
|
||||
*)
|
||||
errormsg "${RED}Invalid option:${NOFORMAT} $userinput";;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# === Direct (non-interactive) commands ===
|
||||
run_up() {
|
||||
infomsg "Starting Oodikone with real data..."
|
||||
npm run both:real "$@"
|
||||
}
|
||||
|
||||
run_restart() {
|
||||
infomsg "Restarting Oodikone with real data..."
|
||||
npm run docker:down
|
||||
npm run both:real
|
||||
}
|
||||
|
||||
run_anon() {
|
||||
infomsg "Starting Oodikone with anonymous data..."
|
||||
npm run both "$@"
|
||||
}
|
||||
|
||||
run_down() {
|
||||
infomsg "Stopping Oodikone..."
|
||||
npm run docker:down "$@"
|
||||
}
|
||||
|
||||
run_logs() {
|
||||
npm run logs "$@"
|
||||
}
|
||||
|
||||
run_lint() {
|
||||
npm run lint -- "$@"
|
||||
}
|
||||
|
||||
run_format() {
|
||||
npm run fmt -- "$@"
|
||||
}
|
||||
|
||||
run_unit_tests() {
|
||||
npm run test:unit --prefix "$@"
|
||||
}
|
||||
|
||||
run_component_tests() {
|
||||
npm run test:component --prefix services/frontend "$@"
|
||||
}
|
||||
|
||||
run_service_tests() {
|
||||
npm run test:services --prefix services/backend "$@"
|
||||
}
|
||||
|
||||
run_api_tests() {
|
||||
npm run test:api --prefix services/backend "$@"
|
||||
}
|
||||
|
||||
run_e2e_tests() {
|
||||
npm run playwright -- "$@"
|
||||
}
|
||||
|
||||
run_logs_updater() {
|
||||
docker compose --file docker-compose.yml logs sis-updater-worker sis-updater-scheduler -f
|
||||
}
|
||||
|
||||
|
||||
run_importer_db() {
|
||||
local pgclient
|
||||
pgclient="$(get_pgclient)"
|
||||
msg "Importer-db"
|
||||
$pgclient -h localhost -p 12346 -U postgres -d sis-importer-db-real -w
|
||||
}
|
||||
|
||||
run_updater_db() {
|
||||
local pgclient
|
||||
pgclient="$(get_pgclient)"
|
||||
msg "Updater-db"
|
||||
$pgclient -h localhost -p 12345 -U postgres -d sis-db-real -w
|
||||
}
|
||||
|
||||
# Add (or refresh) an `ok` shell function in the user's shell rc file, which
|
||||
# simply forwards all arguments to this script. This is the one-time setup
|
||||
# step referenced above the shell function no longer needs its own copy of
|
||||
# oodikone-specific logic.
|
||||
cmd_install() {
|
||||
local shell_name
|
||||
shell_name="$(basename "${SHELL:-bash}")"
|
||||
|
||||
local shell_rc
|
||||
case "$shell_name" in
|
||||
zsh) shell_rc="$HOME/.zshrc" ;;
|
||||
*) shell_rc="$HOME/.bashrc" ;;
|
||||
esac
|
||||
|
||||
local ok_path="$PROJECT_ROOT/ok.sh"
|
||||
local marker_start="# >>> oodikone ok.sh >>>"
|
||||
local marker_end="# <<< oodikone ok.sh <<<"
|
||||
|
||||
if [ -f "$shell_rc" ] && grep -qF "$marker_start" "$shell_rc"; then
|
||||
warningmsg "ok.sh integration already present in $shell_rc, skipping."
|
||||
infomsg "To reinstall (e.g. after moving the repo), remove the block between\n'$marker_start' and '$marker_end' in $shell_rc and re-run 'ok.sh install'."
|
||||
return 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "$marker_start"
|
||||
echo "ok() { \"$ok_path\" \"\$@\"; }"
|
||||
echo "$marker_end"
|
||||
} >> "$shell_rc"
|
||||
|
||||
successmsg "Added the 'ok' function to $shell_rc"
|
||||
infomsg "Run 'source $shell_rc' or restart your shell, then try 'ok up'."
|
||||
}
|
||||
|
||||
# Main
|
||||
setup_colors
|
||||
|
||||
case "${1-}" in
|
||||
up|u|r|real)
|
||||
shift
|
||||
run_up "$@"
|
||||
;;
|
||||
rs|rl)
|
||||
shift
|
||||
run_restart "$@"
|
||||
;;
|
||||
anon|a)
|
||||
shift
|
||||
run_anon "$@"
|
||||
;;
|
||||
down|d)
|
||||
shift
|
||||
run_down "$@"
|
||||
;;
|
||||
logs|l)
|
||||
shift
|
||||
run_logs "$@"
|
||||
;;
|
||||
lint|li)
|
||||
shift
|
||||
run_lint "$@"
|
||||
;;
|
||||
format|fmt|f)
|
||||
shift
|
||||
run_format "$@"
|
||||
;;
|
||||
test:backend|tb)
|
||||
shift
|
||||
run_unit_tests services/backend "$@"
|
||||
;;
|
||||
test:frontend|tf)
|
||||
shift
|
||||
run_unit_tests services/frontend "$@"
|
||||
;;
|
||||
test:updater|tu)
|
||||
shift
|
||||
run_unit_tests updater/sis-updater-worker "$@"
|
||||
;;
|
||||
test:services|ts)
|
||||
shift
|
||||
run_service_tests "$@"
|
||||
;;
|
||||
test:api|ta)
|
||||
shift
|
||||
run_api_tests "$@"
|
||||
;;
|
||||
test:component|tc)
|
||||
shift
|
||||
run_component_tests "$@"
|
||||
;;
|
||||
test:e2e|te)
|
||||
shift
|
||||
run_e2e_tests "$@"
|
||||
;;
|
||||
logs:updater|lu)
|
||||
shift
|
||||
run_logs_updater "$@"
|
||||
;;
|
||||
importer|i)
|
||||
shift
|
||||
run_importer_db
|
||||
;;
|
||||
updater)
|
||||
shift
|
||||
run_updater_db
|
||||
;;
|
||||
install)
|
||||
shift
|
||||
cmd_install
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
"")
|
||||
show_welcome
|
||||
get_s3_config
|
||||
main_loop
|
||||
;;
|
||||
*)
|
||||
errormsg "Unknown command: $1"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
Generated
+16295
-6460
File diff suppressed because it is too large
Load Diff
+92
-53
@@ -1,61 +1,100 @@
|
||||
{
|
||||
"name": "oodikone",
|
||||
"description": "An application for analyzing university data.",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"playwright": "playwright test -c playwright.config.ts",
|
||||
"playwright:ui": "playwright test --ui -c playwright.config.ts",
|
||||
"playwright:headed": "playwright test --headed -c playwright.config.ts",
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"lint:quiet": "oxlint --quiet",
|
||||
"fmt": "oxfmt",
|
||||
"fmt:check": "oxfmt --check",
|
||||
"stylelint": "stylelint",
|
||||
"cli": "./cli.sh",
|
||||
"logs": "docker compose --file docker-compose.yml logs backend frontend sis-updater-worker sis-updater-scheduler -f",
|
||||
"logs:all": "docker compose --file docker-compose.yml --profile oodikone --profile updater logs -f",
|
||||
"logs:ci": "docker compose --file docker-compose.ci.yml logs backend frontend sis-updater-worker sis-updater-scheduler jami",
|
||||
"oodikone": "npm run docker:down && ./run.sh oodikone anon up --detach",
|
||||
"oodikone:real": "./run.sh oodikone real up --detach",
|
||||
"oodikone:test": "npm run docker:down && docker compose --profile oodikone up --detach && docker compose --file docker-compose.test.yml up --detach",
|
||||
"oodikone:ci": "npm run docker:down && docker compose --file docker-compose.ci.yml --profile oodikone up",
|
||||
"updater": "./run.sh updater anon up --detach",
|
||||
"updater:real": "./run.sh updater real up --detach",
|
||||
"both": "./run.sh both anon up --detach",
|
||||
"both:real": "./run.sh both real up --detach",
|
||||
"docker:down": "./run.sh both anon down --remove-orphans; ./run.sh both real down --remove-orphans",
|
||||
"testupdater": "docker compose --file docker-compose.ci.yml up --detach --no-recreate --wait sis-updater-worker && SCHEDULE_IMMEDIATE=weekly EXIT_AFTER_IMMEDIATES=yes docker compose --file docker-compose.ci.yml up sis-updater-scheduler --exit-code-from sis-updater-scheduler",
|
||||
"flushredis": "docker exec -it oodikone-redis redis-cli flushall",
|
||||
"prepare": "husky",
|
||||
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
|
||||
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
|
||||
"ci:docker": "docker exec oodikone-frontend sh -lc \"npm ci --prefix /opt/app-root/shared && npm ci\" && docker exec oodikone-backend npm ci && docker exec oodikone-updater-scheduler npm ci && docker exec oodikone-updater-worker npm ci",
|
||||
"ci:both": "npm run ci:local && npm run ci:docker"
|
||||
},
|
||||
"name": "oodikone2-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/UniversityOfHelsinkiCS/oodikone.git"
|
||||
"url": "git+https://github.com/UniversityOfHelsinkiCS/oodikone2-frontend.git"
|
||||
},
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/UniversityOfHelsinkiCS/oodikone/issues"
|
||||
"scripts": {
|
||||
"dev": "webpack-dev-server --config webpack.config.local.js",
|
||||
"dist": "rimraf dist && webpack --config webpack.config.production.js",
|
||||
"lint": "concurrently 'npm run lint:js' 'npm run lint:css'",
|
||||
"lint:css": "stylelint 'src/**/*.css'",
|
||||
"lint:js": "eslint server ./ --ext .jsx,.js",
|
||||
"lintfix": "eslint server ./ --ext .jsx,.js --fix",
|
||||
"stats": "webpack --json --config webpack.config.production.js > stats.json",
|
||||
"prepush": "npm run lint",
|
||||
"pretest": "npm run lint",
|
||||
"test": ""
|
||||
},
|
||||
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
|
||||
"licence": "",
|
||||
"main": "src/app.jsx",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.61.0",
|
||||
"eslint-plugin-import-x": "^4.16.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"globals": "^17.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.0",
|
||||
"oxfmt": "0.58.0",
|
||||
"oxlint": "1.73.0",
|
||||
"oxlint-tsgolint": "0.25.0",
|
||||
"stylelint": "^17.6.0",
|
||||
"stylelint-config-standard": "^40.0.0"
|
||||
"babel-core": "6.26.0",
|
||||
"babel-eslint": "8.2.1",
|
||||
"babel-loader": "7.1.4",
|
||||
"babel-plugin-inline-json-import": "^0.2.1",
|
||||
"babel-plugin-transform-async-to-generator": "^6.24.1",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"babel-preset-latest": "^6.24.1",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"babel-runtime": "6.26.0",
|
||||
"case-sensitive-paths-webpack-plugin": "2.1.1",
|
||||
"concurrently": "^3.5.1",
|
||||
"css-loader": "0.28.7",
|
||||
"eslint": "^4.19.1",
|
||||
"eslint-config-airbnb": "^16.1.0",
|
||||
"eslint-config-react-app": "^2.0.1",
|
||||
"eslint-loader": "1.9.0",
|
||||
"eslint-plugin-babel": "^4.1.2",
|
||||
"eslint-plugin-flowtype": "2.39.1",
|
||||
"eslint-plugin-import": "^2.10.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.0.3",
|
||||
"eslint-plugin-prefer-object-spread": "^1.2.1",
|
||||
"eslint-plugin-react": "^7.7.0",
|
||||
"extract-text-webpack-plugin": "^v4.0.0-beta.0",
|
||||
"file-loader": "1.1.11",
|
||||
"glob": "^7.1.2",
|
||||
"html-webpack-plugin": "3.2.0",
|
||||
"html-webpack-template": "^6.2.0",
|
||||
"husky": "^0.14.3",
|
||||
"mini-css-extract-plugin": "^0.4.0",
|
||||
"optimize-css-assets-webpack-plugin": "^3.2.0",
|
||||
"postcss-cssnext": "^3.0.2",
|
||||
"postcss-flexbugs-fixes": "3.2.0",
|
||||
"postcss-loader": "^2.1.2",
|
||||
"react-dev-utils": "^4.2.1",
|
||||
"style-loader": "^0.19.1",
|
||||
"stylelint": "^8.2.0",
|
||||
"stylelint-config-standard": "^18.2.0",
|
||||
"sw-precache-webpack-plugin": "0.11.5",
|
||||
"uglifyjs-webpack-plugin": "^1.2.4",
|
||||
"webpack": "^4.4.1",
|
||||
"webpack-cli": "^2.0.13",
|
||||
"webpack-dev-server": "3.1.1",
|
||||
"webpack-manifest-plugin": "^2.0.0-rc.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.18.0",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"dotenv": "4.0.0",
|
||||
"highcharts": "^6.1.0",
|
||||
"highcharts-react-official": "^1.1.0",
|
||||
"jStat": "^1.7.1",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"lodash": "^4.17.5",
|
||||
"moment": "^2.22.0",
|
||||
"prop-types": "^15.6.1",
|
||||
"react": "^16.4.0",
|
||||
"react-datetime": "^2.14.0",
|
||||
"react-dom": "^16.4.0",
|
||||
"react-highcharts": "^16.0.2",
|
||||
"react-hot-loader": "^4.3.4",
|
||||
"react-localize-redux": "^2.16.0",
|
||||
"react-redux": "^5.0.7",
|
||||
"react-router": "^4.2.0",
|
||||
"react-router-dom": "^4.2.2",
|
||||
"react-scroll-to-component": "^1.0.2",
|
||||
"recharts": "^1.0.0-beta.10",
|
||||
"redux": "^3.7.2",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.2.0",
|
||||
"reselect": "^3.0.1",
|
||||
"rimraf": "^2.6.2",
|
||||
"semantic-ui-css": "^2.3.1",
|
||||
"semantic-ui-react": "^0.77.2",
|
||||
"uuid": "^3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
// oxlint-disable-next-line no-default-export
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: ['**/*.spec.ts'],
|
||||
testIgnore: ['**/support/**', '**/plugins/**'],
|
||||
|
||||
// Fail the build on CI if you accidentally left test.only in the source code.
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
|
||||
reporter: [['list', { printFailuresInline: true }]],
|
||||
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 4 : undefined,
|
||||
|
||||
use: {
|
||||
baseURL: process.env.CI ? 'http://localhost:1337' : process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
trace: 'on-first-retry',
|
||||
testIdAttribute: 'data-cy',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
const cssNextPlugin = require('postcss-cssnext') // eslint-disable-line import/no-extraneous-dependencies
|
||||
|
||||
const variables = require('./src/styles/variables')
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
cssNextPlugin({
|
||||
browsers: 'last 2 versions',
|
||||
features: {
|
||||
customProperties: { variables }
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "all non-major dependencies",
|
||||
"groupSlug": "all-minor-patch",
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"matchCurrentVersion": "!/^0/",
|
||||
"matchPackageNames": ["*"]
|
||||
},
|
||||
{
|
||||
"matchDatasources": ["docker"],
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
"rebaseWhen": "never",
|
||||
"vulnerabilityAlerts": {
|
||||
"labels": ["security"]
|
||||
},
|
||||
"addLabels": ["dependencies"],
|
||||
"rangeStrategy": "bump"
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This script is used to run oodikone with different setups and is mainly used by
|
||||
# scripts in package.json.
|
||||
# Base for script: https://betterdev.blog/minimal-safe-bash-script-template/
|
||||
|
||||
# === Config ===
|
||||
|
||||
# Fail immediately if script fails, unbound variables are referenced
|
||||
# or command inside pipe fails. -E ensures cleanup trap fires in rare ERR cases.
|
||||
set -euoE pipefail
|
||||
|
||||
# Set up constants
|
||||
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
# Set up messages and exiting
|
||||
source "$PROJECT_ROOT"/scripts/utils.sh
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "${BASH_SOURCE[0]}") option [version] command --flag
|
||||
|
||||
Parameters:
|
||||
* Option: oodikone/updater/both
|
||||
* Version: anon/real. Not necessary when running down/logs/pull.
|
||||
* Command: will be passed to docker compose.
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse parameters. If arguments are not correct, print usage and exit with error.
|
||||
parse_params() {
|
||||
args=("$@")
|
||||
|
||||
[[ ${#args[@]} -eq 0 ]] && usage && die "Wrong number of arguments"
|
||||
option=${args[0]}
|
||||
|
||||
# parse arguments
|
||||
[[ ("$option" != "oodikone" && "$option" != "updater" && "$option" != "both" && "$option" != "tests") ]] && \
|
||||
usage && die "Wrong option: $option"
|
||||
|
||||
[[ ${#args[@]} -eq 1 ]] && usage && die "Wrong number of arguments"
|
||||
|
||||
# Some arguments can be passed without version. Otherwise parse version and then pass
|
||||
# rest to compose
|
||||
if [[ "${args[1]}" == "down" || "${args[1]}" == "logs" || "${args[1]}" == "pull" ]]; then
|
||||
version=""
|
||||
compose_command=${args[*]:1}
|
||||
else
|
||||
version=${args[1]}
|
||||
[[ "$version" != "anon" && "$version" != "real" && "$version" != "api" ]] && usage && \
|
||||
die "Wrong version $version"
|
||||
compose_command=${args[*]:2}
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Set which profile to use to launch correct services
|
||||
parse_profiles() {
|
||||
if [[ "$option" == "both" ]]; then
|
||||
profiles="--profile oodikone --profile updater"
|
||||
else
|
||||
profiles="--profile $option"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Set which docker-compose files to use based on version.
|
||||
parse_env() {
|
||||
env=""
|
||||
if [[ "$version" == "real" ]]; then
|
||||
env="--file docker-compose.yml --file docker-compose.real.yml"
|
||||
fi
|
||||
if [[ "$version" == "api" ]]; then
|
||||
env="--file docker-compose.ci.yml"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# === Run script ===
|
||||
|
||||
parse_params "$@"
|
||||
|
||||
# Create command that will be run. Empty command and "down" command will be handled
|
||||
# differently.
|
||||
parse_profiles
|
||||
parse_env
|
||||
if [[ "$compose_command" == "" ]]; then
|
||||
final_command="docker compose ${env}"
|
||||
else
|
||||
final_command="docker compose ${env} ${profiles} ${compose_command}"
|
||||
fi
|
||||
|
||||
msg "${BLUE}Running: ${final_command}${NOFORMAT}"
|
||||
eval "$final_command"
|
||||
@@ -1,10 +0,0 @@
|
||||
______ __ __ __
|
||||
/ \ | \| \| \
|
||||
| $$$$$$\ ______ ____| $$ \$$| $$ __ ______ _______ ______
|
||||
| $$ | $$ / \ / $$| \| $$ / \ / \ | \ / \
|
||||
| $$ | $$| $$$$$$\| $$$$$$$| $$| $$_/ $$| $$$$$$\| $$$$$$$\| $$$$$$\
|
||||
| $$ | $$| $$ | $$| $$ | $$| $$| $$ $$ | $$ | $$| $$ | $$| $$ $$
|
||||
| $$__/ $$| $$__/ $$| $$__| $$| $$| $$$$$$\ | $$__/ $$| $$ | $$| $$$$$$$$
|
||||
\$$ $$ \$$ $$ \$$ $$| $$| $$ \$$\ \$$ $$| $$ | $$ \$$ \
|
||||
\$$$$$$ \$$$$$$ \$$$$$$$ \$$ \$$ \$$ \$$$$$$ \$$ \$$ \$$$$$$$
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
::---..``` `ohmMMMMMNNNdy.
|
||||
/MMMMMMMMMMMMMMMMMMMMmyMMMMd
|
||||
`sddmmmNNNNNMMMMMMMMMMMMMMMMmdddddhhhhhhy+`
|
||||
``````mMMMMMMMMMMMMMMNNMMMMMMMMMMMN-
|
||||
.yMMMMMMMMMMMMo``````...----.
|
||||
:MMMMMMMMMMMMm-
|
||||
hMMNNMMMMMMMMMNy- `.--.`
|
||||
-MMMdMMMMMMMMMMMMNdo:.`` ``..://ossyhdmmNNNNNNmhys+:.
|
||||
` -mMMmdMMMMMMMMMMMMMMMNNNmmmmddmmmNNNNNMMMMMMMMMMMMMMMMMMMMMMNmh/.
|
||||
.mds+:-.` ` -+hNMMNsNNNNNNNMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNy-
|
||||
.`.-/+syyy+:. -ddymNMMMMNmNNNNdNNNNNNNMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNo`
|
||||
``-oydmmMMMMMMMMNNNNNNNNNNmNNmNNNMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMm:
|
||||
/NMMMMMMMMMNNMMNNNNNNmNNmNNNNMMMMMMMMMMMMMNdmMMMMMMMMMMMMMMMMMMhmMMMMMMMMMMMMMMN/
|
||||
`oNMMMMMMMMMMMMMMMNNNNNNmdNNNMMNNMMMMMMMMMMMd`/MMMMMMMMMMMMMMMMMM`+MMMMMMMMMMMMMMMN-
|
||||
yMMMMMMMMMMMMMMMMMNNNNmNdmNMMNNNMMMMMMMMMMMMN .MMMMMMMMMMMMMMMMMM.:MMMMMMNMMMMMMMMMh
|
||||
oMMMMMMMMMMMMMdyyyhmNNNmmdMMMMNNMMMMMMMMMMMNmm` ddddhhhyyhdNMmyo+o..MMMd+:::/mMMMMMMM:
|
||||
-NMMMMMMMMMMMm+sdmdy/hNNNNNMMMMMMMMMMMMMMMMMy-:` -::. -///--o.-syo. NNo.+dmmosMMMMMMMm
|
||||
`/osyhmMMMMMMMMMMMM+mMMMMMN:MMMMMMNMMMMMMMMMMMMMMMNmN/ yNd`.hNMMMNs` oNMMs y:-hNdys+oMMMMMMMM`
|
||||
:mMMMhmMMMMMMMMMMMMMhmMMMMMhyMMMMMMNMMMMMMMMMMMMMMMMMMo sM: hMMMMMMMo .-::: . oh-:oyh+MMMMMMMM`
|
||||
NMMMMNMMMMMMMMMNNNMMMmmNmNmmMMMMMMMMMMMMMMMMMMMMMMMMMMy +M- mMMMMMMMy sddh+ os.`.NMMm:MMMMMMMM
|
||||
NMMMMMMMMMMMMMMMNNNMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMd -Ms +NMMMMMN: sNMNy sMm/.oys:/MMMMMMMh
|
||||
+NMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMN `NN+`:ydmdy:.+-+ss+oNMMMddmNNMMMMMMMN-
|
||||
.+yhdmdMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM/.+ss++oyhhddNMNMMMMMMMMMMMMMMMMMMMMM+
|
||||
` hMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNMMMMMMMMMMMMMMMMMMMMMMMMMMMNmmNNMMy
|
||||
.NMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMmmMMMMMMm.
|
||||
/NMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMmyNMMMMMMMMh
|
||||
+MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMdhMMMMMMMMMMM`
|
||||
/NMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMmyMMMMMMMMMMMM.
|
||||
yMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMyMMMMMMMMMNMMM`
|
||||
hMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNNNNhMMMMMMMMMMMMh
|
||||
yMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMhMMMMMMMMMMMMMMMMMMMMMMMNNmhys+/-...oMMMMMMMMMMMN:
|
||||
:MMMMMMMMMMMMMMMNddmNMMMMMMMMMMMMMhMMMMMMMMMNmmMMMMN++/++/- `dMMMMMMMMMMs`
|
||||
hMMMMMMMMMMMMMN: .:/+osyyhddmmmdMMMMMMMMMMMMMMMMy `omMMMMMMNmy-
|
||||
yMMMMMMMMMMMm- `+-`mMMMMMMMMMMMMMMMM. ````````.``.-osyo//`
|
||||
`sNMMMMMMMMo.`` ``` `````` ```+MMMMMMMMMMMMMMMy``` `````
|
||||
`yy.shmMMmh- ` ``. ``hMMMMMMMMMMMMN+-.``
|
||||
`.`...` `...``.```....`.-://mMMMMMMMMMMhy/
|
||||
`+hNMMMNmhs:-.
|
||||
@@ -1,312 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This file includes functions that are used to setup Oodikone
|
||||
# File doesn't run anything by itself and should be sourced from other scripts
|
||||
|
||||
# === Config ===
|
||||
|
||||
# Set up constants
|
||||
## Folders
|
||||
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
||||
DUMP_DIR="$PROJECT_ROOT/.databasedumps"
|
||||
S3_CONFIG_FILE=~/.s3cfg
|
||||
S3_BUCKET="s3://psyduck"
|
||||
|
||||
## Following the naming convention in docker compose, these are names for database
|
||||
## services. The real data databases inside service will have names suffixed by "-real".
|
||||
KONE_DB_NAME="kone-db"
|
||||
SIS_DB_NAME="sis-db"
|
||||
SIS_IMPORTER_DB_NAME="sis-importer-db"
|
||||
USER_DB_NAME="user-db"
|
||||
JAMI_DB_NAME="jami-db"
|
||||
DATABASES=("$KONE_DB_NAME" "$SIS_DB_NAME" "$SIS_IMPORTER_DB_NAME" "$USER_DB_NAME" "$JAMI_DB_NAME")
|
||||
|
||||
## Urls should be in same order as databases as both are iterated through by indexes.
|
||||
KONE_DB_S3_PATH="oodikone_kone"
|
||||
SIS_DB_S3_PATH="oodikone_updater"
|
||||
SIS_IMPORTER_DB_S3_PATH="sis_importer"
|
||||
USER_DB_S3_PATH="oodikone_user"
|
||||
JAMI_DB_S3_PATH="jami"
|
||||
S3_PATHS=("$KONE_DB_S3_PATH" "$SIS_DB_S3_PATH" "$SIS_IMPORTER_DB_S3_PATH" "$USER_DB_S3_PATH" "$JAMI_DB_S3_PATH")
|
||||
|
||||
# Source utility functions
|
||||
source "$PROJECT_ROOT"/scripts/utils.sh
|
||||
|
||||
# === Function ===
|
||||
|
||||
draw_mopo() {
|
||||
# Some hacks to print mopo as green, since colours from common config don't work when
|
||||
# catting images. Please feel free to fix this. And tell otahontas how you did it!
|
||||
local mopogreen
|
||||
mopogreen=$(tput setaf 34)
|
||||
local normal
|
||||
normal=$(tput sgr0)
|
||||
if [ "$(tput cols)" -gt "100" ]; then
|
||||
while IFS="" read -r p || [ -n "$p" ]; do
|
||||
printf '%40s\n' "${mopogreen}$p${normal}"
|
||||
done < "$PROJECT_ROOT"/scripts/assets/mopo.txt
|
||||
fi
|
||||
}
|
||||
|
||||
retry() {
|
||||
sleep 5
|
||||
for i in {1..60}; do
|
||||
"$@" && break || warningmsg "Retry attempt $i failed, waiting..." && sleep 5;
|
||||
done
|
||||
}
|
||||
|
||||
download_real_dump() {
|
||||
local database=$1
|
||||
local dump_destination="$DUMP_DIR/$database.sql.gz"
|
||||
local s3_path=$2
|
||||
local selection_flag=$3
|
||||
|
||||
rm -f "$dump_destination"
|
||||
|
||||
if [ "$selection_flag" = "true" ]; then
|
||||
local backup_files
|
||||
mapfile -t backup_files < <(s3cmd -c "$S3_CONFIG_FILE" ls "$S3_BUCKET/$s3_path/" | sort -rk1,2 | awk '{print $4}')
|
||||
|
||||
if [ ${#backup_files[@]} -eq 0 ]; then
|
||||
die "No backup files found in $S3_BUCKET bucket $s3_path path!"
|
||||
fi
|
||||
|
||||
infomsg "Available backups:"
|
||||
PS3="Select backup (or 0 to go back): "
|
||||
select chosen_backup in "${backup_files[@]}"; do
|
||||
if [ "$REPLY" = "0" ] || [ "$chosen_backup" = "Go back" ]; then
|
||||
infomsg "Going back to previous menu."
|
||||
break
|
||||
elif [ -n "$chosen_backup" ]; then
|
||||
infomsg "You selected: $chosen_backup"
|
||||
local FILE_NAME
|
||||
FILE_NAME=$(basename "$chosen_backup")
|
||||
infomsg "Fetching the selected dump: $FILE_NAME"
|
||||
s3cmd -c "$S3_CONFIG_FILE" get "$chosen_backup" "$dump_destination"
|
||||
if [ ! -f "$dump_destination" ]; then
|
||||
die "Download failed or file not found: $dump_destination"
|
||||
fi
|
||||
echo "$dump_destination"
|
||||
break
|
||||
else
|
||||
warningmsg "Invalid selection. Please select a valid backup number."
|
||||
fi
|
||||
done
|
||||
else
|
||||
local current_date
|
||||
current_date=$(date +"%Y%m%d")
|
||||
s3cmd -c "$S3_CONFIG_FILE" get "s3://psyduck/${s3_path}/${s3_path}_${current_date}.sql.gz" "$dump_destination"
|
||||
fi
|
||||
}
|
||||
|
||||
check_if_postgres_is_ready() {
|
||||
local container=$1
|
||||
local database=$2
|
||||
retry docker exec -u postgres "$container" pg_isready --dbname="$database"
|
||||
}
|
||||
|
||||
reset_databases() {
|
||||
local databases=("$@")
|
||||
local database_dump_dir=$DUMP_DIR
|
||||
|
||||
infomsg "Restoring PostgreSQL dumps from backups. This might take a while."
|
||||
|
||||
npm run docker:down
|
||||
docker compose up -d "${databases[@]}"
|
||||
|
||||
for database in "${databases[@]}"; do
|
||||
local database_dump="$database_dump_dir/$database.sql.gz"
|
||||
local database_container="oodikone-$database"
|
||||
local database_name="$database-real"
|
||||
|
||||
infomsg "Attempting to create database $database_name from dump $database_dump inside container $database_container"
|
||||
|
||||
infomsg "Trying to remove possibly existing previous version of database"
|
||||
check_if_postgres_is_ready "$database_container" "$database_name"
|
||||
docker exec -u postgres "$database_container" dropdb "$database_name" || warningmsg "This is okay, continuing"
|
||||
|
||||
infomsg "Trying to create new database"
|
||||
check_if_postgres_is_ready "$database_container" "$database_name"
|
||||
docker exec -u postgres "$database_container" createdb "$database_name" || warningmsg "This is okay, continuing"
|
||||
|
||||
infomsg "Restoring database from dump database"
|
||||
msg "Writing database..."
|
||||
docker exec -i "$database_container" /bin/bash -c "gunzip | psql -U postgres -d $database_name" < "$database_dump" 2> /dev/null
|
||||
msg ""
|
||||
done
|
||||
|
||||
successmsg "Database setup finished"
|
||||
}
|
||||
|
||||
reset_all_data() {
|
||||
infomsg "Downloading real data dumps"
|
||||
for i in ${!DATABASES[*]}; do
|
||||
local database="${DATABASES[$i]}"
|
||||
local url="${S3_PATHS[$i]}"
|
||||
download_real_dump "$database" "$url" false
|
||||
done
|
||||
reset_databases "${DATABASES[@]}"
|
||||
}
|
||||
|
||||
restore_data_from_dumps() {
|
||||
infomsg "Restoring earlier-downloaded dumps to databases"
|
||||
reset_databases "${DATABASES[@]}"
|
||||
}
|
||||
|
||||
reset_single_database() {
|
||||
# Define custom shell prompt for the nested interactive select loop
|
||||
PS3="Please enter which database to reset: "
|
||||
|
||||
options=(
|
||||
"kone-db"
|
||||
"sis-db"
|
||||
"sis-importer-db"
|
||||
"user-db"
|
||||
"jami-db"
|
||||
"Return to main menu."
|
||||
)
|
||||
|
||||
local database=""
|
||||
local url=""
|
||||
local selection_flag=true
|
||||
|
||||
while true; do
|
||||
select opt in "${options[@]}"; do
|
||||
case $opt in
|
||||
"kone-db")
|
||||
local database=$KONE_DB_NAME
|
||||
local url=$KONE_DB_S3_PATH
|
||||
break 2;;
|
||||
"sis-db")
|
||||
local database=$SIS_DB_NAME
|
||||
local url=$SIS_DB_S3_PATH
|
||||
break 2;;
|
||||
"sis-importer-db")
|
||||
local database=$SIS_IMPORTER_DB_NAME
|
||||
local url=$SIS_IMPORTER_DB_S3_PATH
|
||||
break 2;;
|
||||
"user-db")
|
||||
local database=$USER_DB_NAME
|
||||
local url=$USER_DB_S3_PATH
|
||||
break 2;;
|
||||
"jami-db")
|
||||
local database=$JAMI_DB_NAME
|
||||
local url=$JAMI_DB_S3_PATH
|
||||
break 2;;
|
||||
"Return to main menu.")
|
||||
break 2;;
|
||||
*) msg "${RED}Invalid option:${NOFORMAT} $REPLY
|
||||
";;
|
||||
esac
|
||||
break
|
||||
done
|
||||
done
|
||||
|
||||
if [ -n "$database" ]; then
|
||||
infomsg "Downloading $database dump"
|
||||
download_real_dump $database $url $selection_flag
|
||||
reset_databases $database
|
||||
fi
|
||||
}
|
||||
|
||||
nuke_node_modules() {
|
||||
folders_to_set_up=(
|
||||
"$PROJECT_ROOT"
|
||||
"$PROJECT_ROOT/services/frontend"
|
||||
"$PROJECT_ROOT/services/backend"
|
||||
"$PROJECT_ROOT/services/shared"
|
||||
"$PROJECT_ROOT/updater/sis-updater-scheduler"
|
||||
"$PROJECT_ROOT/updater/sis-updater-worker"
|
||||
)
|
||||
|
||||
named_volumes=(
|
||||
"oodikone-shared-nodemod"
|
||||
"oodikone-frontend-nodemod"
|
||||
"oodikone-backend-nodemod"
|
||||
"oodikone-updater-worker-nodemod"
|
||||
"oodikone-updater-scheduler-nodemod"
|
||||
)
|
||||
|
||||
infomsg "Running docker compose down"
|
||||
"$PROJECT_ROOT"/run.sh both down --remove-orphans || infomsg "brr"
|
||||
for folder in "${folders_to_set_up[@]}"; do
|
||||
cd "$folder" || die "Couldn't change directory to folder $folder"
|
||||
rm -rf node_modules || die "Couldn't remove node_modules in folder $folder"
|
||||
done
|
||||
|
||||
for volume in "${named_volumes[@]}"; do
|
||||
docker volume rm "$volume" || infomsg "Couldn't delete volume $volume (already gone?)"
|
||||
done
|
||||
|
||||
infomsg "Installing npm packages locally to root and each subfolder to enable linting and formatting (and LSP)"
|
||||
|
||||
for folder in "${folders_to_set_up[@]}"; do
|
||||
cd "$folder" || die "Couldn't change directory to folder $folder"
|
||||
([[ -d node_modules ]] && die "$folder already has node_modules, exiting!") || npm ci
|
||||
done
|
||||
|
||||
cd "$PROJECT_ROOT" || die "Couldn't change directory to project root"
|
||||
|
||||
successmsg "Node_module nuke performed"
|
||||
}
|
||||
|
||||
set_up_oodikone_from_scratch() {
|
||||
draw_mopo
|
||||
folders_to_set_up=(
|
||||
"$PROJECT_ROOT"
|
||||
"$PROJECT_ROOT/services/frontend"
|
||||
"$PROJECT_ROOT/services/backend"
|
||||
"$PROJECT_ROOT/services/shared"
|
||||
"$PROJECT_ROOT/updater/sis-updater-scheduler"
|
||||
"$PROJECT_ROOT/updater/sis-updater-worker"
|
||||
)
|
||||
|
||||
infomsg "Cleaning up docker setup and node_modules from possible previous installations"
|
||||
"$PROJECT_ROOT"/run.sh both down --remove-orphans --volumes --rmi all || infomsg "Cleaning errored, but will continue"
|
||||
for folder in "${folders_to_set_up[@]}"; do
|
||||
cd "$folder" || die "Couldn't change directory to folder $folder"
|
||||
rm -rf node_modules || die "Couldn't remove node_modules in folder $folder"
|
||||
done
|
||||
|
||||
infomsg "Installing npm packages locally to root and each subfolder to enable linting and formatting"
|
||||
|
||||
for folder in "${folders_to_set_up[@]}"; do
|
||||
cd "$folder" || die "Couldn't change directory to folder $folder"
|
||||
([[ -d node_modules ]] && die "$folder already has node_modules, exiting!") || npm ci
|
||||
done
|
||||
cd "$PROJECT_ROOT" || die "Couldn't change directory to project root"
|
||||
|
||||
infomsg "Setting up husky for pre-commit linting"
|
||||
npm run prepare
|
||||
|
||||
infomsg "Pulling images"
|
||||
"$PROJECT_ROOT"/run.sh both anon pull
|
||||
|
||||
infomsg "Building images"
|
||||
"$PROJECT_ROOT"/run.sh both anon build
|
||||
|
||||
successmsg "Setup ready, Oodikone can be started! See README for more info."
|
||||
}
|
||||
|
||||
docker_prune() {
|
||||
infomsg "Running: docker system prune -a && docker volume prune -a"
|
||||
docker system prune -a && docker volume prune -a
|
||||
}
|
||||
|
||||
init_dirs() {
|
||||
if [[ ! -d "$DUMP_DIR" ]]; then
|
||||
infomsg "Creating directory for dumps"
|
||||
mkdir "$DUMP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
# If username is not set, get username from data file.
|
||||
# Ask user to provide username, if username was not found from data file.
|
||||
get_s3_config() {
|
||||
if [ ! -f "$S3_CONFIG_FILE" ]; then
|
||||
warningmsg "No username provided, config file is required. The \".s3cfg\" config file can be found on toska/dokumentaatio repo."
|
||||
return 0
|
||||
fi
|
||||
|
||||
infomsg "Using your config $S3_CONFIG_FILE - for getting database dumps"
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This file includes some utility functions that are used in Oodikone scripts
|
||||
# Functions are based on https://betterdev.blog/minimal-safe-bash-script-template/
|
||||
|
||||
# Print messages and logs that are not script output to stderr
|
||||
msg() {
|
||||
echo >&2 -e "${1-}"
|
||||
}
|
||||
|
||||
# Some special types of messages with colours
|
||||
successmsg() {
|
||||
msg "${GREEN}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
errormsg() {
|
||||
msg "${RED}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
infomsg() {
|
||||
msg "${BLUE}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
warningmsg() {
|
||||
msg "${ORANGE}$1${NOFORMAT}
|
||||
"
|
||||
}
|
||||
|
||||
# Quit and exit with given message and error code
|
||||
# By default uses exit code 1, message must be given
|
||||
die() {
|
||||
local msg=$1
|
||||
local code=${2-1}
|
||||
msg "${RED}${msg}${NOFORMAT}"
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
# Setup colors for messages if running interactive shell
|
||||
if [[ -t 2 ]] && [[ "${TERM-}" != "dumb" ]]; then
|
||||
NOFORMAT='\033[0m' RED='\033[0;31m' GREEN='\033[0;32m' ORANGE='\033[0;33m' BLUE='\033[0;34m' PURPLE='\033[0;35m' CYAN='\033[0;36m' YELLOW='\033[1;33m'
|
||||
else
|
||||
NOFORMAT='' RED='' GREEN='' ORANGE='' BLUE='' PURPLE='' CYAN='' YELLOW=''
|
||||
fi
|
||||
@@ -1,117 +0,0 @@
|
||||
# disallow directory named "private", it can be used to store things that should not end up in version control or images
|
||||
# NOTE: needs to be at the root of the project
|
||||
private/
|
||||
gitignored/
|
||||
|
||||
# disallow version control, package managment, IDEs, tools etc
|
||||
**/node_modules
|
||||
**/.venv
|
||||
**/.git
|
||||
**/.github
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/.zed
|
||||
**/coverage/
|
||||
**/.eslintcache
|
||||
**/.DS_Store
|
||||
|
||||
# do not include any data by default (except json)
|
||||
**/*.csv
|
||||
**/*.tsv
|
||||
|
||||
**/*.db
|
||||
**/*.sqlite
|
||||
**/*.sql
|
||||
**/*.psql
|
||||
**/*.sqlz
|
||||
**/*.sqz
|
||||
**/*.sql.gz
|
||||
**/*.bson
|
||||
**/*.jsonl
|
||||
|
||||
**/*.bin
|
||||
**/*.dat
|
||||
**/*.data
|
||||
|
||||
**/*.swp
|
||||
**/*.tmp
|
||||
**/.~lock*
|
||||
**/*.pid
|
||||
**/*.seed
|
||||
**/*.lock
|
||||
|
||||
**/*.gz
|
||||
**/*.tgz
|
||||
**/*.xz
|
||||
**/*.zip
|
||||
**/*.gzip
|
||||
**/*.tar
|
||||
**/*.xz
|
||||
**/*.7z
|
||||
**/*.rar
|
||||
|
||||
**/*.docx
|
||||
**/*.doc
|
||||
**/*.xlsx
|
||||
**/*.xls
|
||||
**/*.pptx
|
||||
**/*.ppt
|
||||
**/*.odt
|
||||
**/*.odp
|
||||
**/*.ods
|
||||
**/*.pdf
|
||||
|
||||
|
||||
# disallow logs
|
||||
**/*.log
|
||||
**/*.log*
|
||||
**/logs/
|
||||
**/log/
|
||||
**/*.cpuprofile
|
||||
**/*.heapprofile
|
||||
**/*.heapsnapshot
|
||||
|
||||
# disallow configuration
|
||||
**/*.yml
|
||||
**/*.yaml
|
||||
**/*.toml
|
||||
**/*.conf
|
||||
**/*.ini
|
||||
**/toska_login/
|
||||
**/toska_login/*
|
||||
**/cypress.env.json
|
||||
cypress/
|
||||
|
||||
|
||||
# disallow secrects
|
||||
**/.env
|
||||
**/.env*
|
||||
**/*.env.*
|
||||
**/.secrects
|
||||
**/*.s3cfg
|
||||
|
||||
# disallow backups
|
||||
**/backups/*
|
||||
**/backups
|
||||
**/backup/*
|
||||
**/backup
|
||||
**/dump/
|
||||
**/dump
|
||||
**/dumps/
|
||||
**/dumps
|
||||
**/databasedumps/
|
||||
**/privateData
|
||||
**/temp
|
||||
**/minio-data/*
|
||||
|
||||
|
||||
# additional inclusions here
|
||||
|
||||
# exclusions here
|
||||
**/.dockerignore
|
||||
**/.gitignore
|
||||
**/.eslintrc.json
|
||||
**/*Dockerfile
|
||||
**/build
|
||||
**/dist
|
||||
**/.npm
|
||||
@@ -1,12 +0,0 @@
|
||||
CI='true'
|
||||
NODE_ENV='test'
|
||||
DB_URL_KONE=postgres://postgres@localhost:4321/kone-db
|
||||
DB_URL_USER=postgres://postgres@localhost:4320/user-db
|
||||
FRONT_URL=http://localhost:3000
|
||||
REDIS=localhost
|
||||
SECRET_TOKEN=test
|
||||
SIS_DB_URL=postgres://postgres@localhost:12345/sis-db
|
||||
SIS_UPDATER_URL=http://localhost:8082
|
||||
JAMI_URL='http://localhost:3003/'
|
||||
IMPORTER_DB_API_URL='https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/importer'
|
||||
CRYPT_KEY='e16730fce263d5270ba8dcf84d068a32556542455e71331880d6c1c5e4db9b4e'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user