mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c367f5b93 | ||
|
|
ee2240f457 | ||
|
|
fb4ecc7785 | ||
|
|
594ffb460b | ||
|
|
d9d7f2ae80 | ||
|
|
8425643639 | ||
|
|
33d5a04dc8 | ||
|
|
abbecf412e | ||
|
|
6809c668e0 | ||
|
|
0db4db47ac | ||
|
|
ab420572eb | ||
|
|
657a96abc0 | ||
|
|
1760a165e4 | ||
|
|
3f311dee69 | ||
|
|
eb6b683296 | ||
|
|
b2153ec3dd | ||
|
|
8949e7baf0 | ||
|
|
1fa10f7812 | ||
|
|
fbe5db8a43 | ||
|
|
d4c7a4014b | ||
|
|
1177c2f848 | ||
|
|
0f8d72fd1d | ||
|
|
47e3e2e26e | ||
|
|
a312a14944 | ||
|
|
e3ec33f8dc | ||
|
|
9c55d3b2ab | ||
|
|
17ce83660c | ||
|
|
22d3877b8e | ||
|
|
c60bee34fd | ||
|
|
15240050b7 | ||
|
|
7da09c8b02 | ||
|
|
cfb2d4d5c0 | ||
|
|
39b594d795 | ||
|
|
ff5bce1713 | ||
|
|
8e288f3b26 | ||
|
|
4134c8b64b | ||
|
|
ddc1a22492 | ||
|
|
8ff1cb54dc | ||
|
|
777de520f9 | ||
|
|
a9e325fe72 | ||
|
|
befc3db3a8 | ||
|
|
e1b8ebd17f | ||
|
|
883129bee0 | ||
|
|
18eb04beeb | ||
|
|
eb12d7e5ec | ||
|
|
26ca0a3bc1 | ||
|
|
b79c97780b | ||
|
|
0cd3bf28ff | ||
|
|
20b0ba5a30 | ||
|
|
67001a9db1 | ||
|
|
8c268395b0 | ||
|
|
235d2b03b2 | ||
|
|
cbe20f1ad3 | ||
|
|
47211dcf89 | ||
|
|
26c6c653e5 | ||
|
|
13bdf698cf | ||
|
|
fd6a65925a | ||
|
|
435d2920fb | ||
|
|
4ae93e0b7e | ||
|
|
dc5200da2f | ||
|
|
373c8f9de8 | ||
|
|
34cc022830 | ||
|
|
ab311ddc66 | ||
|
|
833fdad3bb | ||
|
|
4b634e032d | ||
|
|
a16fd13413 | ||
|
|
58913b4684 | ||
|
|
05c8993ba3 | ||
|
|
d3090ae725 | ||
|
|
c870061fec | ||
|
|
ec43699c3c | ||
|
|
1b1e380a13 | ||
|
|
5c13b5c0ab | ||
|
|
f35fd41bfb | ||
|
|
6548cdd42b | ||
|
|
49b0da760d | ||
|
|
34e9cf5667 | ||
|
|
288d32c591 | ||
|
|
5001d55843 | ||
|
|
eaedfe99db | ||
|
|
6cf375936a | ||
|
|
8bc5c3ea65 | ||
|
|
3e4e749625 | ||
|
|
114af7522d | ||
|
|
c0b29d69c7 | ||
|
|
fb541ffa3a | ||
|
|
0ed4ce7bb4 | ||
|
|
87e1e332a8 | ||
|
|
914440cef9 | ||
|
|
901a4de21c | ||
|
|
2bc28c7dd3 | ||
|
|
2dd43b1ccd | ||
|
|
3fc1bc161b | ||
|
|
1edffd437e | ||
|
|
4ed5e610bf | ||
|
|
90ab1e80f7 | ||
|
|
5b9f3c0184 | ||
|
|
3f81fff6aa | ||
|
|
5058a54099 | ||
|
|
c5fb781f50 | ||
|
|
6c45c5cd76 | ||
|
|
b7e3df2e6b | ||
|
|
35ce0388eb | ||
|
|
5252f3b816 | ||
|
|
48f108c41d | ||
|
|
bade936f3c | ||
|
|
c17721bd54 | ||
|
|
a2e9064376 | ||
|
|
4b92ce4bd9 | ||
|
|
f2348db941 | ||
|
|
b828904c3d | ||
|
|
ed82a26e45 | ||
|
|
b87ee36ebf | ||
|
|
7695315843 | ||
|
|
0a292057cb | ||
|
|
0f5c672f5d | ||
|
|
8ec509aeed | ||
|
|
7f4eab62a7 | ||
|
|
6d82475696 | ||
|
|
bdcdd43230 | ||
|
|
80e91d7bfb | ||
|
|
b15a56b177 | ||
|
|
c2684a4e8e | ||
|
|
4ed8b3d2e9 | ||
|
|
d6c55ed4f9 | ||
|
|
97804b645e | ||
|
|
6b1c3e358a | ||
|
|
1935e36a3a | ||
|
|
15d0acae13 | ||
|
|
ca31e0ed31 | ||
|
|
ab4c7c9ec3 | ||
|
|
6cd3e1bf4f | ||
|
|
f836b3dacd | ||
|
|
066e1267f1 | ||
|
|
eb29a4d261 | ||
|
|
85fc25eec7 | ||
|
|
27d8d73e3d | ||
|
|
4df339fa25 | ||
|
|
0b2349d1c1 | ||
|
|
55f18a4cef | ||
|
|
022efc22bd | ||
|
|
a02e4adfa7 | ||
|
|
92db3af85f | ||
|
|
e112bd3482 | ||
|
|
205a3079ad | ||
|
|
29a0447b3f | ||
|
|
ee65aac42a | ||
|
|
56c59cb86b | ||
|
|
e404fe09e3 | ||
|
|
613164b3da | ||
|
|
7446760798 | ||
|
|
a86a99ef96 | ||
|
|
df7f2b75da | ||
|
|
3f40896f3b | ||
|
|
975d37142a | ||
|
|
6026f04fcf | ||
|
|
12182193e1 | ||
|
|
ca8ad3ea04 | ||
|
|
b21693ba1d | ||
|
|
b99d3ff649 | ||
|
|
2ca49bbe32 | ||
|
|
9015be8458 | ||
|
|
8dc95c7a89 | ||
|
|
4537bd792b | ||
|
|
fcea050b9d | ||
|
|
75049cc641 | ||
|
|
58a8c38c53 | ||
|
|
29723b15aa | ||
|
|
77c3271c25 | ||
|
|
eaee2bac5a | ||
|
|
5a2e8ed4b1 | ||
|
|
128303cc70 | ||
|
|
92dea1c19d | ||
|
|
94669ef98a | ||
|
|
7036d1be28 | ||
|
|
0aff1afcd6 | ||
|
|
6db47e0568 | ||
|
|
2b855654a6 | ||
|
|
5acf0d3e2c | ||
|
|
76f80c7828 | ||
|
|
c0f4acf681 | ||
|
|
31348527a0 | ||
|
|
773c768805 | ||
|
|
f335d38d27 | ||
|
|
892f64f492 | ||
|
|
a12a86ad0e | ||
|
|
3ed549cc75 | ||
|
|
809a42d947 | ||
|
|
ceabbd0628 | ||
|
|
d4485ee3dd | ||
|
|
214df46c4d | ||
|
|
39709d41c7 | ||
|
|
54464f12da | ||
|
|
3a752a1f83 | ||
|
|
12003a9c0b | ||
|
|
d5f3f86b2b | ||
|
|
f37617754d | ||
|
|
1a41d3d8fc | ||
|
|
b83dae4ead | ||
|
|
b1d75d53d1 | ||
|
|
52569cb402 | ||
|
|
cd88b6c41c | ||
|
|
ea5543f712 | ||
|
|
e0fb0ab788 | ||
|
|
adda1c5020 | ||
|
|
c15a113bbd | ||
|
|
d52be3e425 | ||
|
|
5643fe0c07 | ||
|
|
fb673950b7 | ||
|
|
4b052a1b13 | ||
|
|
34d0509572 | ||
|
|
5e8a5022cd | ||
|
|
a989f98524 | ||
|
|
6076b3d45a | ||
|
|
3d4a09f2ef | ||
|
|
c6e40d27a4 | ||
|
|
016d2b6ec8 | ||
|
|
2754874e67 | ||
|
|
64352a6e24 | ||
|
|
4f290d45eb | ||
|
|
27a75877bd | ||
|
|
3f9af3b5f6 | ||
|
|
359a18be92 | ||
|
|
b6a1e07fb4 | ||
|
|
63d8b12273 | ||
|
|
124b203629 | ||
|
|
ada19dc3a8 | ||
|
|
eba2a775ae | ||
|
|
f74342d25d | ||
|
|
8b133fae64 | ||
|
|
6100aaaa0b | ||
|
|
5a2f7196a2 | ||
|
|
53684200ca | ||
|
|
c3b24d3d5d | ||
|
|
20b40ec5c8 | ||
|
|
4244e70d68 | ||
|
|
7480a063c3 | ||
|
|
37b4b31a9a | ||
|
|
159ccc3655 | ||
|
|
380168b425 | ||
|
|
14ed9fa99a | ||
|
|
adcac058a6 | ||
|
|
03efb92936 | ||
|
|
a0f3a1d4ab | ||
|
|
27c3291049 | ||
|
|
706ada2b6f | ||
|
|
41ff70cd45 | ||
|
|
c065cfd2ed | ||
|
|
9ac6b3db84 | ||
|
|
b3d7722d79 | ||
|
|
197fee44ad | ||
|
|
5c6f6ec689 | ||
|
|
6fbd93b97d | ||
|
|
95b3695fc5 | ||
|
|
bb547e2f51 | ||
|
|
858f82d93e | ||
|
|
33cea662b9 | ||
|
|
fc75dfd2dd | ||
|
|
f0fc96f07f | ||
|
|
df08a30f2d | ||
|
|
97d332d50e | ||
|
|
860488977f | ||
|
|
6b1ca59559 | ||
|
|
071038a1fa | ||
|
|
392e28010d | ||
|
|
c1c7d3d254 | ||
|
|
c35701bb5d | ||
|
|
b7e8b6fad1 | ||
|
|
f355577c8a | ||
|
|
2bd78a587a | ||
|
|
294b624e5a | ||
|
|
2eacb56ce2 | ||
|
|
e384d734a6 | ||
|
|
a9ab7621bf | ||
|
|
98ea010d64 | ||
|
|
304b16c73c | ||
|
|
82a726190e | ||
|
|
af21972e88 | ||
|
|
cf7e34f4ce | ||
|
|
57cff95ba3 | ||
|
|
b7c7a24a3f | ||
|
|
0769b38b98 | ||
|
|
6557f7db55 | ||
|
|
9e6b9be0e2 | ||
|
|
52b92e4cb6 | ||
|
|
e23cb37121 | ||
|
|
1c6b9a60ed | ||
|
|
30e7f832f7 | ||
|
|
62b5009391 | ||
|
|
6dd7dbc803 | ||
|
|
e8f4ed8ae6 | ||
|
|
f4678a8714 | ||
|
|
25730450af | ||
|
|
e329f6e311 | ||
|
|
abd48cb8c1 | ||
|
|
033ab8f328 | ||
|
|
8fdac9827b | ||
|
|
8baada888b | ||
|
|
01e6317386 | ||
|
|
17cff4dc5f | ||
|
|
182a35079b | ||
|
|
422e2776e3 | ||
|
|
2e6132e09c | ||
|
|
885de8b64c | ||
|
|
c632173a86 | ||
|
|
2dc1797bc4 | ||
|
|
bba918812c | ||
|
|
b8f77275ff | ||
|
|
39fdeb5321 | ||
|
|
edbd41afc7 | ||
|
|
16c9580ed4 | ||
|
|
82b4e99f7b | ||
|
|
1eb1527260 | ||
|
|
82a4d2a08d | ||
|
|
91fa0106f5 | ||
|
|
ad8c51fce6 | ||
|
|
4d012699e2 | ||
|
|
a5af4ffcae | ||
|
|
cab573eebb | ||
|
|
5d3c5c99c5 | ||
|
|
577de97741 | ||
|
|
60d95e45d2 | ||
|
|
67ab0db6d1 | ||
|
|
34ae6f279b | ||
|
|
1c91554a3b | ||
|
|
e53c0c2de4 | ||
|
|
25deee6016 | ||
|
|
f8cc05afda | ||
|
|
9b46c9f43f | ||
|
|
654a2a6604 | ||
|
|
27a9ac8225 | ||
|
|
b8d93af641 | ||
|
|
33ee9d142d | ||
|
|
25ece19e36 | ||
|
|
39ac7a2665 | ||
|
|
fb35bc0c2b | ||
|
|
ce08567bb4 | ||
|
|
89f08dabc5 | ||
|
|
d6f414dc71 | ||
|
|
d9a8a87c2f | ||
|
|
2bab5c6ef0 | ||
|
|
8cf9eae693 | ||
|
|
535cd08593 | ||
|
|
f3fd84598a | ||
|
|
ceb813636a | ||
|
|
c51f90ab1a | ||
|
|
12a0a38ce0 | ||
|
|
ef25eeae40 | ||
|
|
70b10480a4 | ||
|
|
11bfd0b8c5 | ||
|
|
a45ec5537e | ||
|
|
165fff0163 | ||
|
|
a6df2e8d50 | ||
|
|
bfeb3ef9b1 | ||
|
|
4fdbba9fe1 | ||
|
|
fe52b06696 | ||
|
|
54e60279f7 | ||
|
|
2ef35ced6b | ||
|
|
386290288f | ||
|
|
b1d33dc64c | ||
|
|
7d4c8c99bf | ||
|
|
ed7e03169a | ||
|
|
eaa9a17a49 | ||
|
|
6c463b6b3b | ||
|
|
79c04ba2a6 | ||
|
|
b0c4d8bb8c | ||
|
|
cf0796bb88 | ||
|
|
9a23799972 | ||
|
|
010829b96c | ||
|
|
b921bc0891 | ||
|
|
aa6ce4ab28 | ||
|
|
b5b211e750 | ||
|
|
c114121a7a | ||
|
|
31eb9b2101 | ||
|
|
55c1a5f93b | ||
|
|
2311a9c027 | ||
|
|
62b7864ffe | ||
|
|
f1000a8f25 | ||
|
|
4e566827f7 | ||
|
|
56f5f730c4 | ||
|
|
556999614c | ||
|
|
1c4e3721c6 | ||
|
|
7032902c75 | ||
|
|
e3928ebd5f | ||
|
|
917931378a | ||
|
|
ff93d54470 | ||
|
|
402ee672a1 | ||
|
|
d5fc584d93 | ||
|
|
11a390128f | ||
|
|
f80ef6fb40 | ||
|
|
78e31f0a3e | ||
|
|
651b3b5c0d | ||
|
|
66e666c99a | ||
|
|
464cdff246 | ||
|
|
58c35f21b0 | ||
|
|
ae1c7765a1 | ||
|
|
59e2258da3 | ||
|
|
5f8dc44958 | ||
|
|
c8b4586ea1 | ||
|
|
b04859639f | ||
|
|
8fba294dd8 | ||
|
|
714f3bc953 | ||
|
|
7247049564 | ||
|
|
ff6646ebb4 | ||
|
|
d77c735030 | ||
|
|
aaefbfa9fe | ||
|
|
7ec487412f | ||
|
|
f7c84ca06f | ||
|
|
bc46a18151 | ||
|
|
fcd8eb9284 | ||
|
|
d0c52f301f | ||
|
|
a606c21cd3 | ||
|
|
dc496618f3 | ||
|
|
099e60b617 | ||
|
|
c39fdcd77a | ||
|
|
907e24e26e | ||
|
|
664d8fb83a | ||
|
|
3618d318eb | ||
|
|
cc09af092f | ||
|
|
48a77d2b12 | ||
|
|
99a598e773 | ||
|
|
c9455d2fde | ||
|
|
a343b2460a | ||
|
|
7ca612364b | ||
|
|
6bed41dd9e | ||
|
|
392d5ea3b0 | ||
|
|
537ab19fb6 | ||
|
|
74b566f574 | ||
|
|
509fdeadfa | ||
|
|
1f13389954 | ||
|
|
e699139f4b | ||
|
|
9f9786a3bb | ||
|
|
7c79d23a47 | ||
|
|
85eb3c9634 | ||
|
|
25f7f46a36 | ||
|
|
569e411f7c | ||
|
|
d7f8024d27 | ||
|
|
ca429a7b68 | ||
|
|
01bc1f11f4 | ||
|
|
50aad14a39 | ||
|
|
76624da6c6 | ||
|
|
f7eba6b2d7 | ||
|
|
6aa7762acb | ||
|
|
1f61ab7b81 | ||
|
|
5681ef9f97 | ||
|
|
7e903456ae | ||
|
|
ece089b5b6 | ||
|
|
177b2c39a5 | ||
|
|
f399041163 | ||
|
|
7f238c5ccb | ||
|
|
ce41a9a3e3 | ||
|
|
a117ce0fd4 | ||
|
|
fe5418575e | ||
|
|
08adaed6d7 | ||
|
|
fc8d59af72 | ||
|
|
9e7c84d053 | ||
|
|
3e69cfae20 | ||
|
|
69b9301c17 | ||
|
|
4cae8eaac2 | ||
|
|
6ea3f9a922 | ||
|
|
ea7d4b1554 | ||
|
|
426658f8b0 | ||
|
|
72a6778ae5 | ||
|
|
8c2a2d113e | ||
|
|
27dbf0b575 | ||
|
|
2ff9ae7843 | ||
|
|
ee8a7b016a | ||
|
|
c7831b7f66 | ||
|
|
5895021a48 | ||
|
|
ced54e7a9b | ||
|
|
25cd18e025 | ||
|
|
c08b2cf37c | ||
|
|
2da42ed93e | ||
|
|
f2e7893c60 | ||
|
|
5b348c37db | ||
|
|
bb6ca028c7 | ||
|
|
340d46c739 | ||
|
|
52f77935fc | ||
|
|
d35cbc1085 | ||
|
|
6f17bb002a | ||
|
|
692e704108 | ||
|
|
31c45581e0 | ||
|
|
cb3ed86231 | ||
|
|
de85a38abe | ||
|
|
e28a1e5e6f | ||
|
|
66bd4be4ed | ||
|
|
37fbb5cbb3 | ||
|
|
c31fac4419 | ||
|
|
7760f60ff5 | ||
|
|
7b65539873 | ||
|
|
fe4bda7ed8 | ||
|
|
6ccaec1643 | ||
|
|
df01c3b693 | ||
|
|
b7235ff4b4 | ||
|
|
84d8a6bfec | ||
|
|
24ccb9de58 | ||
|
|
faac3e968e | ||
|
|
3407af8215 | ||
|
|
94d289194a | ||
|
|
39c21b261a | ||
|
|
74ae6f3d46 | ||
|
|
3f9ce90834 | ||
|
|
b235fa7883 | ||
|
|
e2070248be | ||
|
|
97068be78e | ||
|
|
53dd55f874 | ||
|
|
3c99a30a8c | ||
|
|
5fd94fd0d0 | ||
|
|
c36ce33f7f | ||
|
|
ff6bcd6658 | ||
|
|
53e75b6164 | ||
|
|
a65c4d448b | ||
|
|
f9a4d85ce2 | ||
|
|
c610a53168 | ||
|
|
ad40e02442 | ||
|
|
6b6e070deb | ||
|
|
253f31d7d0 | ||
|
|
551775bcec | ||
|
|
70e8d6f033 | ||
|
|
ea505b5d84 | ||
|
|
344a3c00ef | ||
|
|
b29984f589 | ||
|
|
0ad6d6f9a2 | ||
|
|
6bf1d9a590 | ||
|
|
430fc97917 | ||
|
|
7ba2d3489e | ||
|
|
1e68c959b9 | ||
|
|
55b900d1f6 | ||
|
|
1ff637ab9d | ||
|
|
cff045fd3b | ||
|
|
7fce9fd5be | ||
|
|
a2569d1525 | ||
|
|
70e220ba58 | ||
|
|
9482f58930 | ||
|
|
fbc92b6949 | ||
|
|
2b452429c9 | ||
|
|
2eb542a61a | ||
|
|
10ba9153ed | ||
|
|
4eb3946d4f | ||
|
|
691233a91e | ||
|
|
cda44b321c | ||
|
|
bdbed16047 | ||
|
|
ec6dd9d672 | ||
|
|
95ff21a09b | ||
|
|
a464d8cd49 | ||
|
|
d97e1757b1 | ||
|
|
e91239c4e4 | ||
|
|
b0d358db78 | ||
|
|
22643fa590 | ||
|
|
9c5665c216 | ||
|
|
5549202299 | ||
|
|
31004bed07 | ||
|
|
5cdfea1bc2 | ||
|
|
cbb17ef00f | ||
|
|
a75b3b1d5a | ||
|
|
b34f42efd9 | ||
|
|
34e2293bc8 | ||
|
|
71ae263e40 | ||
|
|
b87b129766 | ||
|
|
76c707c322 | ||
|
|
1d0d9f91bf | ||
|
|
da22043197 | ||
|
|
db567235a9 | ||
|
|
08d42c8efd | ||
|
|
53003b2c93 | ||
|
|
9049160b02 | ||
|
|
b40ba0846a | ||
|
|
cae1fe1550 | ||
|
|
180bea21c1 | ||
|
|
f861d55aab | ||
|
|
9f689d5ec7 | ||
|
|
43d67713a6 | ||
|
|
ff15862aaf | ||
|
|
2d00381b0d | ||
|
|
9186ca5b40 | ||
|
|
7b43b7bb06 | ||
|
|
533b6b17bd | ||
|
|
1ffa846243 | ||
|
|
9f7f037e90 | ||
|
|
795bae945d | ||
|
|
c2b0dc46ca | ||
|
|
f22fe44f5a | ||
|
|
2139286a49 | ||
|
|
4e1ad9441a | ||
|
|
3a021e07a7 | ||
|
|
8b14c21f3c | ||
|
|
6bbd53ade9 | ||
|
|
e4e20c8e3c | ||
|
|
3337192b8a | ||
|
|
61ee4475e7 | ||
|
|
43c3dd2998 | ||
|
|
de5e23fdc6 | ||
|
|
355b034a4a | ||
|
|
2321ab7242 | ||
|
|
ecc610bf6d | ||
|
|
bef5e39d22 | ||
|
|
b7d3fbab09 | ||
|
|
c0d7538cab | ||
|
|
7858845b60 | ||
|
|
4c87250206 | ||
|
|
8c52ff707a | ||
|
|
92d9040260 | ||
|
|
874e75670e | ||
|
|
9f60d026ad | ||
|
|
0bb2da9137 | ||
|
|
0977eefbd3 | ||
|
|
99d2e803b6 | ||
|
|
5a4998ce84 | ||
|
|
316c17745d | ||
|
|
bfad75d03e | ||
|
|
c802e3827f | ||
|
|
78f1b2a3e4 | ||
|
|
6b7b9da7c8 | ||
|
|
dfadf975d0 | ||
|
|
3809d0e284 | ||
|
|
b2338981a9 | ||
|
|
be56f8b388 | ||
|
|
bf4c8fa2fb | ||
|
|
d9b8bad314 | ||
|
|
2149cad79c | ||
|
|
718393cb25 | ||
|
|
bb56f3e801 | ||
|
|
08a3f50a5a | ||
|
|
e04315e72b | ||
|
|
56a8463871 | ||
|
|
3895ca5143 | ||
|
|
dbb89ad2d8 | ||
|
|
422f6464fc | ||
|
|
84cd9956ff | ||
|
|
3473d8e546 | ||
|
|
d5e44d4df4 | ||
|
|
843f9d9716 | ||
|
|
521ec7e302 | ||
|
|
7217f9666d | ||
|
|
a40cd6a38c | ||
|
|
8258fa3cf8 | ||
|
|
65b0c83778 | ||
|
|
7eb5511a77 | ||
|
|
378df9cfcd | ||
|
|
0ba959b99e | ||
|
|
609fd2e3bc | ||
|
|
7a43f3f522 | ||
|
|
61959c25b9 | ||
|
|
5146ff9c48 | ||
|
|
481acc3f27 | ||
|
|
df7761e6e7 | ||
|
|
7c2c6478b5 | ||
|
|
284a24c937 | ||
|
|
eedfe5a971 | ||
|
|
e958ba7a5e | ||
|
|
f5ce505a34 | ||
|
|
d596db6f15 | ||
|
|
a8da08bd82 | ||
|
|
f4cf3f2c71 | ||
|
|
d2190579fe | ||
|
|
3805ed1ffe | ||
|
|
14d24dd320 | ||
|
|
da54f92261 | ||
|
|
248627d809 | ||
|
|
bc177a124c | ||
|
|
b5592f4a6c | ||
|
|
31c2ecfbd5 | ||
|
|
57dca3f6c4 | ||
|
|
501604ee7e | ||
|
|
257d2c313c | ||
|
|
3f51d6c073 | ||
|
|
fe5269f0be | ||
|
|
1b8cc1c69f | ||
|
|
fca3c11191 | ||
|
|
b2f0976e14 | ||
|
|
07b8b813ce | ||
|
|
0c45f7f480 | ||
|
|
29112f11e4 | ||
|
|
817eff0490 | ||
|
|
f8c665ca5e | ||
|
|
5abac85e81 | ||
|
|
cf15dfab72 | ||
|
|
9ca60cacc5 | ||
|
|
8747161f0c | ||
|
|
03c13cedd2 | ||
|
|
086d1f89f4 | ||
|
|
3335891b5a | ||
|
|
1b36ac9dfe | ||
|
|
60d08ed9eb | ||
|
|
dc51480e27 | ||
|
|
aa30506df2 | ||
|
|
c929cd2325 | ||
|
|
0fef278dd8 | ||
|
|
1e9cab4b46 | ||
|
|
2beea33a4c | ||
|
|
6d0acb4091 | ||
|
|
6d8d41dbe1 | ||
|
|
e3f6c4a244 | ||
|
|
818b9e4e1c | ||
|
|
b8bbe7634e | ||
|
|
6e8e63b38d |
@@ -4,9 +4,9 @@ description: 'Runs ESLint and Stylelint for frontend and backend'
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
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
|
||||
|
||||
@@ -19,7 +19,7 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: 'Login to toska Docker registry'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ inputs.username }}
|
||||
@@ -27,10 +27,10 @@ runs:
|
||||
|
||||
- name: 'Run services, pull rest of the needed images'
|
||||
shell: bash
|
||||
run: docker compose --file docker-compose.ci.yml up --detach --quiet-pull
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
|
||||
|
||||
- name: 'Run cypress'
|
||||
uses: cypress-io/github-action@v6
|
||||
uses: cypress-io/github-action@v7
|
||||
with:
|
||||
browser: chrome
|
||||
spec: ${{ inputs.spec }}
|
||||
@@ -38,7 +38,7 @@ runs:
|
||||
CYPRESS_baseUrl: http://localhost:1337
|
||||
|
||||
- name: 'Upload video if tests fail'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: 'Cypress videos - ${{ inputs.test_name }}'
|
||||
@@ -47,4 +47,4 @@ runs:
|
||||
- name: 'Print logs on failure'
|
||||
shell: bash
|
||||
if: failure()
|
||||
run: docker compose --file docker-compose.ci.yml logs
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: 'Get current time'
|
||||
id: get_current_time
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
- release_backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
@@ -100,9 +100,9 @@ jobs:
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_URL: https://toska.cs.helsinki.fi/
|
||||
SENTRY_URL: https://toska.it.helsinki.fi/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
|
||||
SENTRY_ORG: toska
|
||||
SENTRY_ORG: sentry
|
||||
SENTRY_PROJECT: oodikone-frontend
|
||||
with:
|
||||
environment: production
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
- release_backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
@@ -134,9 +134,9 @@ jobs:
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_URL: https://toska.cs.helsinki.fi/
|
||||
SENTRY_URL: https://toska.it.helsinki.fi/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
|
||||
SENTRY_ORG: toska
|
||||
SENTRY_ORG: sentry
|
||||
SENTRY_PROJECT: oodikone-backend
|
||||
with:
|
||||
environment: production
|
||||
@@ -157,13 +157,13 @@ jobs:
|
||||
if: success()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
|
||||
with:
|
||||
webhook-url: ${{ secrets.WEBHOOK_URL }}
|
||||
message-type: deployment
|
||||
softa-url: https://oodikone.cs.helsinki.fi/
|
||||
softa-url: https://oodikone.helsinki.fi/
|
||||
deployment-target: production
|
||||
|
||||
report_failure:
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
if: failure()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
|
||||
with:
|
||||
|
||||
@@ -17,14 +17,43 @@ jobs:
|
||||
name: 'Lint packages'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Lint backend, frontend and shared
|
||||
uses: ./.github/actions/lint
|
||||
|
||||
test_api:
|
||||
needs:
|
||||
- lint_packages
|
||||
strategy:
|
||||
fail-fast: false
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install node-24 for cypress
|
||||
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: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
|
||||
- name: Start oodikone detached
|
||||
run: npm run oodikone:ci:setup
|
||||
|
||||
- name: Run api-tests
|
||||
run: npm run oodikone:ci:api
|
||||
|
||||
test_end_to_end:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- lint_packages
|
||||
- test_api
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -41,8 +70,8 @@ jobs:
|
||||
spec: cypress/e2e/Students.js
|
||||
- name: 'Test completed courses'
|
||||
spec: cypress/e2e/Completed_courses.js
|
||||
- name: 'Test study programme overview'
|
||||
spec: cypress/e2e/Study_programme_overview.js
|
||||
- name: 'Test degree programme overview'
|
||||
spec: cypress/e2e/Degree_programme_overview.js
|
||||
- name: 'Test teachers and users'
|
||||
spec: |
|
||||
cypress/e2e/Teachers.js
|
||||
@@ -59,7 +88,7 @@ jobs:
|
||||
spec: cypress/e2e/University.js
|
||||
name: ${{ matrix.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Run tests'
|
||||
uses: ./.github/actions/run-tests
|
||||
with:
|
||||
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/test.yaml
|
||||
uses: UniversityOfHelsinkiCS/oodikone/.github/workflows/test.yaml@master
|
||||
secrets:
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Get current time
|
||||
id: get_current_time
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build backend image
|
||||
id: build-image
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
if: success()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
|
||||
with:
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
if: failure()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
|
||||
with:
|
||||
|
||||
+130
-56
@@ -16,7 +16,7 @@ jobs:
|
||||
name: 'Lint packages'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Lint backend, frontend and shared
|
||||
uses: ./.github/actions/lint
|
||||
|
||||
@@ -25,10 +25,10 @@ jobs:
|
||||
name: 'Build backend'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: actions/checkout@v6
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: 'Build image and export it'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
build-args: |
|
||||
SENTRY_RELEASE=${{ github.sha }}
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
- name: 'Upload image for other jobs'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: backend-image
|
||||
path: /tmp/backend.tar
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
name: 'Build frontend'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get current time
|
||||
id: get_current_time
|
||||
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
|
||||
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Check if new migrations have been added
|
||||
id: check_migrations
|
||||
uses: dorny/paths-filter@v3
|
||||
@@ -98,9 +98,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
@@ -120,15 +120,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Download backend image'
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-image
|
||||
- name: 'Load built backend image'
|
||||
run: docker load --input backend.tar
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: check_migrations
|
||||
with:
|
||||
@@ -157,13 +157,13 @@ jobs:
|
||||
- 'services/backend/src/database/migrations_user/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
- name: 'Download backend image'
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-image
|
||||
- name: 'Load built backend image'
|
||||
run: docker load --input backend.tar
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
@@ -174,15 +174,104 @@ jobs:
|
||||
sleep 30 # wait for backend to start and run migrations
|
||||
./anonyymioodi/commit_and_push_to_toska_hub.sh user-db
|
||||
|
||||
# === End-to-end tests ===
|
||||
test_end_to_end:
|
||||
runs-on: ubuntu-latest
|
||||
# === Other tests ===
|
||||
test_service_exit_statuses:
|
||||
name: 'Check services run correctly'
|
||||
needs:
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
# Always run the tests, even if some of the needed jobs failed or were skipped
|
||||
if: always()
|
||||
- lint_packages
|
||||
if: always() && !cancelled() && needs.lint_packages.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- 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, pull rest of the needed images'
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone up --detach --quiet-pull
|
||||
|
||||
- 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: 'Save Docker images for downstream jobs'
|
||||
run: |
|
||||
docker save \
|
||||
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/kone-db \
|
||||
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/sis-db \
|
||||
registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi/user-db \
|
||||
redis:7.2.6 \
|
||||
postgres:15.10 \
|
||||
"quay.io/toska/jami:production" \
|
||||
nginx:latest \
|
||||
| zstd -T0 -3 -o /tmp/docker-images.tar.zst
|
||||
|
||||
- name: 'Upload Docker images artifact'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: docker-images
|
||||
path: /tmp/docker-images.tar.zst
|
||||
retention-days: 1
|
||||
|
||||
- name: 'Print logs on failure'
|
||||
if: failure()
|
||||
run: docker compose --file docker-compose.ci.yml --profile oodikone logs
|
||||
|
||||
# === Backend api-tests ===
|
||||
test_api:
|
||||
needs:
|
||||
- test_service_exit_statuses
|
||||
if: always() && !cancelled() && needs.test_service_exit_statuses.result == 'success'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install node-24 for cypress
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: 'Download Docker images'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: docker-images
|
||||
path: /tmp
|
||||
|
||||
- name: 'Load Docker images'
|
||||
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
|
||||
|
||||
- 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 oodikone (without frontend and backend)
|
||||
run: npm run oodikone:ci:setup
|
||||
|
||||
- name: Run api-tests
|
||||
run: npm run oodikone:ci:api
|
||||
|
||||
# === End-to-end tests ===
|
||||
test_end_to_end:
|
||||
needs:
|
||||
- test_service_exit_statuses
|
||||
- test_api
|
||||
if: always() && !cancelled() && needs.test_service_exit_statuses.result == 'success'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -199,8 +288,8 @@ jobs:
|
||||
spec: cypress/e2e/Students.js
|
||||
- name: 'Test completed courses'
|
||||
spec: cypress/e2e/Completed_courses.js
|
||||
- name: 'Test study programme overview'
|
||||
spec: cypress/e2e/Study_programme_overview.js
|
||||
- name: 'Test degree programme overview'
|
||||
spec: cypress/e2e/Degree_programme_overview.js
|
||||
- name: 'Test teachers and users'
|
||||
spec: |
|
||||
cypress/e2e/Teachers.js
|
||||
@@ -215,9 +304,27 @@ jobs:
|
||||
spec: cypress/e2e/Study_guidance_groups.js
|
||||
- name: 'Test university'
|
||||
spec: cypress/e2e/University.js
|
||||
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install node-24 for cypress
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: 'Download Docker images'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: docker-images
|
||||
path: /tmp
|
||||
|
||||
- name: 'Load Docker images'
|
||||
run: zstd -d /tmp/docker-images.tar.zst --stdout | docker load
|
||||
|
||||
- name: 'Run tests'
|
||||
uses: ./.github/actions/run-tests
|
||||
with:
|
||||
@@ -225,36 +332,3 @@ jobs:
|
||||
password: ${{ secrets.password }}
|
||||
spec: ${{ matrix.spec }}
|
||||
test_name: ${{ matrix.name }}
|
||||
|
||||
# === Other tests ===
|
||||
test_service_exit_statuses:
|
||||
name: 'Check services run correctly'
|
||||
needs:
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
- build_and_push_anon_user_db
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry-toska.ext.ocp-prod-0.k8s.it.helsinki.fi
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
|
||||
- name: 'Run services, pull rest of the needed images'
|
||||
run: docker compose --file docker-compose.ci.yml up --detach --quiet-pull
|
||||
|
||||
- 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: docker compose --file docker-compose.ci.yml logs
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
name: 'Test (and release) Updater'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
release:
|
||||
types: [published]
|
||||
paths:
|
||||
- '.github/workflows/updater.yaml'
|
||||
- 'updater/sis-updater-worker/**'
|
||||
- 'updater/sis-updater-scheduler/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'updater/sis-updater-worker/**'
|
||||
- 'updater/sis-updater-scheduler/**'
|
||||
|
||||
jobs:
|
||||
lint_scheduler_and_worker:
|
||||
name: 'Lint scheduler and worker'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
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'
|
||||
@@ -33,10 +26,10 @@ jobs:
|
||||
name: 'Build Updater worker'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: actions/checkout@v6
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: 'Build the image'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: updater/sis-updater-worker/
|
||||
push: false
|
||||
@@ -45,7 +38,7 @@ jobs:
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
- name: 'Upload image for other jobs'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: updater-worker-image
|
||||
path: /tmp/worker.tar
|
||||
@@ -54,10 +47,10 @@ jobs:
|
||||
name: 'Build Updater scheduler'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: actions/checkout@v6
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: 'Build the image'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: updater/sis-updater-scheduler/
|
||||
push: false
|
||||
@@ -66,7 +59,7 @@ jobs:
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
- name: 'Upload image for other jobs'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: updater-scheduler-image
|
||||
path: /tmp/scheduler.tar
|
||||
@@ -79,15 +72,15 @@ jobs:
|
||||
- build_worker
|
||||
- build_scheduler
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v3
|
||||
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@v5
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: 'updater-{worker,scheduler}-image'
|
||||
merge-multiple: true
|
||||
@@ -122,9 +115,8 @@ jobs:
|
||||
name: 'Build and release Updater worker'
|
||||
runs-on: ubuntu-latest
|
||||
needs: test_updater
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build worker image
|
||||
id: build-image
|
||||
@@ -152,9 +144,8 @@ jobs:
|
||||
name: 'Build and release Updater scheduler'
|
||||
runs-on: ubuntu-latest
|
||||
needs: test_updater
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build scheduler image
|
||||
id: build-image
|
||||
|
||||
@@ -65,9 +65,6 @@ instrumented/
|
||||
.vscode/launch.json
|
||||
.vscode/terminals.json
|
||||
|
||||
# Funidata specific
|
||||
environments/
|
||||
|
||||
# Target of shared symlink
|
||||
services/**/src/shared
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export default {
|
||||
Dockerfile: files =>
|
||||
`${dockerCmdBase} hadolint/hadolint:v2.12.0 hadolint --ignore DL3006 ${relativeFilePaths(files)}`,
|
||||
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.7.2 ${relativeFilePaths(files)} -x`,
|
||||
'.github/{workflows,actions}/*': files => `npm run actionlint ${relativeFilePaths(files)}`,
|
||||
'docker-compose*': files => {
|
||||
const composeFiles = file => {
|
||||
if (['docker-compose.ci.yml', 'docker-compose.test.yml'].some(f => file.includes(f))) {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"all": true,
|
||||
"include": ["services/frontend/src/**/*.js", "services/frontend/src/**/*.jsx"],
|
||||
"extends": "./nyc-config.js"
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"extends": ["stylelint-config-standard"],
|
||||
"rules": {
|
||||
"selector-class-pattern": "^[a-z][a-zA-Z-]*$"
|
||||
"selector-class-pattern": "^(?:[a-z][a-zA-Z-]*|Mui[A-Za-z-]+)$"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +112,6 @@ The `documentation` directory contains some notes on the sis-db schema and is me
|
||||
|
||||
For internal meeting notes and sensitive information, see Toska GitLab.
|
||||
|
||||
[SortableTable documentation](./services/frontend/src/components/SortableTable/README.md)
|
||||
|
||||
### 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**.
|
||||
|
||||
@@ -6,14 +6,14 @@ The quick guide should be enough for most purposes: updating schema, data, or th
|
||||
|
||||
## 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.
|
||||
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_1`).
|
||||
- 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:
|
||||
|
||||
@@ -67,6 +67,6 @@ The quick guide should be enough for most purposes: updating schema, data, or th
|
||||
|
||||
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_1`). If you see any errors, try clicking the buttons again.
|
||||
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.
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
const { defineConfig } = require('cypress')
|
||||
|
||||
module.exports = defineConfig({
|
||||
experimentalStudio: process.env.NODE_ENV === 'development',
|
||||
projectId: 'c3jsph',
|
||||
defaultCommandTimeout: 30000,
|
||||
requestTimeout: 30000,
|
||||
@@ -14,7 +13,6 @@ module.exports = defineConfig({
|
||||
// We've imported your old cypress plugins here.
|
||||
// You may want to clean this up later by importing these.
|
||||
setupNodeEvents(on, config) {
|
||||
require('@cypress/code-coverage/task')(on, config)
|
||||
require('./cypress/plugins/index.js')(on, config)
|
||||
return config
|
||||
},
|
||||
|
||||
@@ -17,32 +17,32 @@ const openSearch = () => {
|
||||
}
|
||||
|
||||
const selectSavedCourselist = name => {
|
||||
cy.get('[data-cy=history-search]').click()
|
||||
cy.get('[data-cy=history-search]').type(name)
|
||||
cy.get('[data-cy=history-search]').type('{downarrow}')
|
||||
cy.get('[data-cy=history-search]').type('{enter}')
|
||||
cy.cs('history-search').click()
|
||||
cy.cs('history-search').type(name)
|
||||
cy.cs('history-search').type('{downarrow}')
|
||||
cy.cs('history-search').type('{enter}')
|
||||
}
|
||||
|
||||
const createCourseList = (courseCodes, courseListName) => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy=course-list-input]').type(courseCodes.join('\n'))
|
||||
cy.get('[data-cy=search-name]').type(courseListName)
|
||||
cy.get('[data-cy=save-courselist]').click()
|
||||
cy.cs('course-list-input').type(courseCodes.join('\n'))
|
||||
cy.cs('search-name').type(courseListName)
|
||||
cy.cs('save-courselist').click()
|
||||
}
|
||||
|
||||
const generateCourseListName = () => `TEST-course-list-${new Date().getTime()}`
|
||||
|
||||
const deleteAllPreviousSearches = () => {
|
||||
cy.get('[data-cy=history-search]').click()
|
||||
cy.cs('history-search').click()
|
||||
|
||||
cy.get('.MuiAutocomplete-popper li').then($options => {
|
||||
if ($options.length > 0) {
|
||||
cy.wrap($options).each(($option, index, $list) => {
|
||||
cy.wrap($option).click()
|
||||
cy.get('[data-cy=delete-courselist]').click()
|
||||
cy.cs('delete-courselist').click()
|
||||
if (index < $list.length - 1) {
|
||||
cy.get('[data-cy=history-search]').click()
|
||||
cy.cs('history-search').click()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -57,7 +57,7 @@ describe('When search modal is opened', () => {
|
||||
hasLanded()
|
||||
cy.url().should('include', '/completedcoursessearch')
|
||||
cy.contains('Search completed courses of students')
|
||||
cy.get('[data-cy=open-completed-courses-modal-button]').click()
|
||||
cy.cs('open-completed-courses-modal-button').click()
|
||||
})
|
||||
|
||||
it('Modal opens correctly', () => {
|
||||
@@ -74,8 +74,8 @@ describe('When search modal is opened', () => {
|
||||
cy.init('/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&studentList=433237&studentList=457144')
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.contains('[data-cy=student-no-input]', '433237, 457144')
|
||||
cy.contains('[data-cy=course-list-input]', 'TKT10001, TKT10002')
|
||||
cy.cs('student-no-input').contains('433237, 457144')
|
||||
cy.cs('course-list-input').contains('TKT10001, TKT10002')
|
||||
})
|
||||
|
||||
describe('When a search is executed with invalid input', () => {
|
||||
@@ -83,16 +83,16 @@ describe('When search modal is opened', () => {
|
||||
cy.init('/completedcoursessearch')
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy=student-no-input]').type('1')
|
||||
cy.get('[data-cy=course-list-input]').type('1')
|
||||
cy.get('[data-cy=completed-courses-search-button]').click()
|
||||
cy.get('[data-cy=rights-notification]').should(
|
||||
cy.cs('student-no-input').type('1')
|
||||
cy.cs('course-list-input').type('1')
|
||||
cy.cs('completed-courses-search-button').click()
|
||||
cy.cs('rights-notification').should(
|
||||
'contain.text',
|
||||
'The information for the following students could not be displayed'
|
||||
)
|
||||
cy.contains('[data-cy=rights-notification]', '1')
|
||||
cy.contains('[data-cy=completed-courses-table-div]', 'Student number')
|
||||
cy.contains('[data-cy=completed-courses-table-div]', '1').should('not.exist')
|
||||
cy.cs('rights-notification').contains('1')
|
||||
cy.cs('completed-courses-table-div').contains('Student number')
|
||||
cy.cs('completed-courses-table-div').contains('1').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,9 +102,9 @@ describe('When search modal is opened', () => {
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
|
||||
cy.get('[data-cy=student-no-input]').type(studentSet1.join('\n'))
|
||||
cy.get('[data-cy=course-list-input]').type(coursesSet1.join('\n'))
|
||||
cy.get('[data-cy=completed-courses-search-button]').click()
|
||||
cy.cs('student-no-input').type(studentSet1.join('\n'))
|
||||
cy.cs('course-list-input').type(coursesSet1.join('\n'))
|
||||
cy.cs('completed-courses-search-button').click()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,10 +120,10 @@ describe('When search modal is opened', () => {
|
||||
it('Finds correct students and courses', () => {
|
||||
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
|
||||
for (const studentNumber of studentSet1) {
|
||||
cy.contains('[data-cy=completed-courses-table-div]', studentNumber)
|
||||
cy.cs('completed-courses-table-div').contains(studentNumber)
|
||||
}
|
||||
for (const courseCode of coursesSet1) {
|
||||
cy.contains('[data-cy=completed-courses-table-div]', courseCode)
|
||||
cy.cs('completed-courses-table-div').contains(courseCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -150,7 +150,7 @@ describe('Courselist saving-related functions work', () => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.contains('[data-cy=course-list-input]', courses.join(', '))
|
||||
cy.cs('course-list-input').contains(courses.join(', '))
|
||||
})
|
||||
|
||||
it('Course list can be deleted', () => {
|
||||
@@ -159,7 +159,7 @@ describe('Courselist saving-related functions work', () => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.get('[data-cy=delete-courselist]').click()
|
||||
cy.cs('delete-courselist').click()
|
||||
cy.contains('You have no previous searches.')
|
||||
})
|
||||
|
||||
@@ -169,11 +169,11 @@ describe('Courselist saving-related functions work', () => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.get('[data-cy=course-list-input]').type(',TKT10001')
|
||||
cy.get('[data-cy=save-courselist]').click()
|
||||
cy.cs('course-list-input').type(',TKT10001')
|
||||
cy.cs('save-courselist').click()
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.contains('[data-cy=course-list-input]', 'CSM14204, TKT10004, TKT10001')
|
||||
cy.cs('course-list-input').contains('CSM14204, TKT10004, TKT10001')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,7 @@ const checkGradeTable = gradesTableContents => {
|
||||
}
|
||||
|
||||
const checkTableContents = contents => {
|
||||
cy.get('table tbody').scrollIntoView()
|
||||
cy.get('table tbody').within(() => {
|
||||
contents.forEach((values, trIndex) => {
|
||||
cy.get('tr')
|
||||
@@ -117,7 +118,7 @@ describe('Basic user', () => {
|
||||
|
||||
searchByCourseCode(coursecode1)
|
||||
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains('td', coursecode1).click()
|
||||
cy.cs(`course-${coursecode1}`).click()
|
||||
|
||||
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.url().should('contain', `courseCodes=%5B%22${coursecode1}%22%5D`)
|
||||
@@ -135,7 +136,7 @@ describe('Basic user', () => {
|
||||
|
||||
searchByCourseCode(coursecode2)
|
||||
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains('td', coursecode2).click()
|
||||
cy.cs(`course-${coursecode2}`).click()
|
||||
|
||||
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.url().should('contain', `courseCodes=%5B%22${coursecode2}%22%5D`)
|
||||
@@ -154,7 +155,7 @@ describe('Basic user', () => {
|
||||
|
||||
searchByCourseCode(coursecode)
|
||||
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains('td', `${coursecode},`).click()
|
||||
cy.cs(`course-${coursecode}`).click()
|
||||
|
||||
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.url().should('contain', `courseCodes=%5B%22${coursecode}%22%5D`)
|
||||
@@ -179,7 +180,7 @@ describe('Basic user', () => {
|
||||
|
||||
searchByCourseCode(coursecode)
|
||||
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains('td', coursecode).click()
|
||||
cy.cs(`course-${coursecode}`).click()
|
||||
|
||||
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.url().should('contain', `courseCodes=%5B%22${coursecode}%22%5D`)
|
||||
@@ -208,7 +209,7 @@ describe('Basic user', () => {
|
||||
|
||||
searchByCourseCode(coursecode)
|
||||
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains('td', coursecode).click()
|
||||
cy.cs(`course-${coursecode}`).click()
|
||||
|
||||
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.url().should('contain', `courseCodes=%5B%22${coursecode}%22%5D`)
|
||||
@@ -222,7 +223,7 @@ describe('Basic user', () => {
|
||||
|
||||
cy.wait('@coursestats').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains(
|
||||
'Population of course Tieteellisen kirjoittamisen seminaarin alkuopetus: Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot 2011-2018 (open and normal)'
|
||||
'Population of course Tieteellisen kirjallisen työn ja tiedonhankinnan perustaidot 2011-2018 (open and normal)'
|
||||
)
|
||||
cy.contains('Showing 4 out of 4 students')
|
||||
|
||||
@@ -241,7 +242,7 @@ describe('Basic user', () => {
|
||||
|
||||
searchByCourseCode(coursecode)
|
||||
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains('td', coursecode).click()
|
||||
cy.cs(`course-${coursecode}`).click()
|
||||
|
||||
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.url().should('contain', `courseCodes=%5B%22${coursecode}%22%5D`)
|
||||
@@ -281,8 +282,8 @@ describe('Basic user', () => {
|
||||
|
||||
searchByCourseCode('TKT')
|
||||
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains('td', /^TKT20001/).click()
|
||||
cy.contains('td', /^TKT10002/).click()
|
||||
cy.cs(`course-TKT20001`).click()
|
||||
cy.cs(`course-TKT10002`).click()
|
||||
|
||||
cy.contains('Fetch statistics').should('be.enabled')
|
||||
cy.contains('Fetch statistics').click()
|
||||
@@ -292,15 +293,16 @@ describe('Basic user', () => {
|
||||
|
||||
openSummaryTab()
|
||||
cy.contains('Tietorakenteet ja algoritmit').click()
|
||||
cy.contains('Ohjelmoinnin perusteet').should('not.exist')
|
||||
cy.contains('TKT20001')
|
||||
cy.contains('58131')
|
||||
cy.contains('TKT10002').should('exist')
|
||||
cy.contains('TKT20001').should('exist')
|
||||
cy.contains('58131').should('not.exist')
|
||||
|
||||
openSummaryTab()
|
||||
cy.contains('Ohjelmoinnin perusteet').click()
|
||||
cy.contains('TKT10002').should('exist')
|
||||
cy.contains('TKT20001').should('exist')
|
||||
cy.contains('Käyttöjärjestelmät').should('not.exist')
|
||||
cy.contains('TKT10002')
|
||||
cy.contains('581325')
|
||||
cy.contains('581325').should('not.exist')
|
||||
openSummaryTab()
|
||||
})
|
||||
})
|
||||
@@ -308,12 +310,12 @@ describe('Basic user', () => {
|
||||
it('On searches with multiple courses, has correct links on the Course tab', () => {
|
||||
cy.intercept({
|
||||
pathname: '/api/v2/coursesmulti',
|
||||
query: { code: 'TKT' },
|
||||
query: { code: 'BSCS' },
|
||||
}).as('courses')
|
||||
|
||||
cy.intercept({
|
||||
pathname: '/api/v3/courseyearlystats',
|
||||
query: { 'codes[]': 'TKT20001', 'codes[]': 'TKT10002' },
|
||||
query: { 'codes[]': 'BSCS1003', 'codes[]': 'BSCS1001' },
|
||||
}).as('yearlystats')
|
||||
|
||||
cy.intercept({
|
||||
@@ -324,16 +326,16 @@ describe('Basic user', () => {
|
||||
cy.cs('select-multiple-courses-toggle').click()
|
||||
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
|
||||
|
||||
searchByCourseCode('TKT')
|
||||
searchByCourseCode('BSCS')
|
||||
cy.wait('@courses').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains('td', /^TKT20001/).click()
|
||||
cy.contains('td', /^TKT10002/).click()
|
||||
cy.cs(`course-BSCS1003`).click()
|
||||
cy.cs(`course-BSCS1001`).click()
|
||||
|
||||
cy.contains('Fetch statistics').should('be.enabled')
|
||||
cy.contains('Fetch statistics').click()
|
||||
|
||||
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.url().should('contain', 'courseCodes=%5B%22TKT10002%22%2C%22TKT20001%22%5D')
|
||||
cy.url().should('contain', 'courseCodes=%5B%22BSCS1001%22%2C%22BSCS1003%22%5D')
|
||||
|
||||
cy.cs('course-population-for-2023-2024').click()
|
||||
cy.contains('Population of course Introduction to Programming 2023-2024 (open and normal)')
|
||||
@@ -343,9 +345,11 @@ describe('Basic user', () => {
|
||||
// necessary if the test is flaky.
|
||||
cy.go('back')
|
||||
cy.cs('CourseSelector').click()
|
||||
cy.cs('CourseSelectorOptionTKT20001').click()
|
||||
cy.cs('CourseSelectorOptionBSCS1003').click()
|
||||
// Check the link has updated correctly
|
||||
cy.cs('course-population-for-2022-2023').invoke('attr', 'href').should('include', 'BSCS1003')
|
||||
cy.cs('course-population-for-2022-2023').click()
|
||||
cy.contains('Population of course Tietorakenteet ja algoritmit')
|
||||
cy.contains('Population of course Data Structures and Algorithms 2022-2023 (open and normal)')
|
||||
})
|
||||
|
||||
it('Searching course by name displays right courses', () => {
|
||||
@@ -353,7 +357,7 @@ describe('Basic user', () => {
|
||||
searchByCourseName('tietokantojen perusteet')
|
||||
|
||||
cy.contains('Tietokantojen perusteet')
|
||||
cy.contains('td', 'TKT10004, BSCS2001, 581328, A581328, AYTKT10004').click()
|
||||
cy.cs(`course-TKT10004`).click()
|
||||
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
@@ -370,7 +374,7 @@ describe('Basic user', () => {
|
||||
cy.contains('Search for courses')
|
||||
searchByCourseName('tietorakenteet ja algoritmit')
|
||||
cy.contains('Tietorakenteet ja algoritmit')
|
||||
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
|
||||
cy.cs(`course-TKT20001`).click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
|
||||
@@ -380,7 +384,7 @@ describe('Basic user', () => {
|
||||
clickNewQueryButton()
|
||||
cy.contains('Please enter at least 5 characters for course name or 2 characters for course code.')
|
||||
searchByCourseName('tietorakenteet ja algoritmit')
|
||||
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
|
||||
cy.cs(`course-TKT20001`).click()
|
||||
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
|
||||
@@ -393,7 +397,6 @@ describe('Basic user', () => {
|
||||
cy.contains('Search for courses')
|
||||
searchByCourseCode('TKT')
|
||||
cy.cs('select-multiple-courses-toggle').should('not.have.class', 'Mui-checked')
|
||||
cy.contains('TKT10004, BSCS2001, 581328, A581328, AYTKT10004')
|
||||
cy.contains('Select all search results').should('not.exist')
|
||||
cy.cs('select-multiple-courses-toggle').click()
|
||||
cy.cs('select-multiple-courses-toggle').should('have.class', 'Mui-checked')
|
||||
@@ -404,7 +407,7 @@ describe('Basic user', () => {
|
||||
cy.contains('Search for courses')
|
||||
searchByCourseName('tietokantojen perusteet')
|
||||
|
||||
cy.contains('td', /^TKT10004/).click()
|
||||
cy.cs(`course-TKT10004`).click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
|
||||
cy.contains('TKT10004 • Tietokantojen perusteet')
|
||||
@@ -448,7 +451,7 @@ describe('Basic user', () => {
|
||||
|
||||
it('Population of course shows grades for each student', () => {
|
||||
searchByCourseCode('TKT20001')
|
||||
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
|
||||
cy.cs(`course-TKT20001`).click()
|
||||
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 • Data Structures and Algorithms')
|
||||
@@ -456,13 +459,13 @@ describe('Basic user', () => {
|
||||
cy.cs('course-population-for-2019-2020').click()
|
||||
cy.contains('Population of course Tietorakenteet ja algoritmit 2019-2020 (open and normal)')
|
||||
cy.contains('Students (33)').click()
|
||||
cy.contains('td', '394776').siblings().eq(1).contains('3')
|
||||
cy.contains('td', '497388').siblings().eq(1).contains('2')
|
||||
cy.contains('td', '394776').siblings().eq(2).contains('3')
|
||||
cy.contains('td', '497388').siblings().eq(2).contains('2')
|
||||
})
|
||||
|
||||
it("In 'Course population' view, student numbers of students that the user isn't allowed to see are hidden", () => {
|
||||
searchByCourseCode('TKT20001')
|
||||
cy.contains('td', 'TKT20001, BSCS1003, 58131, AYTKT20001').click()
|
||||
cy.cs(`course-TKT20001`).click()
|
||||
cy.contains('TKT20001 • Tietorakenteet ja algoritmit')
|
||||
cy.contains('AYTKT20001 • Avoin yo: Tietorakenteet ja algoritmit')
|
||||
cy.contains('BSCS1003 • Data Structures and Algorithms')
|
||||
@@ -476,7 +479,7 @@ describe('Basic user', () => {
|
||||
|
||||
it('Language distribution is correct', () => {
|
||||
searchByCourseCode('TKT20003')
|
||||
cy.contains('td', 'TKT20003, 582219').click()
|
||||
cy.cs(`course-TKT20003`).click()
|
||||
cy.contains('TKT20003 • Käyttöjärjestelmät')
|
||||
cy.contains('582219 • Käyttöjärjestelmät')
|
||||
cy.cs('course-population-for-2021-2022').click()
|
||||
@@ -494,7 +497,7 @@ describe('Basic user', () => {
|
||||
cy.cs('combine-substitutions-toggle').should('have.class', 'Mui-checked').click()
|
||||
cy.cs('combine-substitutions-toggle').should('not.have.class', 'Mui-checked')
|
||||
searchByCourseCode('TKT10002')
|
||||
cy.contains('td', /^TKT10002$/).click()
|
||||
cy.cs(`course-TKT10002`).click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('TKT10002 • Ohjelmoinnin perusteet')
|
||||
})
|
||||
@@ -727,7 +730,7 @@ describe('Basic user', () => {
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('Search for courses')
|
||||
searchByCourseCode('TKT10002')
|
||||
cy.contains('td', /^TKT10002, BSCS1001, 581325, A581325, AYTKT10002$/).click()
|
||||
cy.cs(`course-TKT10002`).click()
|
||||
cy.contains('Search for courses').should('not.exist')
|
||||
cy.contains('TKT10002 • Ohjelmoinnin perusteet')
|
||||
cy.contains('AYTKT10002 • Avoin yo: Ohjelmoinnin perusteet')
|
||||
@@ -1201,7 +1204,7 @@ describe('Only course statistics', () => {
|
||||
cy.wait('@yearlystats').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.url().should('contain', `courseCodes=%5B%22${coursecode}%22%5D`)
|
||||
|
||||
cy.contains('Filter statistics by study programmes').should('not.exist')
|
||||
cy.contains('Filter statistics by degree programmes').should('not.exist')
|
||||
cy.contains('Show population').should('not.exist')
|
||||
cy.contains('Faculty statistics').should('be.disabled')
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const openCustomPopupForm = () => {
|
||||
cy.get('[data-cy="custom-pop-search-button"]').click()
|
||||
}
|
||||
|
||||
const fillName = () => {
|
||||
const name = `TEST-${new Date().getTime()}`
|
||||
cy.contains('Insert name for this custom population if you wish to save it')
|
||||
cy.get('[data-cy="custom-population-name-input"]').type(name)
|
||||
cy.contains('Insert a name for this custom population if you wish to save it')
|
||||
cy.cs('custom-population-name-input').type(name)
|
||||
return name
|
||||
}
|
||||
|
||||
@@ -16,38 +12,33 @@ const save = () => {
|
||||
}
|
||||
|
||||
const selectSavedPopulation = name => {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(`${name}{enter}`)
|
||||
cy.cs('history-search').type(`${name}{enter}`)
|
||||
}
|
||||
|
||||
const deleteAllSearches = () => {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).click()
|
||||
cy.contains('Saved populations')
|
||||
.parent()
|
||||
.parent()
|
||||
.parent()
|
||||
.parent()
|
||||
.get('.dropdown')
|
||||
.then(dropdown => {
|
||||
const searchItems = dropdown.find('div[role=option] > span[class=text]')
|
||||
for (let i = 0; i < searchItems.length; i++) {
|
||||
if (searchItems[i].textContent.includes('TEST-')) {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(`${searchItems[i].textContent}{enter}`)
|
||||
cy.get('button').contains('Delete').click()
|
||||
}
|
||||
}
|
||||
cy.cs('history-search').click()
|
||||
cy.get('.MuiAutocomplete-popper > .MuiPaper-root')
|
||||
.find('li[role=option]')
|
||||
.then($lis => {
|
||||
const labels = [...$lis].map(li => li.textContent?.trim() || '')
|
||||
|
||||
labels.forEach((label, index) => {
|
||||
if (index !== 0) cy.cs('history-search').click()
|
||||
cy.contains('.MuiAutocomplete-popper li[role=option]', label).click()
|
||||
cy.contains('button', 'Delete').click()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const fillForm = (content, separator) => {
|
||||
cy.get('[data-cy="student-number-input"]').type(content.join(separator))
|
||||
cy.cs('student-number-input').type(content.join(separator))
|
||||
}
|
||||
|
||||
const search = () => {
|
||||
cy.get('[data-cy="search-button"]').click()
|
||||
cy.cs('search-button').click()
|
||||
}
|
||||
|
||||
const searchFor = (studentnumbers, separator) => {
|
||||
openCustomPopupForm()
|
||||
fillForm(studentnumbers, separator)
|
||||
search()
|
||||
}
|
||||
@@ -74,7 +65,7 @@ const containsSpecificStudents = (studentnumbers = []) => {
|
||||
}
|
||||
|
||||
const checkRightsNotification = studentNumbers => {
|
||||
cy.get('[data-cy="rights-notification"]').within(() => {
|
||||
cy.cs('rights-notification').within(() => {
|
||||
cy.contains(
|
||||
'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.'
|
||||
)
|
||||
@@ -96,7 +87,6 @@ describe('Custom population tests', () => {
|
||||
cy.visit('/custompopulation')
|
||||
cy.url().should('include', '/custompopulation')
|
||||
cy.contains('Custom population')
|
||||
cy.get('button').contains('Custom population').click()
|
||||
deleteAllSearches()
|
||||
})
|
||||
|
||||
@@ -126,7 +116,7 @@ describe('Custom population tests', () => {
|
||||
cy.contains('Credit accumulation').should('not.exist')
|
||||
cy.contains('Programme distribution').should('not.exist')
|
||||
cy.contains('Courses of population').should('not.exist')
|
||||
cy.get('button').contains('Custom population')
|
||||
cy.get('button').contains('Back to search form')
|
||||
})
|
||||
|
||||
it("Doesn't return students user has no right to", () => {
|
||||
@@ -138,16 +128,15 @@ describe('Custom population tests', () => {
|
||||
cy.contains('Credit accumulation').should('not.exist')
|
||||
cy.contains('Programme distribution').should('not.exist')
|
||||
cy.contains('Courses of population').should('not.exist')
|
||||
cy.get('button').contains('Custom population')
|
||||
cy.get('button').contains('Back to search form')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom population search saving', () => {
|
||||
it('Saves a custom population search', { retries: 2 }, () => {
|
||||
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1: students }) => {
|
||||
openCustomPopupForm()
|
||||
cy.contains(
|
||||
'Insert student numbers you wish to use for population. Separate each number with a comma, semicolon, space, or newline.'
|
||||
'Insert student numbers to use for the population. Each student number needs to be separated with a comma, a semicolon, a space, or a line break.'
|
||||
)
|
||||
const name = fillName()
|
||||
// A comma and a newline on purpose, the page should be able to handle it
|
||||
@@ -157,17 +146,16 @@ describe('Custom population tests', () => {
|
||||
// Round 1
|
||||
selectSavedPopulation(name)
|
||||
search()
|
||||
cy.contains(`Custom population "${name}"`)
|
||||
cy.contains(`Custom population: ${name}`)
|
||||
containsAmountOfStudents(students.length)
|
||||
containsSpecificStudents(students)
|
||||
|
||||
// Round 2
|
||||
cy.visit('/custompopulation')
|
||||
cy.contains('Custom population')
|
||||
openCustomPopupForm()
|
||||
selectSavedPopulation(name)
|
||||
search()
|
||||
cy.contains(`Custom population "${name}"`)
|
||||
cy.contains(`Custom population: ${name}`)
|
||||
containsAmountOfStudents(students.length)
|
||||
containsSpecificStudents(students)
|
||||
})
|
||||
@@ -176,28 +164,25 @@ describe('Custom population tests', () => {
|
||||
it('Updates a custom population search', () => {
|
||||
cy.fixture('customPopulations').then(
|
||||
({ studentNumbersForCSStudentsSet1: students1, studentNumbersForCSStudentsSet2: students2 }) => {
|
||||
openCustomPopupForm()
|
||||
const name = fillName()
|
||||
fillForm(students1, ' ')
|
||||
save()
|
||||
|
||||
selectSavedPopulation(name)
|
||||
search()
|
||||
cy.contains(`Custom population "${name}"`)
|
||||
cy.contains(`Custom population: ${name}`)
|
||||
containsAmountOfStudents(students1.length)
|
||||
containsSpecificStudents(students1)
|
||||
cy.contains('Custom population')
|
||||
openCustomPopupForm()
|
||||
cy.contains('Back to search form').click()
|
||||
selectSavedPopulation(name)
|
||||
fillForm(['\n', ...students2], ', ')
|
||||
save()
|
||||
|
||||
cy.visit('/custompopulation')
|
||||
cy.contains('Custom population')
|
||||
openCustomPopupForm()
|
||||
selectSavedPopulation(name)
|
||||
search()
|
||||
cy.contains(`Custom population "${name}"`)
|
||||
cy.contains(`Custom population: ${name}`)
|
||||
containsAmountOfStudents(students1.length + students2.length)
|
||||
containsSpecificStudents([...students1, ...students2])
|
||||
}
|
||||
|
||||
+140
-112
@@ -30,8 +30,8 @@ const deleteTag = tagName => {
|
||||
cy.cs('confirm-delete-tag-button').click()
|
||||
}
|
||||
|
||||
describe('Study programme overview', () => {
|
||||
describe('Study programme selector', () => {
|
||||
describe('Degree programme overview', () => {
|
||||
describe('Degree programme selector', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme', 'admin')
|
||||
})
|
||||
@@ -43,7 +43,7 @@ describe('Study programme overview', () => {
|
||||
cy.contains('Doctoral programmes')
|
||||
})
|
||||
|
||||
it('Study programme search filter', () => {
|
||||
it('Degree programme search filter', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').should('exist')
|
||||
cy.cs('study-programme-filter').type('Tietojenkäsittelytieteen')
|
||||
@@ -93,31 +93,14 @@ describe('Study programme overview', () => {
|
||||
it('Basic information contains correct credits', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 222],
|
||||
[2022, 3235, 3205, 0, 30, 209],
|
||||
[2021, 5133, 5108, 0, 25, 428],
|
||||
[2020, 5801, 5796, 0, 5, 94],
|
||||
[2019, 5305, 5305, 0, 0, 162],
|
||||
[2018, 3442, 3432, 0, 10, 21],
|
||||
[2017, 1211, 1211, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
it("Toggling 'Show special categories' on displays additional information", () => {
|
||||
cy.cs('special-categories-toggle').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 0, 0, 222],
|
||||
[2022, 3235, 3205, 0, 30, 0, 0, 209],
|
||||
[2021, 5133, 5108, 0, 25, 0, 0, 428],
|
||||
[2020, 5801, 5796, 0, 5, 0, 0, 94],
|
||||
[2019, 5305, 5305, 0, 0, 0, 0, 162],
|
||||
[2018, 3442, 3432, 0, 10, 0, 0, 21],
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 189],
|
||||
...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, 0],
|
||||
[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],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
@@ -190,14 +173,14 @@ describe('Study programme overview', () => {
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const creditTableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 160, 160, 0, 0, 67],
|
||||
['2022 - 2023', 2725, 2720, 0, 5, 337],
|
||||
['2021 - 2022', 4092, 4042, 0, 50, 198],
|
||||
['2020 - 2021', 5420, 5415, 0, 5, 321],
|
||||
['2019 - 2020', 6043, 6043, 0, 0, 101],
|
||||
['2018 - 2019', 4856, 4851, 0, 5, 107],
|
||||
['2017 - 2018', 2350, 2345, 0, 5, 26],
|
||||
...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, 0],
|
||||
['2018 - 2019', 4856, 4851, 0, 0, 5, 0, 0, 107, 0],
|
||||
['2017 - 2018', 2350, 2345, 0, 0, 5, 0, 0, 26, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
|
||||
@@ -231,10 +214,10 @@ describe('Study programme overview', () => {
|
||||
cy.contains('2022')
|
||||
cy.contains('48 graduated').trigger('mouseover')
|
||||
cy.contains('48 students graduated in year 2022')
|
||||
cy.contains('median study time: 44.5 months')
|
||||
cy.contains('22 graduated on time')
|
||||
cy.contains('12 graduated max year overtime')
|
||||
cy.contains('14 graduated over year late')
|
||||
cy.contains('median study time: 6 semesters')
|
||||
cy.contains('26 graduated on time')
|
||||
cy.contains('10 graduated max year overtime')
|
||||
cy.contains('12 graduated over year late')
|
||||
})
|
||||
|
||||
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
|
||||
@@ -262,7 +245,7 @@ describe('Study programme overview', () => {
|
||||
cy.contains('2021')
|
||||
cy.contains('2 graduated').trigger('mouseover')
|
||||
cy.contains('2 students graduated in year 2021')
|
||||
cy.contains('median study time: 25 months')
|
||||
cy.contains('median study time: 4.5 semesters')
|
||||
cy.contains('1 graduated on time')
|
||||
cy.contains('1 graduated max year overtime')
|
||||
cy.contains('0 graduated over year late')
|
||||
@@ -274,9 +257,9 @@ describe('Study programme overview', () => {
|
||||
cy.contains('2023')
|
||||
cy.contains('11 graduated').trigger('mouseover')
|
||||
cy.contains('11 students graduated in year 2023')
|
||||
cy.contains('median study time: 69 months')
|
||||
cy.contains('3 graduated on time')
|
||||
cy.contains('7 graduated max year overtime')
|
||||
cy.contains('median study time: 12 semesters')
|
||||
cy.contains('4 graduated on time')
|
||||
cy.contains('6 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
@@ -286,11 +269,11 @@ describe('Study programme overview', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept(
|
||||
'GET',
|
||||
'api/v2/studyprogrammes/KH50_001/studytrackstats?graduated=GRADUATED_INCLUDED&special_groups=SPECIAL_INCLUDED&combined_programme='
|
||||
'api/v2/studyprogrammes/KH50_001/studytrackstats?special_groups=SPECIAL_INCLUDED&combined_programme='
|
||||
).as('stQuery')
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
cy.cs('StudyTracksAndClassStatisticsTab').click()
|
||||
})
|
||||
|
||||
describe('Info boxes', () => {
|
||||
@@ -317,9 +300,9 @@ describe('Study programme overview', () => {
|
||||
cy.cs('average-graduation-times-section')
|
||||
})
|
||||
|
||||
it('Students of the study programme are shown correctly', () => {
|
||||
it('Students of the degree programme are shown correctly', () => {
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
|
||||
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 1],
|
||||
['2022 - 2023', 26, 25, 0, 0, 24, 2, 19, 7, 0, 25, 1],
|
||||
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
|
||||
@@ -398,44 +381,46 @@ describe('Study programme overview', () => {
|
||||
it('Student progress data is shown correctly', () => {
|
||||
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]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 26, 9, 9, 4, 3, 0, 1, 0],
|
||||
['2021 - 2022', 38, 8, 6, 11, 8, 5, 0, 0],
|
||||
['2020 - 2021', 30, 2, 1, 3, 7, 4, 4, 9],
|
||||
['2019 - 2020', 35, 1, 0, 2, 1, 0, 4, 27],
|
||||
['2018 - 2019', 45, 0, 1, 1, 2, 0, 3, 38],
|
||||
['2017 - 2018', 47, 0, 1, 3, 0, 1, 2, 40],
|
||||
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
|
||||
// [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', 38, 8, 4, 9, 8, 4, 0, 0, 5],
|
||||
['2020 - 2021', 30, 2, 1, 2, 5, 0, 0, 1, 19],
|
||||
['2019 - 2020', 35, 1, 0, 2, 1, 0, 0, 4, 27],
|
||||
['2018 - 2019', 45, 0, 1, 1, 2, 0, 0, 2, 39],
|
||||
['2017 - 2018', 47, 0, 1, 3, 0, 0, 1, 0, 42],
|
||||
['Total', 229, 28, 15, 21, 19, 4, 1, 7, 134],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'study-programme-progress')
|
||||
})
|
||||
|
||||
it('Progress section', () => {
|
||||
cy.cs('programme-progress-bar-chart-section').scrollIntoView()
|
||||
|
||||
cy.cs('programme-progress-bar-chart-section')
|
||||
.should('contain', 'Less than 30 credits')
|
||||
.should('contain', '30–60 credits')
|
||||
.should('contain', 'At least 180 credits')
|
||||
.should('contain', '49.8%') // The percentage for total, at least 180 credits, to check that the graph renders
|
||||
.should('contain', '58.5%') // The percentage for total graduated, to check that the graph renders
|
||||
|
||||
cy.cs('programme-progress-bar-chart-section').contains('49.8%').trigger('mouseover', { force: true })
|
||||
cy.contains('At least 180 credits: 114')
|
||||
cy.cs('programme-progress-bar-chart-section').contains('58.5%').trigger('mouseover', { force: true })
|
||||
cy.contains('Graduated: 134')
|
||||
})
|
||||
|
||||
it('Average graduation times section', () => {
|
||||
cy.cs('average-graduation-times-section').within(() => {
|
||||
cy.contains('2020 - 2021')
|
||||
cy.get('[aria-label="2020 - 2021, 15. On time."]').trigger('mouseover')
|
||||
cy.contains('On time: 15')
|
||||
cy.get('[aria-label="2020 - 2021, 16. On time."]').trigger('mouseover')
|
||||
cy.contains('On time: 16')
|
||||
cy.contains("Click a bar to view that year's study track level breakdown")
|
||||
cy.get('[aria-label="2019 - 2020, 22. On time."]').click()
|
||||
cy.contains('Year 2019 - 2020 by start year')
|
||||
cy.get('[aria-label="MAT-MAT, 14. On time."]').trigger('mouseover')
|
||||
cy.get('[aria-label="MAT-MAT, 13. On time."]').trigger('mouseover')
|
||||
cy.contains('Matematiikka')
|
||||
cy.contains('MAT-MAT')
|
||||
cy.contains('On time: 14')
|
||||
cy.contains('On time: 13')
|
||||
})
|
||||
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
@@ -444,9 +429,9 @@ describe('Study programme overview', () => {
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('19 graduated').trigger('mouseover')
|
||||
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
|
||||
cy.contains('median study time: 33 months')
|
||||
cy.contains('15 graduated on time')
|
||||
cy.contains('3 graduated max year overtime')
|
||||
cy.contains('median study time: 6 semesters')
|
||||
cy.contains('16 graduated on time')
|
||||
cy.contains('2 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
@@ -465,7 +450,7 @@ describe('Study programme overview', () => {
|
||||
it('Students of the study track are shown correctly', () => {
|
||||
cy.cs('study-track-overview-section').contains('Students of the study track MAT-MAT by starting year')
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
|
||||
// [Year, All, Started studying, Present, Absent, Passive, Graduated, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2022 - 2023', 3, 3, 0, 0, 1, 2, 2, 1, 0, 3, 0],
|
||||
['2021 - 2022', 5, 1, 0, 0, 1, 4, 3, 2, 0, 5, 0],
|
||||
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
|
||||
@@ -488,7 +473,7 @@ describe('Study programme overview', () => {
|
||||
|
||||
it('Info message about missing progress stats is displayed', () => {
|
||||
cy.contains('Progress of students of the study track MAT-MAT by starting year')
|
||||
cy.contains('Progress data is currently only available for all students of the study programme.')
|
||||
cy.contains('Progress data is currently only available for all students of the degree programme.')
|
||||
})
|
||||
|
||||
it.skip('Average graduation times are displayed correctly', () => {
|
||||
@@ -519,7 +504,7 @@ describe('Study programme overview', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('programme-courses-tab').click()
|
||||
cy.cs('ProgrammeCoursesTab').click()
|
||||
|
||||
cy.cs('by-credit-type-section')
|
||||
cy.cs('by-credit-type-tab')
|
||||
@@ -528,14 +513,21 @@ describe('Study programme overview', () => {
|
||||
|
||||
describe('By credit type tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('thead').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('th').eq(3).click()
|
||||
})
|
||||
})
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(8)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT21001')
|
||||
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
|
||||
cy.get('td').eq(3).contains('341')
|
||||
cy.get('td').eq(1).contains('MAT11004')
|
||||
cy.get('td').eq(2).contains('Differentiaalilaskenta')
|
||||
cy.get('td').eq(3).contains('1170')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -548,12 +540,12 @@ describe('Study programme overview', () => {
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.eq(9)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT21001')
|
||||
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
|
||||
cy.get('td').eq(3).contains('121')
|
||||
cy.get('td').eq(1).contains('MAT21009')
|
||||
cy.get('td').eq(2).contains('Kandidaatintutkielma')
|
||||
cy.get('td').eq(3).contains('414')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -568,12 +560,12 @@ describe('Study programme overview', () => {
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.eq(5)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT21001')
|
||||
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
|
||||
cy.get('td').eq(3).contains('311')
|
||||
cy.get('td').eq(1).contains('MAT11001')
|
||||
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
|
||||
cy.get('td').eq(3).contains('1220')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -632,31 +624,67 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it("'Show credits/Show students' toggle works", () => {
|
||||
cy.get('thead tr').within(() => {
|
||||
cy.get('th').should('have.length', 8)
|
||||
const headers = ['Type', 'Code', 'Name', 'Total', 'Major', 'Non-major', 'Non-degree', 'Transferred']
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
cy.get('thead tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 6)
|
||||
const headers = ['Type', 'Code', 'Name', 'Total credits', 'Credits produced by', 'Transferred credits']
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
cy.get('thead tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 7)
|
||||
const headers = [
|
||||
'Degree students',
|
||||
'Open university (hetu)',
|
||||
'Open university (no hetu)',
|
||||
'Exchange students',
|
||||
'Other universities',
|
||||
'Separate studies',
|
||||
'Other',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
cy.cs('show-credits-students-toggle').click()
|
||||
|
||||
cy.get('thead tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 7)
|
||||
const headers = [
|
||||
'Type',
|
||||
'Code',
|
||||
'Name',
|
||||
'Total students',
|
||||
'Breakdown of total',
|
||||
'Breakdown of passed',
|
||||
'Not included in total or passed',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
cy.get('thead tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 10)
|
||||
const headers = [
|
||||
'Type',
|
||||
'Code',
|
||||
'Name',
|
||||
'Total',
|
||||
'Passed',
|
||||
'Not',
|
||||
'Major',
|
||||
'Non-major',
|
||||
'Non-degree',
|
||||
'Transferred',
|
||||
'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',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
@@ -665,12 +693,12 @@ describe('Study programme overview', () => {
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.eq(5)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT21001')
|
||||
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
|
||||
cy.get('td').eq(3).contains('341')
|
||||
cy.get('td').eq(1).contains('MAT11001')
|
||||
cy.get('td').eq(2).contains('Johdatus yliopistomatematiikkaan')
|
||||
cy.get('td').eq(3).contains('375')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -682,7 +710,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('test', () => {
|
||||
it.skip('test', () => {
|
||||
// TODO: Implement some real tests for this tab
|
||||
cy.contains('From')
|
||||
})
|
||||
@@ -693,7 +721,7 @@ describe('Study programme overview', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('degree-courses-tab').click()
|
||||
cy.cs('DegreeCoursesTab').click()
|
||||
})
|
||||
|
||||
it('Curriculum section', () => {
|
||||
@@ -729,7 +757,7 @@ describe('Study programme overview', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
cy.cs('tags-tab').click()
|
||||
cy.cs('TagsTab').click()
|
||||
})
|
||||
|
||||
it('info box', () => {
|
||||
@@ -739,13 +767,13 @@ describe('Study programme overview', () => {
|
||||
|
||||
describe('Adding tags to populations and removing them works', () => {
|
||||
beforeEach(() => {
|
||||
cy.contains('This study programme does not have any tags yet')
|
||||
cy.contains('This degree programme does not have any tags yet')
|
||||
cy.cs('tag-name-text-field').type(tagName)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
deleteTag(tagName)
|
||||
cy.contains('This study programme does not have any tags yet')
|
||||
cy.contains('This degree programme does not have any tags yet')
|
||||
})
|
||||
|
||||
it('can create and delete tags without start year', () => {
|
||||
@@ -856,7 +884,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('can access basic information', () => {
|
||||
cy.cs('basic-information-tab').click()
|
||||
cy.cs('BasicInformationTab').click()
|
||||
|
||||
cy.cs('students-of-the-study-programme-section')
|
||||
cy.cs('credits-produced-by-the-study-programme-section')
|
||||
@@ -866,7 +894,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('can access study tracks', () => {
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
cy.cs('StudyTracksAndClassStatisticsTab').click()
|
||||
|
||||
cy.cs('study-track-overview-section')
|
||||
cy.cs('progress-of-students-section')
|
||||
@@ -874,9 +902,9 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it("doesn't see other tabs", () => {
|
||||
cy.cs('degree-courses-tab').should('not.exist')
|
||||
cy.cs('tags-tab').should('not.exist')
|
||||
cy.cs('update-statistics-tab').should('not.exist')
|
||||
cy.cs('DegreeCoursesTab').should('not.exist')
|
||||
cy.cs('TagsTab').should('not.exist')
|
||||
cy.cs('UpdateStatisticsTab').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -273,7 +273,7 @@ describe('Faculty statistics', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Study programme toggle', () => {
|
||||
it.skip('Degree programme toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
|
||||
@@ -179,11 +179,11 @@ describe.skip('Population Statistics', { testIsolation: false }, () => {
|
||||
]
|
||||
|
||||
cy.cs('courseFilter-header').click()
|
||||
cy.selectFromDropdown('courseFilter', `${courses[0].code} - ${courses[0].name}`)
|
||||
cy.selectFromAutocomplete('courseFilter', `${courses[0].code} - ${courses[0].name}`)
|
||||
checkFilteredStudentCount(9)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
|
||||
checkFilteredStudentCount(7)
|
||||
cy.selectFromDropdown('courseFilter', `${courses[1].code} - ${courses[1].name}`)
|
||||
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
|
||||
checkFilteredStudentCount(5)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 1)
|
||||
checkFilteredStudentCount(4)
|
||||
@@ -273,12 +273,12 @@ describe('Population Statistics with Bachelor + Master', { testIsolation: false
|
||||
checkFilteredStudentCount(0)
|
||||
|
||||
// Non-Bachelor
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Inactive Bachelor study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Inactive Bachelor study right"]').should('be.checked')
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Passive Bachelor study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Passive Bachelor study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(5)
|
||||
// Non-Master (combined)
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Inactive Master study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Inactive Master study right"]').should('be.checked')
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Passive Master study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Passive Master study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(24)
|
||||
|
||||
cy.cs('studyRightStatusFilter-clear').click()
|
||||
@@ -385,7 +385,6 @@ describe('Custom Population Statistics', { testIsolation: false }, () => {
|
||||
|
||||
before(() => {
|
||||
cy.init('/custompopulation')
|
||||
cy.cs('custom-pop-search-button').click()
|
||||
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1, studentNumbersForCSStudentsSet2 }) => {
|
||||
const students = [...studentNumbersForCSStudentsSet1, ...studentNumbersForCSStudentsSet2]
|
||||
defaultAmountOfStudents = students.length
|
||||
@@ -449,11 +448,11 @@ describe('Custom Population Statistics', { testIsolation: false }, () => {
|
||||
]
|
||||
|
||||
cy.cs('courseFilter-header').click()
|
||||
cy.selectFromDropdown('courseFilter', `${courses[0].code} - ${courses[0].name}`)
|
||||
cy.selectFromAutocomplete('courseFilter', `${courses[0].code} - ${courses[0].name}`)
|
||||
checkFilteredStudentCount(7)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
|
||||
checkFilteredStudentCount(6)
|
||||
cy.selectFromDropdown('courseFilter', `${courses[1].code} - ${courses[1].name}`)
|
||||
cy.selectFromAutocomplete('courseFilter', `${courses[1].code} - ${courses[1].name}`)
|
||||
checkFilteredStudentCount(5)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 3)
|
||||
checkFilteredStudentCount(2)
|
||||
|
||||
@@ -1,42 +1,23 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const hasLanded = () => {
|
||||
cy.contains('This view displays amounts')
|
||||
cy.contains('Show number of')
|
||||
}
|
||||
|
||||
const chooseSemester = (semester, fromOrTo) => {
|
||||
cy.get(`[data-cy="semester-${fromOrTo}"]`).click()
|
||||
cy.get(`[data-cy="semester-${fromOrTo}"]`).within(() => {
|
||||
cy.get('div.visible.menu.transition').within(() => {
|
||||
cy.contains('div.item', semester).click()
|
||||
})
|
||||
cy.get('div.menu.transition').should('not.have.class', 'visible')
|
||||
})
|
||||
cy.cs(`semester-${fromOrTo}`).click()
|
||||
cy.cs(`select-opt-${semester}`).click()
|
||||
cy.cs(`select-opt-${semester}`).should('not.have.class', 'visible')
|
||||
}
|
||||
|
||||
const checkNumbers = (numbers, numberOfColumns, mode) => {
|
||||
const offset = mode === 'semesters' ? 2 : 1
|
||||
cy.get('table > tbody > tr:first').within(() => {
|
||||
cy.get('td').should('have.length', numberOfColumns)
|
||||
cy.get('td').eq(0).contains('All courses total')
|
||||
numbers.forEach((number, index) => {
|
||||
cy.get('td')
|
||||
.eq(index + offset)
|
||||
.contains(number)
|
||||
})
|
||||
})
|
||||
}
|
||||
const offset = 1 // skip first col with codes / names
|
||||
cy.cs(`ooditable-${mode}`).find('table').find('tbody').as('tbody')
|
||||
cy.get('@tbody').find('tr').first().as('totalRow')
|
||||
cy.get('@totalRow').find('td').should('have.length', numberOfColumns)
|
||||
cy.get('@totalRow').find('td').eq(0).should('contain', 'All courses total')
|
||||
|
||||
const checkStyle = (styles, numberOfColumns) => {
|
||||
cy.get('table > tbody > tr:first').within(() => {
|
||||
cy.get('td').should('have.length', numberOfColumns)
|
||||
styles.forEach((style, index) => {
|
||||
cy.get('td')
|
||||
.eq(index + 2)
|
||||
.should('have.attr', 'style')
|
||||
.and('include', style)
|
||||
})
|
||||
numbers.forEach((number, index) => {
|
||||
cy.get('@totalRow')
|
||||
.find('td')
|
||||
.eq(index + offset)
|
||||
.should('contain', number)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,8 +25,7 @@ describe('When language center is opened', () => {
|
||||
describe('as an admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/languagecenterview', 'admin')
|
||||
cy.get('[data-cy="completions-button"]').click()
|
||||
hasLanded()
|
||||
cy.cs('completions-button').click()
|
||||
})
|
||||
|
||||
describe('Faculties tab', () => {
|
||||
@@ -64,7 +44,7 @@ describe('When language center is opened', () => {
|
||||
cy.get('table > tbody > tr:first').within(() => {
|
||||
cy.get('td').should('have.length', 15)
|
||||
cy.get('td').eq(0).contains('All courses total')
|
||||
const numbers = [36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0, 2076]
|
||||
const numbers = [2076, 36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0]
|
||||
numbers.forEach((number, index) => {
|
||||
cy.get('td')
|
||||
.eq(index + 1)
|
||||
@@ -73,16 +53,14 @@ describe('When language center is opened', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Faculties tab "exceeding" button works', () => {
|
||||
cy.get('[data-cy="difference-button"]').click()
|
||||
checkNumbers([...new Array(12).fill(0), 1, 221], 15)
|
||||
cy.cs('difference-button').click()
|
||||
checkNumbers([65, 2, 0, 3, 13, 0, 1, 1, 2, 0, 1, 30, 11, 1], 15, 'faculties')
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Faculties tab semester selector changes numbers', () => {
|
||||
chooseSemester('Syksy 2020', 'from')
|
||||
checkNumbers([28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
|
||||
checkNumbers([1184, 28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0], 15, 'faculties')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,23 +71,8 @@ describe('When language center is opened', () => {
|
||||
chooseSemester('Kevät 2024', 'to')
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Semester tab shows numbers', () => {
|
||||
checkNumbers([69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1, 2076], 17, 'semesters')
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Coloring mode works on semester tab', () => {
|
||||
cy.contains('Compare to average of course').click()
|
||||
const expectedAlphas = [0.04, 0.016, 0.17, 0.03, 0.192, 0.055, 0.247, 0.08, 0.173, 0.05, 0.07, 0.043, 0.004, 0]
|
||||
|
||||
checkStyle(
|
||||
expectedAlphas.map(alpha => `background-color: rgba(0, 170, 0, ${alpha})`),
|
||||
17
|
||||
)
|
||||
cy.get('table > tbody > tr:first').within(() => {
|
||||
cy.get('td').eq(16).should('have.attr', 'style').and('not.include', 'background-color')
|
||||
})
|
||||
checkNumbers([2076, 69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1], 16, 'semesters')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,9 @@ const deleteAllSearches = () => {
|
||||
})
|
||||
}
|
||||
|
||||
describe('Open uni population tests', () => {
|
||||
// Skipping the whole test suite as it is unclear whether the feature has any use or will be
|
||||
// removed alltogether.
|
||||
describe.skip('Open uni population tests', () => {
|
||||
describe('Open uni form can be used', () => {
|
||||
// When openUniUser is created, change it here
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -252,8 +252,8 @@ describe('Population statistics tests', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
|
||||
cy.get('[data-cy=toggle-group-module-MAT110]').click()
|
||||
cy.contains('td', 'MAT11001').siblings().find('i.level.up').click()
|
||||
cy.cs('toggle-group-module-MAT110').click()
|
||||
cy.contains('td', 'MAT11001').siblings().find('[data-testid="NorthEastIcon"]').click()
|
||||
cy.wait('@coursePage')
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT11001 • Johdatus yliopistomatematiikkaan')
|
||||
@@ -319,7 +319,7 @@ describe('Population statistics tests', () => {
|
||||
})
|
||||
|
||||
it("'General tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.cs('ooditable-general').within(() => {
|
||||
cy.contains('522142')
|
||||
cy.contains('Tilastotiede')
|
||||
cy.contains('Matematiikka')
|
||||
@@ -330,18 +330,19 @@ describe('Population statistics tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it("'Courses tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Courses').click()
|
||||
})
|
||||
cy.cs('ooditable-courses').within(() => {
|
||||
cy.contains('MAT12001')
|
||||
cy.contains('MAT21001')
|
||||
})
|
||||
})
|
||||
|
||||
it("'Modules tab' Displays correct modules based on the selected programme", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Modules').click()
|
||||
cy.cs('student-table-tabs').contains('Modules').click()
|
||||
cy.cs('ooditable-modules').within(() => {
|
||||
cy.contains('MAT011')
|
||||
cy.contains('MAT110')
|
||||
})
|
||||
@@ -349,18 +350,17 @@ describe('Population statistics tests', () => {
|
||||
cy.cs('curriculum-picker').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.contains('Courses of class').click()
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.cs('ooditable-modules').within(() => {
|
||||
cy.contains('MAT011').should('not.exist')
|
||||
cy.contains('MAT110')
|
||||
})
|
||||
})
|
||||
|
||||
it.skip("Empty 'tags' tab has a link to the page where tags can be created", () => {
|
||||
// TODO: This fails in the pipeline, but works locally. Investigate.
|
||||
it("Empty 'tags' tab has a link to the page where tags can be created", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Tags').click()
|
||||
cy.contains('No tags defined. You can define them here.').find('a').click()
|
||||
})
|
||||
cy.contains('No tags defined. You can define them here.').find('a').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?tab=4')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Create new tag')
|
||||
@@ -383,17 +383,15 @@ describe('Population statistics tests', () => {
|
||||
cy.contains(nonExisting).should('not.exist')
|
||||
cy.contains('button', 'Check student numbers').click()
|
||||
cy.contains('Check for student numbers')
|
||||
cy.get('textarea').type(existing)
|
||||
cy.get('textarea').type('{enter}')
|
||||
cy.get('textarea').type(nonExisting)
|
||||
cy.cs('check-student-numbers').type(`${existing}{enter}${nonExisting}`)
|
||||
cy.contains('button', 'Check students').click()
|
||||
cy.contains('#checkstudentsresults', 'Results').within(() => {
|
||||
cy.contains('Student numbers in list and in Sisu').click()
|
||||
cy.contains('#found', existing)
|
||||
cy.contains('Student numbers in list but not in Sisu').click()
|
||||
cy.contains('#notfound', nonExisting)
|
||||
cy.contains('Student numbers in Sisu but not in list').click()
|
||||
cy.contains('#notsearched', '457144')
|
||||
cy.get('#checkstudentsresults').within(() => {
|
||||
cy.cs('found-title').click()
|
||||
cy.cs('found-data').contains(existing)
|
||||
cy.cs('not-found-title').click()
|
||||
cy.cs('not-found-data').contains(nonExisting)
|
||||
cy.cs('not-searched-title').click()
|
||||
cy.cs('not-searched-data').contains('457144')
|
||||
})
|
||||
cy.contains('button', 'Close').click()
|
||||
cy.contains('Student numbers in list and in Sisu').should('not.exist')
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('Study guidance group tests', () => {
|
||||
|
||||
const studyGuidanceGroupsTableContent = [
|
||||
['MAT kandit 2020', 3, 'Matemaattisten tieteiden kandiohjelma', '2020 - 2021'],
|
||||
['Oma ohjausryhmä', 2, 'Add study programme', 'Add year'],
|
||||
['Oma ohjausryhmä', 2, 'Add degree programme', 'Add starting year'],
|
||||
]
|
||||
|
||||
cy.checkTableStats(studyGuidanceGroupsTableContent, 'study-guidance-group-overview')
|
||||
@@ -52,11 +52,11 @@ describe('Study guidance group tests', () => {
|
||||
it('has a working back button', () => {
|
||||
cy.init('/studyguidancegroups/sgg-cypress-1', 'onlystudyguidancegroups')
|
||||
|
||||
cy.get('button.ui.button').contains('Back').click()
|
||||
cy.get('button').contains('Back to groups').click()
|
||||
cy.url().should('eq', `${Cypress.config().baseUrl}/studyguidancegroups`)
|
||||
})
|
||||
|
||||
describe('without associated study programme and year', () => {
|
||||
describe('without associated degree programme and year', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/studyguidancegroups/sgg-cypress-2', 'onlystudyguidancegroups')
|
||||
})
|
||||
@@ -84,7 +84,7 @@ describe('Study guidance group tests', () => {
|
||||
'All credits',
|
||||
'Credits since 1.1.1970',
|
||||
'Start year at uni',
|
||||
'Study programmes',
|
||||
'Degree programmes',
|
||||
'Tags',
|
||||
]
|
||||
cy.contains('Students (2)').click()
|
||||
@@ -94,15 +94,15 @@ describe('Study guidance group tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('with associated study programme and year', () => {
|
||||
describe('with associated degree programme and year', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/studyguidancegroups/sgg-cypress-1', 'onlystudyguidancegroups')
|
||||
})
|
||||
|
||||
it('shows the correct labels', () => {
|
||||
cy.get('.ui.medium.header').contains('MAT kandit 2020')
|
||||
cy.get('.ui.blue.tag.label:first').contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.get('.ui.blue.tag.label:last').contains('2020 - 2021')
|
||||
cy.get('h4').contains('MAT kandit 2020')
|
||||
cy.get('.MuiChip-root:first').contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.get('.MuiChip-root:last').contains('2020 - 2021')
|
||||
})
|
||||
|
||||
it('shows the correct panes', () => {
|
||||
@@ -113,17 +113,17 @@ describe('Study guidance group tests', () => {
|
||||
cy.contains('Students (3)')
|
||||
})
|
||||
|
||||
it("clicking the 'Show starting from associated year' button activates the 'Date of Course Credits' filter", () => {
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-active="false"]')
|
||||
it("clicking the 'Show credits starting from the associated academic year' filter", () => {
|
||||
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
|
||||
cy.cs('Credit accumulation (for 3 students)').click()
|
||||
cy.get('.ui.primary.button').contains('Show starting from associated year').click()
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-active="true"]')
|
||||
cy.get('label').contains('Show credits starting from the associated academic year').click()
|
||||
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="true"]')
|
||||
cy.get('.date-picker')
|
||||
.eq(0)
|
||||
.within(() => cy.get('input').should('have.value', '01.08.2020'))
|
||||
cy.contains('Reset All Filters')
|
||||
cy.get('.ui.primary.button').contains('Show all credits').click()
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-active="false"]')
|
||||
cy.get('label').contains('Show credits starting from the associated academic year').click()
|
||||
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
|
||||
})
|
||||
|
||||
it.skip('students table has the correct tabs', () => {
|
||||
|
||||
+19
-19
@@ -10,17 +10,19 @@ describe('Teachers page tests', () => {
|
||||
const teacher1 = 'Luokkanen Liisa Viljami'
|
||||
const teacher2 = 'Perälä Juhani Susanna'
|
||||
|
||||
const statisticsHeaders = ['Name', 'Credits', 'Credits transferred', 'Passed']
|
||||
|
||||
it('Check Statistics', () => {
|
||||
cy.get(':nth-child(1) > .ui > .search').click()
|
||||
cy.cs('semester-start').click()
|
||||
cy.contains('Syksy 2020').click()
|
||||
cy.cs('course-providers').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('course-providers').children('.icon').click()
|
||||
cy.contains('button', 'Search').click()
|
||||
cy.get('body').click(0, 0) // Click outside of the select
|
||||
cy.cs('search-statistics').click()
|
||||
|
||||
cy.contains('Teacher')
|
||||
const headers = ['Name', 'Credits', 'Credits transferred', 'Passed']
|
||||
cy.get('table thead tr th').should('have.length', 4)
|
||||
headers.forEach((header, index) => {
|
||||
statisticsHeaders.forEach((header, index) => {
|
||||
cy.get('table thead tr th').eq(index).should('contain', header)
|
||||
})
|
||||
cy.contains('td', teacher1).siblings().eq(0).contains('235')
|
||||
@@ -30,15 +32,15 @@ describe('Teachers page tests', () => {
|
||||
})
|
||||
|
||||
it('Teacher search works', () => {
|
||||
cy.get('.borderless > :nth-child(3)').click()
|
||||
cy.get('.prompt').type(teacher1.split(' ')[0])
|
||||
cy.cs('Search').click()
|
||||
cy.cs('teacher-search').type(teacher1.split(' ')[0])
|
||||
cy.get('table tbody tr').should('have.length', 4)
|
||||
cy.get('table tbody tr').eq(2).contains('td', teacher1)
|
||||
cy.get('table tbody tr').eq(3).contains('td', teacher1)
|
||||
})
|
||||
|
||||
it('Can check teacher page', () => {
|
||||
cy.get('.borderless > :nth-child(3)').click()
|
||||
cy.get('.prompt').type(teacher2)
|
||||
cy.cs('Search').click()
|
||||
cy.cs('teacher-search').type(teacher2)
|
||||
|
||||
// Prevent opening in new tab
|
||||
cy.contains('a', teacher2).invoke('removeAttr', 'target').click()
|
||||
@@ -46,19 +48,17 @@ describe('Teachers page tests', () => {
|
||||
cy.contains(teacher2)
|
||||
cy.contains('Syksy 2023').click()
|
||||
cy.contains('Kevät 2019').click()
|
||||
cy.get('.ok-sortable-table tbody tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
const rowContent = ['MAT12004', 'Tilastollinen päättely I', '120', '0', '92.31%']
|
||||
rowContent.forEach((content, index) => {
|
||||
cy.get('td').eq(index).contains(content)
|
||||
})
|
||||
cy.contains('tr', 'MAT12004').within(() => {
|
||||
const rowContent = ['MAT12004', 'Tilastollinen päättely I', '120', '0', '92.31%']
|
||||
rowContent.forEach((content, index) => {
|
||||
cy.get('td').eq(index).contains(content)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Check leaderboad works', () => {
|
||||
cy.get('.borderless > :nth-child(2)').click()
|
||||
cy.get(':nth-child(1) > .ui > .search').click()
|
||||
cy.cs('Leaderboard').click()
|
||||
cy.cs('academic-year').click()
|
||||
cy.contains('2020-2021').click()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,12 +39,6 @@ describe('University view', () => {
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it("'Graduated included / Graduated excluded' toggle works", () => {
|
||||
cy.cs('graduated-toggle').click()
|
||||
checkProgressBarCharts()
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it("'All study rights / Special study rights excluded' toggle works", () => {
|
||||
cy.cs('study-right-toggle').click()
|
||||
checkProgressBarCharts()
|
||||
|
||||
@@ -122,6 +122,19 @@ Cypress.Commands.add('selectFromDropdown', { prevSubject: false }, (label, index
|
||||
if (isMultiSelect) cy.get('#menu- > .MuiModal-backdrop').click()
|
||||
})
|
||||
|
||||
/**
|
||||
* Select item from FilterSearchableSelect dropdown.
|
||||
* If the index is a number, select index-th item.
|
||||
* Otherwise select the item containing index text.
|
||||
*/
|
||||
Cypress.Commands.add('selectFromAutocomplete', { prevSubject: false }, (label, index) => {
|
||||
cy.cs(`${label}-selector`).click()
|
||||
cy.get(`#${label}-popper.MuiAutocomplete-popper [role="listbox"]`).within(() => {
|
||||
if (typeof index === 'number') cy.get('li').eq(index).click()
|
||||
else cy.get('li').contains(index).click()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Set range selector values FilterRange.
|
||||
* If the reset flag is set, the function tries to reset the original values.
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
require('@cypress/code-coverage/support')
|
||||
require('./commands')
|
||||
|
||||
Cypress.on('uncaught:exception', error => {
|
||||
|
||||
@@ -25,6 +25,8 @@ services:
|
||||
build:
|
||||
context: services
|
||||
dockerfile: backend/Dockerfile
|
||||
profiles:
|
||||
- oodikone
|
||||
|
||||
frontend:
|
||||
<<: *defaults
|
||||
@@ -32,6 +34,8 @@ services:
|
||||
build:
|
||||
context: services
|
||||
dockerfile: frontend/Dockerfile
|
||||
profiles:
|
||||
- oodikone
|
||||
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
@@ -47,6 +51,7 @@ services:
|
||||
command: >
|
||||
sh -c 'redis-server --appendonly yes && redis-cli flushall'
|
||||
image: redis:7.2.6
|
||||
container_name: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
@@ -101,6 +106,8 @@ services:
|
||||
- 1337:80
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
profiles:
|
||||
- oodikone
|
||||
|
||||
# Following services 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-importer-db:
|
||||
@@ -114,6 +121,7 @@ services:
|
||||
<<: *defaults
|
||||
build:
|
||||
context: updater/sis-updater-worker
|
||||
container_name: sis-updater-worker
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-db
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
services:
|
||||
oodikone-backend:
|
||||
build:
|
||||
context: ${OODIKONE_REPO:-.}/services
|
||||
dockerfile: backend/Dockerfile
|
||||
args:
|
||||
SENTRY_ENVIRONMENT: ''
|
||||
SENTRY_RELEASE: ''
|
||||
SENTRY_DSN: ''
|
||||
SERVICE_PROVIDER: fd
|
||||
NODE_ENV: production
|
||||
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/oodikone-backend:${DOCKER_IMAGE_TAG:-dev}
|
||||
|
||||
oodikone-frontend:
|
||||
build:
|
||||
context: ${OODIKONE_REPO:-.}/services
|
||||
dockerfile: frontend/Dockerfile
|
||||
args:
|
||||
SENTRY_ENVIRONMENT: ''
|
||||
SENTRY_RELEASE: ''
|
||||
SENTRY_DSN: ''
|
||||
SERVICE_PROVIDER: fd
|
||||
LANGUAGE_CENTER_VIEW_ENABLED: 'false' # currently no intention to be enabled
|
||||
SIS_URL: 'https://sis-qa.funidata.fi'
|
||||
NODE_ENV: production
|
||||
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/oodikone-frontend:${DOCKER_IMAGE_TAG:-dev}
|
||||
|
||||
updater-scheduler:
|
||||
build:
|
||||
context: ${OODIKONE_REPO:-.}/updater/sis-updater-scheduler
|
||||
args:
|
||||
SENTRY_ENVIRONMENT: ''
|
||||
SENTRY_RELEASE: ''
|
||||
SENTRY_DSN: ''
|
||||
NODE_ENV: production
|
||||
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/updater-scheduler:${DOCKER_IMAGE_TAG:-dev}
|
||||
|
||||
updater-worker:
|
||||
build:
|
||||
context: ${OODIKONE_REPO:-.}/updater/sis-updater-worker
|
||||
args:
|
||||
SENTRY_ENVIRONMENT: ''
|
||||
SENTRY_RELEASE: ''
|
||||
SENTRY_DSN: ''
|
||||
SERVICE_PROVIDER: fd
|
||||
NODE_ENV: production
|
||||
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/updater-worker:${DOCKER_IMAGE_TAG:-dev}
|
||||
@@ -1,190 +0,0 @@
|
||||
x-defaults: &defaults
|
||||
restart: unless-stopped
|
||||
|
||||
x-build_defaults: &build_defaults
|
||||
dockerfile: dev.Dockerfile
|
||||
|
||||
services:
|
||||
adminer:
|
||||
<<: *defaults
|
||||
container_name: adminer
|
||||
environment:
|
||||
ADMINER_DESIGN: pepa-linha
|
||||
image: adminer:4.8.1
|
||||
ports:
|
||||
- 5050:8080
|
||||
volumes:
|
||||
- ./adminer-plugins:/var/www/html/plugins-enabled
|
||||
|
||||
backend:
|
||||
<<: *defaults
|
||||
build:
|
||||
<<: *build_defaults
|
||||
context: services/backend
|
||||
depends_on:
|
||||
- kone-db
|
||||
- redis
|
||||
- sis-db
|
||||
- user-db
|
||||
env_file:
|
||||
- .env
|
||||
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://localhost:3005'
|
||||
JAMI_URL: 'http://jami:3003/'
|
||||
SERVICE_PROVIDER: fd
|
||||
SIS_HOST: ${SIS_HOST}
|
||||
SIS_GRAPHQL_APPLICATION_ACCOUNT: ${SIS_GRAPHQL_APPLICATION_ACCOUNT}
|
||||
SIS_GRAPHQL_APPLICATION_KEY: ${SIS_GRAPHQL_APPLICATION_KEY}
|
||||
IMPORTER_DB_API_USER: dev_user
|
||||
IMPORTER_DB_API_PASSWORD: dev_password
|
||||
ROOT_ORG_ID: ${ROOT_ORG_ID}
|
||||
LANGUAGE_CENTER_VIEW_ENABLED: 'false'
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- backend-nodemod:/opt/app-root/backend/node_modules
|
||||
- ./environments/testu:/opt/app-root/backend/src/environment
|
||||
- ./services/backend/:/opt/app-root/backend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
|
||||
frontend:
|
||||
<<: *defaults
|
||||
build:
|
||||
<<: *build_defaults
|
||||
context: services/frontend
|
||||
container_name: frontend
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
REACT_APP_SERVICE_PROVIDER: fd
|
||||
REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED: 'false'
|
||||
REACT_APP_SIS_URL: 'https://sis-qa.funidata.fi'
|
||||
volumes:
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
- ./services/frontend/:/opt/app-root/frontend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
container_name: kone-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.10
|
||||
ports:
|
||||
- 12349:5432
|
||||
volumes:
|
||||
- kone-db-data:/data
|
||||
|
||||
redis:
|
||||
<<: *defaults
|
||||
command: 'redis-server --appendonly yes'
|
||||
container_name: redis
|
||||
image: redis:6.2.16
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
sis-db:
|
||||
<<: *defaults
|
||||
container_name: sis-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.10
|
||||
volumes:
|
||||
- sis-db-data:/data
|
||||
shm_size: 2gb
|
||||
ports:
|
||||
- 12345:5432
|
||||
|
||||
sis-updater-scheduler:
|
||||
<<: *defaults
|
||||
build:
|
||||
<<: *build_defaults
|
||||
context: updater/sis-updater-scheduler
|
||||
container_name: sis-updater-scheduler
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
depends_on:
|
||||
- redis
|
||||
environment:
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
SECRET_TOKEN: dev
|
||||
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
|
||||
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
|
||||
SIS_IMPORTER_DATABASE: importer-db
|
||||
SIS_IMPORTER_PASSWORD: dev
|
||||
SIS_IMPORTER_USER: dev
|
||||
SERVICE_PROVIDER: fd
|
||||
ports:
|
||||
- 8082:8082
|
||||
volumes:
|
||||
- ./updater/sis-updater-scheduler/:/opt/app-root/src
|
||||
|
||||
sis-updater-worker:
|
||||
<<: *defaults
|
||||
deploy:
|
||||
replicas: 2
|
||||
mode: replicated
|
||||
build:
|
||||
<<: *build_defaults
|
||||
context: updater/sis-updater-worker
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-db
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
environment:
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
|
||||
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
|
||||
SIS_IMPORTER_DATABASE: importer-db
|
||||
SIS_IMPORTER_PASSWORD: dev
|
||||
SIS_IMPORTER_USER: dev
|
||||
SIS_PASSWORD: postgres
|
||||
ROOT_ORG_ID: ${ROOT_ORG_ID}
|
||||
SERVICE_PROVIDER: fd
|
||||
volumes:
|
||||
- ./updater/sis-updater-worker/:/opt/app-root/src
|
||||
|
||||
user-db:
|
||||
<<: *defaults
|
||||
container_name: user-db
|
||||
environment:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.10
|
||||
volumes:
|
||||
- user-db-data:/data
|
||||
ports:
|
||||
- 12347:5432
|
||||
|
||||
volumes:
|
||||
backend-nodemod:
|
||||
frontend-nodemod:
|
||||
kone-db-data:
|
||||
redis-data:
|
||||
sis-db-data:
|
||||
user-db-data:
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: oodikone
|
||||
importer:
|
||||
name: importer_network
|
||||
external: true
|
||||
+24
-3
@@ -1,5 +1,26 @@
|
||||
services:
|
||||
frontend:
|
||||
api-tests:
|
||||
volumes:
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
- ./services/frontend/instrumented:/opt/app-root/frontend/src
|
||||
- backend-tests-nodemod:/opt/app-root/backend/node_modules
|
||||
- ./services/backend/:/opt/app-root/backend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
environment:
|
||||
CI: 'true'
|
||||
NODE_ENV: 'test'
|
||||
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: test
|
||||
SIS_DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
SIS_UPDATER_URL: http://sis-updater-scheduler:8082
|
||||
JAMI_URL: 'http://jami:3003/'
|
||||
IMPORTER_DB_API_URL: 'https://api-toska.apps.ocp-prod-0.k8s.it.helsinki.fi/importer'
|
||||
CRYPT_KEY: 'e16730fce263d5270ba8dcf84d068a32556542455e71331880d6c1c5e4db9b4e'
|
||||
build:
|
||||
context: services/backend
|
||||
dockerfile: dev.Dockerfile
|
||||
command: npm run test
|
||||
|
||||
volumes:
|
||||
backend-tests-nodemod:
|
||||
|
||||
+3
-1
@@ -21,6 +21,7 @@ services:
|
||||
build:
|
||||
<<: *build_defaults
|
||||
context: services/backend
|
||||
container_name: backend
|
||||
depends_on:
|
||||
- kone-db
|
||||
- redis
|
||||
@@ -132,6 +133,7 @@ services:
|
||||
build:
|
||||
<<: *build_defaults
|
||||
context: updater/sis-updater-worker
|
||||
container_name: sis-updater-worker
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-db
|
||||
@@ -168,7 +170,7 @@ services:
|
||||
- npm_config_cache=/tmp
|
||||
- PORT=3003
|
||||
- NODE_ENV=dev
|
||||
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/postgres
|
||||
- DATABASE_URL=postgres://postgres:postgres@jami-db:5432/jami-db-real
|
||||
ports:
|
||||
- 3003:3003
|
||||
container_name: jami
|
||||
|
||||
@@ -21,7 +21,7 @@ The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply
|
||||
|
||||
### teachers
|
||||
|
||||
- By default, the role only allows seeing the statistics of course providers (study programmes) they have full rights to (see [Programmes](#programmes))
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ The made changes do not have any effects on faculty or trends views. **Needs man
|
||||
|
||||
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.
|
||||
|
||||
## Study programme
|
||||
## 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.
|
||||
|
||||
@@ -15,7 +15,7 @@ Basic rule of thumb is that for the master/licentiate programme I used names `co
|
||||
- Overview
|
||||
- **Needs manual work**
|
||||
|
||||
**Redis nightly study programme update**
|
||||
**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.
|
||||
|
||||
@@ -36,7 +36,7 @@ All things listed below should work automatically, if new combinations are done.
|
||||
|
||||
- _Students of the studyprogramme by starting year_
|
||||
- Graduated field separated for both programmes
|
||||
- All = currently enrolled + absent + inactive + graduated licentiate
|
||||
- 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_
|
||||
@@ -85,12 +85,12 @@ All things listed below should work automatically, if new combinations are done.
|
||||
|
||||
- **Graduated from programme** - possibility to filter out students based on graduation either in bachelor's or master's.
|
||||
|
||||
- **Studyright status** - active or inactive based on the chosen degree
|
||||
- **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 study programme code in `/v3/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
|
||||
- Backend: student data is fetched by bachelor degree programme code in `/v3/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.
|
||||
|
||||
@@ -53,7 +53,7 @@ erDiagram
|
||||
open_uni_population_searches
|
||||
progress_criteria
|
||||
study_guidance_group_tags
|
||||
study_programme_pins
|
||||
degree_programme_pins
|
||||
tag ||--o{ tag_student : "belongs to"
|
||||
tag
|
||||
tag_student
|
||||
|
||||
@@ -55,11 +55,11 @@ The `semester_enrollments` column is an array of JSON objects. Each object conta
|
||||
|
||||
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 | Inactive | neither 1 or 2 has been declared |
|
||||
| value | meaning | notes |
|
||||
| ----: | :------ | :------------------------------- |
|
||||
| 1 | Present | |
|
||||
| 2 | Absent | |
|
||||
| 3 | Passive | neither 1 or 2 has been declared |
|
||||
|
||||
## Credit type code
|
||||
|
||||
|
||||
@@ -2,26 +2,19 @@
|
||||
|
||||
A collection of useful tools, documentation and other resources.
|
||||
|
||||
## AI tools
|
||||
|
||||
- [ChatGPT](https://chatgpt.com)
|
||||
- [CurreChat](https://curre.helsinki.fi/chat)
|
||||
- [GitHub Copilot](https://github.com/features/copilot)
|
||||
- Free for students!
|
||||
- [Microsoft Copilot](https://copilot.microsoft.com)
|
||||
|
||||
## Code style and maintainability
|
||||
|
||||
- [CodeClimate documentation](https://docs.codeclimate.com/docs)
|
||||
- [Advanced configuration](https://docs.codeclimate.com/docs/advanced-configuration)
|
||||
- [ESLint documentation](https://eslint.org/docs/v8.x)
|
||||
- [Prettier documentation](https://prettier.io/docs/en)
|
||||
- [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
|
||||
- Install: `$ pip install pgcli`
|
||||
- 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/)
|
||||
|
||||
@@ -29,8 +22,6 @@ A collection of useful tools, documentation and other resources.
|
||||
|
||||
- [GitHub documentation](https://docs.github.com/en)
|
||||
- [GitHub Actions](https://docs.github.com/en/actions)
|
||||
- [GitHub Education](https://github.com/education/students)
|
||||
- Free benefits for students!
|
||||
- [Mermaid.js documentation](https://mermaid.js.org/intro/syntax-reference.html)
|
||||
|
||||
## TypeScript
|
||||
@@ -46,7 +37,6 @@ A collection of useful tools, documentation and other resources.
|
||||
- [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)
|
||||
- [Semantic UI React documentation](https://react.semantic-ui.com)
|
||||
|
||||
<!--
|
||||
|
||||
|
||||
+20
-2
@@ -62,7 +62,7 @@ export default tseslint.config(
|
||||
'camelcase': 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
|
||||
'class-methods-use-this': 'off', // Disabled in favor of @typescript-eslint
|
||||
'consistent-return': 'off', // Disabled in favor of @typescript-eslint
|
||||
'id-denylist': ['error', 'c', 'd', 'e', 'err', 't'],
|
||||
'id-denylist': ['error', 'err'],
|
||||
'no-alert': 'error',
|
||||
'no-await-in-loop': 'error',
|
||||
'no-console': 'error',
|
||||
@@ -75,7 +75,14 @@ export default tseslint.config(
|
||||
'prefer-const': 'error',
|
||||
'prefer-destructuring': 'off', // Disabled in favor of @typescript-eslint
|
||||
'dot-notation': 'off', // Disabled in favor of @typescript-eslint
|
||||
'no-restricted-imports': ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
|
||||
'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).' }
|
||||
]
|
||||
}],
|
||||
'quotes': ['error', 'single',
|
||||
{ avoidEscape: true, allowTemplateLiterals: false }
|
||||
],
|
||||
@@ -205,6 +212,17 @@ export default tseslint.config(
|
||||
]
|
||||
},
|
||||
|
||||
// Override for backend api tests
|
||||
{
|
||||
files: [
|
||||
"services/backend/tests**/*.ts"
|
||||
],
|
||||
rules: {
|
||||
"import-x/no-extraneous-dependencies": "off",
|
||||
'no-console': 'off',
|
||||
}
|
||||
},
|
||||
|
||||
// Overrides for CommonJS
|
||||
{
|
||||
files: [
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
const { parserPlugins } = require('@istanbuljs/schema').defaults.nyc
|
||||
|
||||
module.exports = {
|
||||
cache: false,
|
||||
parserPlugins: parserPlugins.concat('typescript', 'jsx'),
|
||||
}
|
||||
Generated
+31
-4103
File diff suppressed because it is too large
Load Diff
+8
-14
@@ -2,7 +2,7 @@
|
||||
"name": "oodikone",
|
||||
"description": "An application for analyzing university data.",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"cypress": "cypress",
|
||||
@@ -12,12 +12,12 @@
|
||||
"eslint": "eslint",
|
||||
"lint:all": "eslint services/ updater/",
|
||||
"stylelint": "stylelint",
|
||||
"actionlint": "node-actionlint",
|
||||
"prettier": "prettier",
|
||||
"cli": "./cli.sh",
|
||||
"oodikone": "./run.sh oodikone anon up --detach",
|
||||
"oodikone:real": "./run.sh oodikone real up --detach",
|
||||
"oodikone:test": "./run.sh oodikone test up --detach",
|
||||
"oodikone:ci:setup": "docker compose --file docker-compose.ci.yml --profile tests up --detach",
|
||||
"oodikone:ci:api": "docker compose --file docker-compose.test.yml up --exit-code-from api-tests",
|
||||
"updater": "./run.sh updater anon up --detach",
|
||||
"updater:real": "./run.sh updater real up --detach",
|
||||
"both": "./run.sh both anon up --detach",
|
||||
@@ -26,11 +26,12 @@
|
||||
"testupdater": "docker-compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker && SCHEDULE_IMMEDIATE=weekly EXIT_AFTER_IMMEDIATES=yes docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start",
|
||||
"flushredis": "docker exec -it redis redis-cli flushall",
|
||||
"prepare": "husky",
|
||||
"rapodiff": "docker exec -t oodikone_backend_1 /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
|
||||
"rapodiff": "docker exec -t oodikone-backend /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
|
||||
"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",
|
||||
"i:both": "npm run install:both",
|
||||
"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",
|
||||
"install:docker": "docker exec frontend npm i && docker exec oodikone_backend_1 npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone_sis-updater-worker_1 npm i",
|
||||
"ci:docker": "docker exec frontend npm ci && docker exec oodikone_backend_1 npm ci && docker exec sis-updater-scheduler npm ci && docker exec oodikone_sis-updater-worker_1 npm ci",
|
||||
"install:docker": "docker exec frontend npm i && docker exec backend npm i && docker exec sis-updater-scheduler npm i && docker exec sis-updater-worker npm i",
|
||||
"ci:docker": "docker exec frontend npm ci && docker exec backend npm ci && docker exec sis-updater-scheduler npm ci && docker exec sis-updater-worker npm ci",
|
||||
"install:both": "npm run install:local && npm run install:docker",
|
||||
"ci:both": "npm run ci:local && npm run ci:docker"
|
||||
},
|
||||
@@ -44,10 +45,8 @@
|
||||
},
|
||||
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
|
||||
"devDependencies": {
|
||||
"@cypress/code-coverage": "^3.14.4",
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"cypress": "^15.0.0",
|
||||
"cypress": "^15.9.0",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-import-resolver-typescript": "^4.4.3",
|
||||
@@ -59,14 +58,9 @@
|
||||
"globals": "^16.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.0",
|
||||
"node-actionlint": "^1.2.2",
|
||||
"nyc": "^17.1.0",
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "^16.20.0",
|
||||
"stylelint-config-standard": "^39.0.0",
|
||||
"typescript-eslint": "^8.34.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ parse_params() {
|
||||
option=${args[0]}
|
||||
|
||||
# parse arguments
|
||||
[[ ("$option" != "oodikone" && "$option" != "updater" && "$option" != "both") ]] && \
|
||||
[[ ("$option" != "oodikone" && "$option" != "updater" && "$option" != "both" && "$option" != "tests") ]] && \
|
||||
usage && die "Wrong option: $option"
|
||||
|
||||
[[ ${#args[@]} -eq 1 ]] && usage && die "Wrong number of arguments"
|
||||
@@ -47,7 +47,7 @@ parse_params() {
|
||||
compose_command=${args[*]:1}
|
||||
else
|
||||
version=${args[1]}
|
||||
[[ "$version" != "anon" && "$version" != "real" && "$version" != "test" ]] && usage && \
|
||||
[[ "$version" != "anon" && "$version" != "real" && "$version" != "api" ]] && usage && \
|
||||
die "Wrong version $version"
|
||||
compose_command=${args[*]:2}
|
||||
fi
|
||||
@@ -70,8 +70,8 @@ parse_env() {
|
||||
if [[ "$version" == "real" ]]; then
|
||||
env="--file docker-compose.yml --file docker-compose.real.yml"
|
||||
fi
|
||||
if [[ "$version" == "test" ]]; then
|
||||
env="--file docker-compose.yml --file docker-compose.test.yml"
|
||||
if [[ "$version" == "api" ]]; then
|
||||
env="--file docker-compose.ci.yml"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
@@ -85,9 +85,9 @@ parse_params "$@"
|
||||
parse_profiles
|
||||
parse_env
|
||||
if [[ "$compose_command" == "" ]]; then
|
||||
final_command="docker-compose ${env}"
|
||||
final_command="docker compose ${env}"
|
||||
else
|
||||
final_command="docker-compose ${env} ${profiles} ${compose_command}"
|
||||
final_command="docker compose ${env} ${profiles} ${compose_command}"
|
||||
fi
|
||||
|
||||
msg "${BLUE}Running: ${final_command}${NOFORMAT}"
|
||||
|
||||
@@ -11,7 +11,6 @@ PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
||||
DUMP_DIR="$PROJECT_ROOT/.databasedumps"
|
||||
S3_CONFIG_FILE=~/.s3cfg
|
||||
S3_BUCKET="s3://psyduck"
|
||||
DOCKER_COMPOSE=$PROJECT_ROOT/docker-compose.yml
|
||||
|
||||
## 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".
|
||||
@@ -139,29 +138,6 @@ reset_databases() {
|
||||
successmsg "Database setup finished"
|
||||
}
|
||||
|
||||
reset_jami_data() {
|
||||
local database="jami-db"
|
||||
local dump_destination="$DUMP_DIR/$database.sql.gz"
|
||||
local s3_path="jami"
|
||||
|
||||
infomsg "Removing old data"
|
||||
|
||||
|
||||
|
||||
infomsg "Removing database and related volume"
|
||||
docker volume rm oodikone_jami-data || warningmsg "This is okay, continuing"
|
||||
docker-compose -f "$DOCKER_COMPOSE" down "$database" || warningmsg "This is okay, continuing"
|
||||
|
||||
infomsg "Starting postgres in the background"
|
||||
docker-compose -f "$DOCKER_COMPOSE" up -d "$database"
|
||||
retry docker-compose -f "$DOCKER_COMPOSE" exec "$database" pg_isready --dbname="postgres"
|
||||
|
||||
infomsg "Populating Jami"
|
||||
docker exec -i "$database" /bin/bash -c "gunzip | psql -U postgres" < "$dump_destination" 2> /dev/null
|
||||
docker-compose stop "$database"
|
||||
msg ""
|
||||
}
|
||||
|
||||
reset_all_real_data() {
|
||||
infomsg "Downloading real data dumps"
|
||||
for i in ${!DATABASES[*]}; do
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This script is used for instrumenting the frontend code
|
||||
# Counters are added for calculating the code coverage
|
||||
# Instrumented code is used for generating code coverage reports
|
||||
|
||||
# Ensure the script exits if any command fails
|
||||
set -euo pipefail
|
||||
|
||||
# Define the directories
|
||||
SOURCE_DIR="services/frontend/src"
|
||||
INSTRU_DIR="services/frontend/instrumented"
|
||||
|
||||
# Print status
|
||||
echo -n "Instrumenting frontend code... "
|
||||
|
||||
# Create the instrumented directory if it does not exist
|
||||
mkdir -p $INSTRU_DIR
|
||||
|
||||
# Instrument the code
|
||||
nyc instrument --all --compact=false $SOURCE_DIR $INSTRU_DIR
|
||||
|
||||
# Copy remaining files without overwriting instrumented files
|
||||
#
|
||||
# Images, CSS and other static files are not instrumended
|
||||
# but they are still needed to run the application
|
||||
rsync -av --quiet --ignore-existing $SOURCE_DIR/ $INSTRU_DIR/
|
||||
|
||||
# Print status
|
||||
echo "Done!"
|
||||
@@ -0,0 +1,7 @@
|
||||
*/.dockerignore
|
||||
*/.eslintrc.json
|
||||
*/.gitignore
|
||||
*/*Dockerfile
|
||||
*/build
|
||||
*/dist
|
||||
*/node_modules
|
||||
@@ -1,3 +0,0 @@
|
||||
.dockerignore
|
||||
*Dockerfile
|
||||
node_modules/
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM registry.access.redhat.com/ubi10/nodejs-22-minimal AS build
|
||||
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal AS build
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
@@ -29,7 +29,7 @@ RUN npm ci --include=dev --prefix shared && npm ci --include=dev --prefix backen
|
||||
RUN npm run build --prefix shared && npm run build --prefix backend
|
||||
|
||||
|
||||
FROM registry.access.redhat.com/ubi10/nodejs-22-minimal
|
||||
FROM registry.access.redhat.com/ubi9/nodejs-24-minimal
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
@@ -10,4 +10,4 @@ COPY . .
|
||||
RUN npm ci
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["node_modules/.bin/tsx", "watch", "--clear-screen=false", "index.js"]
|
||||
CMD ["node_modules/.bin/nodemon", "--exec", "node --import tsx index.ts", "--ext", "ts,js,json", "--watch", "src", "--watch", "index.ts", "--legacy-watch"]
|
||||
|
||||
Generated
+868
-380
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,15 @@
|
||||
"name": "backend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
"build": "tsc -p .",
|
||||
"test": "node --import tsx --test --test-reporter spec --test-force-exit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@oodikone/shared": "file:../shared",
|
||||
"@sentry/node": "^10.5.0",
|
||||
"@sentry/profiling-node": "^10.5.0",
|
||||
"axios": "^1.9.0",
|
||||
"@sentry/node": "^10.43.0",
|
||||
"@sentry/profiling-node": "^10.43.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/pg": "^8.18.0",
|
||||
"bullmq": "^5.53.2",
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
@@ -16,14 +18,14 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^4.21.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.23",
|
||||
"lru-cache": "^11.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.16.0",
|
||||
"pg": "^8.20.0",
|
||||
"redis": "^4.7.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sequelize": "^6.37.7",
|
||||
"sequelize": "^6.37.8",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"umzug": "^3.8.2",
|
||||
"unfuck-utf8-headers-middleware": "^1.0.1",
|
||||
@@ -35,10 +37,11 @@
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "^22.15.31",
|
||||
"@types/validator": "^13.15.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"nodemon": "^3.1.11",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Node env to use
|
||||
export const isDev = process.env.NODE_ENV === 'development'
|
||||
export const isTest = process.env.NODE_ENV === 'test'
|
||||
export const isStaging = process.env.REACT_APP_STAGING === 'true'
|
||||
export const isProduction = !isStaging && process.env.NODE_ENV === 'production'
|
||||
|
||||
@@ -32,12 +33,6 @@ export const baseUrl = isDev ? '/api' : ''
|
||||
export const frontUrl = process.env.FRONT_URL
|
||||
export const backendPort = 8080
|
||||
|
||||
// System run in whose environment
|
||||
export const serviceProvider = process.env.SERVICE_PROVIDER?.toLowerCase() ?? 'toska'
|
||||
|
||||
// Optional logout-url configuration
|
||||
export const configLogoutUrl = serviceProvider === 'toska' ? undefined : process.env.LOGOUT_URL
|
||||
|
||||
// Other stuff
|
||||
export const { DB_URL_KONE, DB_URL_USER, SECRET_TOKEN, SIS_DB_URL, SIS_UPDATER_URL, CRYPT_KEY } = process.env
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export const refreshProgramme = async (code: string) => {
|
||||
}
|
||||
|
||||
export const refreshProgrammes = async () => {
|
||||
logger.info('Refreshing study programme and study track overview statistics for all programmes')
|
||||
logger.info('Refreshing degree programme and study track overview statistics for all programmes')
|
||||
const facultyCodes = (await getFaculties()).map(faculty => faculty.code)
|
||||
const programmeCodes: string[] = []
|
||||
for (const faculty of facultyCodes) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { omit } from 'lodash'
|
||||
import { omit } from 'lodash-es'
|
||||
import morgan from 'morgan'
|
||||
|
||||
import { FormattedUser } from '../types'
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import * as Sentry from '@sentry/node'
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import { intersection } from 'lodash'
|
||||
import { intersection } from 'lodash-es'
|
||||
|
||||
import { configLogoutUrl, isDev, requiredGroup, serviceProvider } from '../config'
|
||||
import { getMockedUser, getMockedUserFd, getOrganizationAccess, getUserFd, getUserToska } from '../services/userService'
|
||||
import { FormattedUser, IamAccess } from '../types'
|
||||
import { requiredGroup } from '../config'
|
||||
import { getMockedUser, getOrganizationAccess, getUser } from '../services/userService'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
import logger from '../util/logger'
|
||||
|
||||
@@ -14,30 +13,6 @@ const hasRequiredIamGroup = (iamGroups: string[], iamRights: string[]) => {
|
||||
return intersection(iamGroups, requiredGroup).length > 0 || iamRights.length > 0
|
||||
}
|
||||
|
||||
const getUser = async (
|
||||
showAsUser: string | undefined,
|
||||
username: string,
|
||||
name: string,
|
||||
email: string,
|
||||
iamGroups: string[],
|
||||
specialGroup: Record<string, boolean>,
|
||||
sisId: string,
|
||||
iamAccess: IamAccess
|
||||
) => {
|
||||
if (showAsUser && specialGroup?.superAdmin) {
|
||||
return await getMockedUser({ userToMock: showAsUser, mockedBy: username })
|
||||
}
|
||||
return await getUserToska({
|
||||
username,
|
||||
name,
|
||||
email,
|
||||
iamGroups,
|
||||
specialGroup,
|
||||
sisId,
|
||||
iamAccess,
|
||||
})
|
||||
}
|
||||
|
||||
type Headers = {
|
||||
displayname?: string
|
||||
hygroupcn?: string
|
||||
@@ -49,7 +24,7 @@ type Headers = {
|
||||
'x-show-as-user'?: string
|
||||
}
|
||||
|
||||
const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunction) => {
|
||||
export default async (req: Request, _: Response, next: NextFunction) => {
|
||||
const {
|
||||
'shib-session-id': sessionId,
|
||||
'x-show-as-user': showAsUser,
|
||||
@@ -61,20 +36,15 @@ const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunct
|
||||
uid: username,
|
||||
} = req.headers as Headers
|
||||
|
||||
const missingHeaders: string[] = []
|
||||
if (!sessionId) missingHeaders.push('shib-session-id')
|
||||
if (!username) missingHeaders.push('uid')
|
||||
|
||||
if (missingHeaders.length > 0) {
|
||||
const reqUser = username ?? 'Anonymous user'
|
||||
if (!username || !sessionId)
|
||||
throw new ApplicationError(
|
||||
`${reqUser} requested ${req.url} without valid request headers. Missing: ${missingHeaders.join(', ')}`,
|
||||
`${username ?? 'Anonymous user'} requested ${req.url} without authorization headers.`,
|
||||
403,
|
||||
{ logoutUrl }
|
||||
)
|
||||
}
|
||||
|
||||
const iamGroups = parseIamGroups(hygroupcn!)
|
||||
|
||||
const { iamAccess = {}, specialGroup = {} } = await getOrganizationAccess(sisId!, iamGroups)
|
||||
const iamRights = Object.keys(iamAccess)
|
||||
|
||||
@@ -83,49 +53,29 @@ const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunct
|
||||
message: 'User does not have required iam group',
|
||||
meta: { username, name, email, iamGroups, iamRights },
|
||||
})
|
||||
|
||||
throw new ApplicationError(`User '${username}' does not have required iam group`, 403, { logoutUrl })
|
||||
}
|
||||
|
||||
const user = await getUser(showAsUser, username!, name!, email!, iamGroups, specialGroup, sisId!, iamAccess)
|
||||
if (!user) {
|
||||
throw new ApplicationError(`Username ${username} not found.`, 403, { logoutUrl })
|
||||
}
|
||||
const user =
|
||||
showAsUser && specialGroup?.superAdmin
|
||||
? await getMockedUser({ userToMock: showAsUser, mockedBy: username })
|
||||
: await getUser({
|
||||
username,
|
||||
name: name!,
|
||||
email: email!,
|
||||
iamGroups,
|
||||
specialGroup,
|
||||
sisId: sisId!,
|
||||
iamAccess,
|
||||
})
|
||||
|
||||
Sentry.setUser({ username: user.mockedBy ?? username! })
|
||||
// Set username just-in-case there is error for invalid user
|
||||
Sentry.setUser({ username: user?.mockedBy ?? username })
|
||||
if (!user) throw new ApplicationError(`Username ${username} not found.`, 403, { logoutUrl })
|
||||
|
||||
req.user = user
|
||||
req.logoutUrl = logoutUrl!
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
const fdUserMiddleware = async (req: Request, _res: Response, next: NextFunction) => {
|
||||
const { remote_user: remoteUser, 'x-show-as-user': showAsUser } = req.headers
|
||||
|
||||
if (!remoteUser) {
|
||||
throw new ApplicationError('Not enough data in request headers, remote_user was missing', 403, { configLogoutUrl })
|
||||
}
|
||||
|
||||
let user: FormattedUser | null
|
||||
|
||||
// getMockedUser in production requires the superAdmin role, which is only available
|
||||
// via iamGroups, so it's now only implemented for the dev environment
|
||||
if (showAsUser && isDev) {
|
||||
user = await getMockedUserFd({ userToMock: showAsUser as string, mockedBy: remoteUser as string })
|
||||
} else {
|
||||
user = await getUserFd({ username: remoteUser as string })
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new ApplicationError(`Could not grant access with the eppn ${remoteUser}.`, 403, { configLogoutUrl })
|
||||
}
|
||||
|
||||
req.user = user
|
||||
req.logoutUrl = configLogoutUrl
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
const currentUserMiddleware = serviceProvider === 'toska' ? toskaUserMiddleware : fdUserMiddleware
|
||||
|
||||
export default currentUserMiddleware
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import headersMiddleware from 'unfuck-utf8-headers-middleware'
|
||||
|
||||
import { serviceProvider } from '../config'
|
||||
|
||||
const headersToska = ['uid', 'displayName', 'shib-session-id', 'hyGroupCn', 'mail', 'hyPersonSisuId']
|
||||
const headersOther = ['remote_user']
|
||||
|
||||
const headers = serviceProvider === 'toska' ? headersToska : headersOther
|
||||
|
||||
const headers = ['uid', 'displayName', 'shib-session-id', 'hyGroupCn', 'mail', 'hyPersonSisuId']
|
||||
export default headersMiddleware(headers)
|
||||
|
||||
@@ -11,16 +11,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type {
|
||||
Credit,
|
||||
Enrollment,
|
||||
Organization,
|
||||
SISStudyRight,
|
||||
SISStudyRightElement,
|
||||
Student,
|
||||
Studyplan,
|
||||
} from '@oodikone/shared/models'
|
||||
import { ExtentCode, SemesterEnrollment } from '@oodikone/shared/types'
|
||||
import type { SISStudyRight } from '@oodikone/shared/models'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { EnrollmentModel } from './enrollment'
|
||||
@@ -34,69 +25,72 @@ import { StudyrightExtentModel } from './studyrightExtent'
|
||||
underscored: true,
|
||||
tableName: 'sis_study_rights',
|
||||
})
|
||||
export class SISStudyRightModel extends Model<SISStudyRight> implements SISStudyRight {
|
||||
export class SISStudyRightModel extends Model implements SISStudyRight {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: SISStudyRight['id']
|
||||
|
||||
@ForeignKey(() => OrganizationModel)
|
||||
@Column(DataType.STRING)
|
||||
facultyCode!: string
|
||||
declare facultyCode: SISStudyRight['facultyCode']
|
||||
|
||||
@BelongsTo(() => OrganizationModel, { foreignKey: 'facultyCode', targetKey: 'code' })
|
||||
organization!: Organization
|
||||
declare organization: SISStudyRight['organization']
|
||||
|
||||
@HasMany(() => StudyplanModel)
|
||||
studyPlans!: Studyplan[]
|
||||
declare studyPlans: SISStudyRight['studyPlans']
|
||||
|
||||
@HasMany(() => SISStudyRightElementModel, { foreignKey: 'studyRightId' })
|
||||
studyRightElements!: SISStudyRightElement[]
|
||||
declare studyRightElements: SISStudyRight['studyRightElements']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
startDate!: Date
|
||||
declare startDate: SISStudyRight['startDate']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
endDate!: Date
|
||||
declare endDate: SISStudyRight['endDate']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
studyStartDate!: Date
|
||||
declare studyStartDate: SISStudyRight['studyStartDate']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
cancelled!: boolean
|
||||
declare cancelled: SISStudyRight['cancelled']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
studentNumber!: string
|
||||
declare studentNumber: SISStudyRight['studentNumber']
|
||||
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'studentNumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
declare student: SISStudyRight['student']
|
||||
|
||||
@ForeignKey(() => StudyrightExtentModel)
|
||||
@Column(DataType.INTEGER)
|
||||
extentCode!: ExtentCode
|
||||
declare extentCode: SISStudyRight['extentCode']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
admissionType!: string
|
||||
declare admissionType: SISStudyRight['admissionType']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
semesterEnrollments!: SemesterEnrollment[] | null
|
||||
declare semesterEnrollments: SISStudyRight['semesterEnrollments']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
declare transferInfo: SISStudyRight['transferInfo']
|
||||
|
||||
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
credits!: Credit[]
|
||||
declare credits: SISStudyRight['credits']
|
||||
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
enrollments!: Enrollment[]
|
||||
declare enrollments: SISStudyRight['enrollments']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
tvex!: boolean
|
||||
declare tvex: SISStudyRight['tvex']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
expirationRuleUrns!: string[]
|
||||
declare expirationRuleUrns: SISStudyRight['expirationRuleUrns']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: SISStudyRight['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: SISStudyRight['updatedAt']
|
||||
}
|
||||
|
||||
@@ -10,57 +10,54 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import type { Name, StudyTrack } from '@oodikone/shared/types'
|
||||
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
|
||||
|
||||
import type { SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
tableName: 'sis_study_right_elements',
|
||||
})
|
||||
export class SISStudyRightElementModel extends Model<SISStudyRightElement> implements SISStudyRightElement {
|
||||
export class SISStudyRightElementModel extends Model implements SISStudyRightElement {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: SISStudyRightElement['id']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
startDate!: Date
|
||||
declare startDate: SISStudyRightElement['startDate']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
endDate!: Date
|
||||
declare endDate: SISStudyRightElement['endDate']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
graduated!: boolean
|
||||
declare graduated: SISStudyRightElement['graduated']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
phase!: Phase
|
||||
declare phase: SISStudyRightElement['phase']
|
||||
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column(DataType.STRING)
|
||||
studyRightId!: string
|
||||
declare studyRightId: SISStudyRightElement['studyRightId']
|
||||
|
||||
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyRightId' })
|
||||
studyRight!: SISStudyRight
|
||||
declare studyRight: SISStudyRightElement['studyRight']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
declare code: SISStudyRightElement['code']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Name
|
||||
declare name: SISStudyRightElement['name']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
studyTrack!: StudyTrack | null
|
||||
declare studyTrack: SISStudyRightElement['studyTrack']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
degreeProgrammeType!: DegreeProgrammeType
|
||||
declare degreeProgrammeType: SISStudyRightElement['degreeProgrammeType']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: SISStudyRightElement['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: SISStudyRightElement['updatedAt']
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { Course, CourseType, Credit, Enrollment, Organization } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
import type { Course } from '@oodikone/shared/models'
|
||||
|
||||
import { CourseProviderModel } from './courseProvider'
|
||||
import { CourseTypeModel } from './courseType'
|
||||
@@ -26,57 +25,60 @@ import { OrganizationModel } from './organization'
|
||||
modelName: 'course',
|
||||
tableName: 'course',
|
||||
})
|
||||
export class CourseModel extends Model<Course> implements Course {
|
||||
export class CourseModel extends Model implements Course {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: Course['id']
|
||||
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
declare code: Course['code']
|
||||
|
||||
@ForeignKey(() => CourseTypeModel)
|
||||
@Column(DataType.STRING)
|
||||
coursetypecode!: string
|
||||
declare coursetypecode: Course['coursetypecode']
|
||||
|
||||
@BelongsTo(() => CourseTypeModel, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
|
||||
courseType!: CourseType
|
||||
declare courseType: Course['courseType']
|
||||
|
||||
@HasMany(() => CreditModel, { foreignKey: 'course_id', sourceKey: 'id' })
|
||||
credits!: Credit[]
|
||||
declare credits: Course['credits']
|
||||
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'course_id' })
|
||||
enrollments!: Enrollment[]
|
||||
declare enrollments: Course['enrollments']
|
||||
|
||||
@BelongsToMany(() => OrganizationModel, () => CourseProviderModel, 'coursecode')
|
||||
organizations!: Organization[]
|
||||
declare organizations: Course['organizations']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
is_study_module!: boolean
|
||||
declare is_study_module: Course['is_study_module']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Name
|
||||
declare name: Course['name']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
max_attainment_date!: Date
|
||||
declare max_attainment_date: Course['max_attainment_date']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
min_attainment_date!: Date
|
||||
declare min_attainment_date: Course['min_attainment_date']
|
||||
|
||||
@CreatedAt
|
||||
@Column({ field: 'created_at', type: DataType.DATE })
|
||||
createdAt!: Date
|
||||
declare createdAt: Course['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column({ field: 'updated_at', type: DataType.DATE })
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Course['updatedAt']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
substitutions!: string[]
|
||||
declare substitutions: Course['substitutions']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
declare substitution_groups: Course['substitution_groups']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
course_unit_type!: string
|
||||
declare course_unit_type: Course['course_unit_type']
|
||||
|
||||
@Column({ field: 'main_course_code', type: DataType.STRING })
|
||||
mainCourseCode!: string
|
||||
declare mainCourseCode: Course['mainCourseCode']
|
||||
}
|
||||
|
||||
@@ -10,23 +10,23 @@ import { OrganizationModel } from './organization'
|
||||
modelName: 'course_provider',
|
||||
tableName: 'course_providers',
|
||||
})
|
||||
export class CourseProviderModel extends Model<CourseProvider> implements CourseProvider {
|
||||
export class CourseProviderModel extends Model implements CourseProvider {
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
coursecode!: string
|
||||
declare coursecode: CourseProvider['coursecode']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
shares!: Array<{ share: number; startDate?: string; endDate?: string }> | null
|
||||
declare shares: CourseProvider['shares']
|
||||
|
||||
@ForeignKey(() => OrganizationModel)
|
||||
@Column(DataType.STRING)
|
||||
organizationcode!: string
|
||||
declare organizationcode: CourseProvider['organizationcode']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: CourseProvider['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: CourseProvider['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { Course, CourseType } from '@oodikone/shared/models'
|
||||
import type { CourseType } from '@oodikone/shared/models'
|
||||
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { CourseModel } from './course'
|
||||
|
||||
@Table({
|
||||
@@ -10,22 +9,22 @@ import { CourseModel } from './course'
|
||||
modelName: 'course_type',
|
||||
tableName: 'course_types',
|
||||
})
|
||||
export class CourseTypeModel extends Model<CourseType> implements CourseType {
|
||||
export class CourseTypeModel extends Model implements CourseType {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
coursetypecode!: string
|
||||
declare coursetypecode: CourseType['coursetypecode']
|
||||
|
||||
@HasMany(() => CourseModel)
|
||||
courses!: Course[]
|
||||
declare courses: CourseType['courses']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Required<Name>
|
||||
declare name: CourseType['name']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: CourseType['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: CourseType['updatedAt']
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { Credit, Course, CreditType, Semester, SISStudyRight, Student, Teacher } from '@oodikone/shared/models'
|
||||
import type { Credit } from '@oodikone/shared/models'
|
||||
import { CreditTypeCode } from '@oodikone/shared/types'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
@@ -27,85 +27,85 @@ import { TeacherModel } from './teacher'
|
||||
modelName: 'credit',
|
||||
tableName: 'credit',
|
||||
})
|
||||
export class CreditModel extends Model<Credit> implements Credit {
|
||||
export class CreditModel extends Model implements Credit {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: Credit['id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
grade!: string
|
||||
declare grade: Credit['grade']
|
||||
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
student_studentnumber!: string
|
||||
declare student_studentnumber: Credit['student_studentnumber']
|
||||
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
declare student: Credit['student']
|
||||
|
||||
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite' })
|
||||
semester!: Semester
|
||||
declare semester: Credit['semester']
|
||||
|
||||
@Column(DataType.DOUBLE)
|
||||
credits!: number
|
||||
declare credits: Credit['credits']
|
||||
|
||||
@ForeignKey(() => CreditTypeModel)
|
||||
@Column(DataType.INTEGER)
|
||||
credittypecode!: CreditTypeCode
|
||||
declare credittypecode: Credit['credittypecode']
|
||||
|
||||
@BelongsTo(() => CreditTypeModel, { foreignKey: 'credittypecode', targetKey: 'credittypecode' })
|
||||
creditType!: CreditType
|
||||
declare creditType: Credit['creditType']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
createdate!: Date
|
||||
declare createdate: Credit['createdate']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
attainment_date!: Date
|
||||
declare attainment_date: Credit['attainment_date']
|
||||
|
||||
@BelongsToMany(() => TeacherModel, () => CreditTeacherModel, 'credit_id')
|
||||
teachers!: Teacher[]
|
||||
declare teachers: Credit['teachers']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
course_code!: string
|
||||
declare course_code: Credit['course_code']
|
||||
|
||||
@BelongsTo(() => CourseModel, { foreignKey: 'course_id' })
|
||||
course!: Course
|
||||
declare course: Credit['course']
|
||||
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
course_id!: string
|
||||
declare course_id: Credit['course_id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
semester_composite!: string
|
||||
declare semester_composite: Credit['semester_composite']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
semestercode!: number
|
||||
declare semestercode: Credit['semestercode']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
isStudyModule!: boolean
|
||||
declare isStudyModule: Credit['isStudyModule']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
org!: string
|
||||
declare org: Credit['org']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
language!: string
|
||||
declare language: Credit['language']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
is_open!: boolean
|
||||
declare is_open: Credit['is_open']
|
||||
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column(DataType.STRING)
|
||||
studyright_id!: string
|
||||
declare studyright_id: Credit['studyright_id']
|
||||
|
||||
@BelongsTo(() => SISStudyRightModel)
|
||||
studyright!: SISStudyRight
|
||||
declare studyright: Credit['studyright']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Credit['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Credit['updatedAt']
|
||||
|
||||
static passed: ({ credittypecode }: { credittypecode: CreditTypeCode }) => boolean
|
||||
|
||||
|
||||
@@ -10,20 +10,20 @@ import { TeacherModel } from './teacher'
|
||||
modelName: 'credit_teacher',
|
||||
tableName: 'credit_teachers',
|
||||
})
|
||||
export class CreditTeacherModel extends Model<CreditTeacher> implements CreditTeacher {
|
||||
export class CreditTeacherModel extends Model implements CreditTeacher {
|
||||
@ForeignKey(() => CreditModel)
|
||||
@Column(DataType.STRING)
|
||||
credit_id!: string
|
||||
declare credit_id: CreditTeacher['credit_id']
|
||||
|
||||
@ForeignKey(() => TeacherModel)
|
||||
@Column(DataType.STRING)
|
||||
teacher_id!: string
|
||||
declare teacher_id: CreditTeacher['teacher_id']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: CreditTeacher['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: CreditTeacher['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { CreditType } from '@oodikone/shared/models'
|
||||
import { CreditTypeCode, Name } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
@@ -11,16 +10,16 @@ import { CreditTypeCode, Name } from '@oodikone/shared/types'
|
||||
export class CreditTypeModel extends Model<CreditType> implements CreditType {
|
||||
@PrimaryKey
|
||||
@Column(DataType.INTEGER)
|
||||
credittypecode!: CreditTypeCode
|
||||
declare credittypecode: CreditType['credittypecode']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Required<Name>
|
||||
declare name: CreditType['name']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: CreditType['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: CreditType['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
import { Column, CreatedAt, DataType, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { CurriculumPeriod } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'curriculum_period',
|
||||
tableName: 'curriculum_periods',
|
||||
})
|
||||
export class CurriculumPeriodModel extends Model<CurriculumPeriod> implements CurriculumPeriod {
|
||||
export class CurriculumPeriodModel extends Model implements CurriculumPeriod {
|
||||
@Column({ type: DataType.STRING, primaryKey: true })
|
||||
id!: string
|
||||
declare id: CurriculumPeriod['id']
|
||||
|
||||
@Column({ type: DataType.JSONB, allowNull: false })
|
||||
name!: Name
|
||||
declare name: CurriculumPeriod['name']
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: false })
|
||||
universityOrgId!: string
|
||||
declare universityOrgId: CurriculumPeriod['universityOrgId']
|
||||
|
||||
@Column({ type: DataType.DATE, allowNull: false })
|
||||
startDate!: Date
|
||||
declare startDate: CurriculumPeriod['startDate']
|
||||
|
||||
@Column({ type: DataType.DATE, allowNull: false })
|
||||
endDate!: Date
|
||||
declare endDate: CurriculumPeriod['endDate']
|
||||
|
||||
@CreatedAt
|
||||
@Column({ type: DataType.DATE, allowNull: false })
|
||||
createdAt!: Date
|
||||
declare createdAt: CurriculumPeriod['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column({ type: DataType.DATE, allowNull: false })
|
||||
updatedAt!: Date
|
||||
declare updatedAt: CurriculumPeriod['updatedAt']
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { Enrollment, Semester, SISStudyRight, Student } from '@oodikone/shared/models'
|
||||
import { EnrollmentState } from '@oodikone/shared/types'
|
||||
import type { Enrollment } from '@oodikone/shared/models'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
import { SemesterModel } from './semester'
|
||||
@@ -23,59 +22,59 @@ import { StudentModel } from './student'
|
||||
modelName: 'enrollment',
|
||||
tableName: 'enrollment',
|
||||
})
|
||||
export class EnrollmentModel extends Model<Enrollment> implements Enrollment {
|
||||
export class EnrollmentModel extends Model implements Enrollment {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: Enrollment['id']
|
||||
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
declare studentnumber: Enrollment['studentnumber']
|
||||
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
declare student: Enrollment['student']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
course_code!: string
|
||||
declare course_code: Enrollment['course_code']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
state!: EnrollmentState
|
||||
declare state: Enrollment['state']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
enrollment_date_time!: Date
|
||||
declare enrollment_date_time: Enrollment['enrollment_date_time']
|
||||
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
course_id!: string
|
||||
declare course_id: Enrollment['course_id']
|
||||
|
||||
@ForeignKey(() => SemesterModel)
|
||||
@Column(DataType.STRING)
|
||||
semester_composite!: string
|
||||
declare semester_composite: Enrollment['semester_composite']
|
||||
|
||||
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite', targetKey: 'composite' })
|
||||
semester!: Semester
|
||||
declare semester: Enrollment['semester']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
semestercode!: number
|
||||
declare semestercode: Enrollment['semestercode']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
is_open!: boolean
|
||||
declare is_open: Enrollment['is_open']
|
||||
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column({ type: DataType.STRING, allowNull: true })
|
||||
studyright_id!: string
|
||||
declare studyright_id: Enrollment['studyright_id']
|
||||
|
||||
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyright_id', targetKey: 'id', constraints: false })
|
||||
studyright!: SISStudyRight
|
||||
declare studyright: Enrollment['studyright']
|
||||
|
||||
@BelongsTo(() => CourseModel, { foreignKey: 'course_id', targetKey: 'id' })
|
||||
course!: CourseModel
|
||||
declare course: Enrollment['course']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Enrollment['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Enrollment['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { CustomPopulationSearch, CustomPopulationSearchCreation } from '@oodikone/shared/models/kone'
|
||||
import type { CustomPopulationSearch } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'custom_population_search',
|
||||
tableName: 'custom_population_searches',
|
||||
})
|
||||
export class CustomPopulationSearchModel
|
||||
extends Model<CustomPopulationSearch, CustomPopulationSearchCreation>
|
||||
implements CustomPopulationSearch
|
||||
{
|
||||
export class CustomPopulationSearchModel extends Model implements CustomPopulationSearch {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
id!: CreationOptional<string>
|
||||
declare id: CustomPopulationSearch['id']
|
||||
|
||||
@Column(DataType.BIGINT)
|
||||
userId!: string
|
||||
declare userId: CustomPopulationSearch['userId']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
name!: string
|
||||
declare name: CustomPopulationSearch['name']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
students!: string[]
|
||||
declare students: CustomPopulationSearch['students']
|
||||
}
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { Column, DataType, Model, PrimaryKey, Table, AutoIncrement, Default } from 'sequelize-typescript'
|
||||
|
||||
import type { ExcludedCourse, ExcludedCourseCreation } from '@oodikone/shared/models/kone'
|
||||
import type { ExcludedCourse } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'excluded_course',
|
||||
tableName: 'excluded_courses',
|
||||
})
|
||||
export class ExcludedCourseModel extends Model<ExcludedCourse, ExcludedCourseCreation> implements ExcludedCourse {
|
||||
export class ExcludedCourseModel extends Model implements ExcludedCourse {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.INTEGER)
|
||||
id!: CreationOptional<number>
|
||||
declare id: ExcludedCourse['id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
programme_code!: string
|
||||
declare programme_code: ExcludedCourse['programme_code']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
course_code!: string
|
||||
declare course_code: ExcludedCourse['course_code']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
curriculum_version!: string
|
||||
declare curriculum_version: ExcludedCourse['curriculum_version']
|
||||
|
||||
@Default(DataType.NOW)
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: CreationOptional<Date>
|
||||
declare createdAt: ExcludedCourse['createdAt']
|
||||
|
||||
@Default(DataType.NOW)
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: CreationOptional<Date>
|
||||
declare updatedAt: ExcludedCourse['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { OpenUniPopulationSearch, OpenUniPopulationSearchCreation } from '@oodikone/shared/models/kone'
|
||||
import type { OpenUniPopulationSearch } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
@@ -9,21 +8,18 @@ import type { OpenUniPopulationSearch, OpenUniPopulationSearchCreation } from '@
|
||||
modelName: 'open_uni_population_search',
|
||||
tableName: 'open_uni_population_searches',
|
||||
})
|
||||
export class OpenUniPopulationSearchModel
|
||||
extends Model<OpenUniPopulationSearch, OpenUniPopulationSearchCreation>
|
||||
implements OpenUniPopulationSearch
|
||||
{
|
||||
export class OpenUniPopulationSearchModel extends Model implements OpenUniPopulationSearch {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
id!: CreationOptional<string>
|
||||
declare id: OpenUniPopulationSearch['id']
|
||||
|
||||
@Column(DataType.BIGINT)
|
||||
userId!: string
|
||||
declare userId: OpenUniPopulationSearch['userId']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
name!: string
|
||||
declare name: OpenUniPopulationSearch['name']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
courseCodes!: string[]
|
||||
declare courseCodes: OpenUniPopulationSearch['courseCodes']
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { ProgressCriteria, ProgressCriteriaCreation } from '@oodikone/shared/models/kone'
|
||||
import type { ProgressCriteria } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
@@ -9,50 +8,47 @@ import type { ProgressCriteria, ProgressCriteriaCreation } from '@oodikone/share
|
||||
modelName: 'progress_criteria',
|
||||
tableName: 'progress_criteria',
|
||||
})
|
||||
export class ProgressCriteriaModel
|
||||
extends Model<ProgressCriteria, ProgressCriteriaCreation>
|
||||
implements ProgressCriteria
|
||||
{
|
||||
export class ProgressCriteriaModel extends Model implements ProgressCriteria {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
declare code: ProgressCriteria['code']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
curriculumVersion!: CreationOptional<string>
|
||||
declare curriculumVersion: ProgressCriteria['curriculumVersion']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
coursesYearOne!: string[]
|
||||
declare coursesYearOne: ProgressCriteria['coursesYearOne']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
coursesYearTwo!: string[]
|
||||
declare coursesYearTwo: ProgressCriteria['coursesYearTwo']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
coursesYearThree!: string[]
|
||||
declare coursesYearThree: ProgressCriteria['coursesYearThree']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
coursesYearFour!: string[]
|
||||
declare coursesYearFour: ProgressCriteria['coursesYearFour']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
coursesYearFive!: string[]
|
||||
declare coursesYearFive: ProgressCriteria['coursesYearFive']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
coursesYearSix!: string[]
|
||||
declare coursesYearSix: ProgressCriteria['coursesYearSix']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditsYearOne!: number
|
||||
declare creditsYearOne: ProgressCriteria['creditsYearOne']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditsYearTwo!: number
|
||||
declare creditsYearTwo: ProgressCriteria['creditsYearTwo']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditsYearThree!: number
|
||||
declare creditsYearThree: ProgressCriteria['creditsYearThree']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditsYearFour!: number
|
||||
declare creditsYearFour: ProgressCriteria['creditsYearFour']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditsYearFive!: number
|
||||
declare creditsYearFive: ProgressCriteria['creditsYearFive']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditsYearSix!: number
|
||||
declare creditsYearSix: ProgressCriteria['creditsYearSix']
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
|
||||
|
||||
import type { StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation } from '@oodikone/shared/models/kone'
|
||||
import type { StudyGuidanceGroupTag } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
@@ -9,22 +8,19 @@ import type { StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation } from '@oodi
|
||||
modelName: 'study_guidance_group_tag',
|
||||
tableName: 'study_guidance_group_tags',
|
||||
})
|
||||
export class StudyGuidanceGroupTagModel
|
||||
extends Model<StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation>
|
||||
implements StudyGuidanceGroupTag
|
||||
{
|
||||
export class StudyGuidanceGroupTagModel extends Model implements StudyGuidanceGroupTag {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
id?: CreationOptional<string>
|
||||
declare id: StudyGuidanceGroupTag['id']
|
||||
|
||||
@Unique
|
||||
@Column(DataType.STRING)
|
||||
studyGuidanceGroupId!: string
|
||||
declare studyGuidanceGroupId: StudyGuidanceGroupTag['studyGuidanceGroupId']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
studyProgramme!: string | null
|
||||
declare studyProgramme: StudyGuidanceGroupTag['studyProgramme']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
year!: string | null
|
||||
declare year: StudyGuidanceGroupTag['year']
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import type { StudyProgrammePin } from '@oodikone/shared/models/kone'
|
||||
modelName: 'study_programme_pin',
|
||||
tableName: 'study_programme_pins',
|
||||
})
|
||||
export class StudyProgrammePinModel extends Model<StudyProgrammePin> implements StudyProgrammePin {
|
||||
export class StudyProgrammePinModel extends Model implements StudyProgrammePin {
|
||||
@PrimaryKey
|
||||
@Column(DataType.INTEGER)
|
||||
userId!: number
|
||||
declare userId: StudyProgrammePin['userId']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
studyProgrammes!: string[]
|
||||
declare studyProgrammes: StudyProgrammePin['studyProgrammes']
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { Tag, TagCreation, TagStudent } from '@oodikone/shared/models/kone'
|
||||
import type { Tag } from '@oodikone/shared/models/kone'
|
||||
|
||||
import { TagStudentModel } from './tagStudent'
|
||||
|
||||
@@ -9,25 +8,25 @@ import { TagStudentModel } from './tagStudent'
|
||||
modelName: 'tag',
|
||||
tableName: 'tag',
|
||||
})
|
||||
export class TagModel extends Model<Tag, TagCreation> implements Tag {
|
||||
export class TagModel extends Model implements Tag {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@ForeignKey(() => TagStudentModel)
|
||||
@Column(DataType.BIGINT)
|
||||
tag_id!: CreationOptional<string>
|
||||
declare tag_id: Tag['tag_id']
|
||||
|
||||
@BelongsTo(() => TagStudentModel)
|
||||
tagStudent!: CreationOptional<TagStudent>
|
||||
declare tagStudent: Tag['tagStudent']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
tagname!: string
|
||||
declare tagname: Tag['tagname']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
studytrack!: string
|
||||
declare studytrack: Tag['studytrack']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
year!: string | null
|
||||
declare year: Tag['year']
|
||||
|
||||
@Column(DataType.BIGINT)
|
||||
personal_user_id!: string | null
|
||||
declare personal_user_id: Tag['personal_user_id']
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { Tag, TagStudent, TagStudentCreation } from '@oodikone/shared/models/kone'
|
||||
import type { TagStudent } from '@oodikone/shared/models/kone'
|
||||
|
||||
import { StudentModel } from '../student'
|
||||
import { TagModel } from './tag'
|
||||
@@ -10,18 +9,18 @@ import { TagModel } from './tag'
|
||||
modelName: 'tag_student',
|
||||
tableName: 'tag_student',
|
||||
})
|
||||
export class TagStudentModel extends Model<TagStudent, TagStudentCreation> implements TagStudent {
|
||||
export class TagStudentModel extends Model implements TagStudent {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@ForeignKey(() => TagModel)
|
||||
@Column(DataType.BIGINT)
|
||||
tag_id!: string
|
||||
declare tag_id: TagStudent['tag_id']
|
||||
|
||||
@PrimaryKey
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
declare studentnumber: TagStudent['studentnumber']
|
||||
|
||||
@BelongsTo(() => TagModel)
|
||||
tag!: CreationOptional<Tag>
|
||||
declare tag: TagStudent['tag']
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { Organization, ProgrammeModule, SISStudyRight } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
import type { Organization } from '@oodikone/shared/models'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
import { CourseProviderModel } from './courseProvider'
|
||||
@@ -23,37 +22,37 @@ import { SISStudyRightModel } from './SISStudyRight'
|
||||
modelName: 'organization',
|
||||
tableName: 'organization',
|
||||
})
|
||||
export class OrganizationModel extends Model<Organization> implements Organization {
|
||||
export class OrganizationModel extends Model implements Organization {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: Organization['id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
declare code: Organization['code']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Name
|
||||
declare name: Organization['name']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
parent_id!: string
|
||||
declare parent_id: Organization['parent_id']
|
||||
|
||||
@HasMany(() => OrganizationModel, { foreignKey: 'parent_id', as: 'children' })
|
||||
children!: Organization[]
|
||||
declare children: Organization['children']
|
||||
|
||||
@HasMany(() => ProgrammeModuleModel, { foreignKey: 'organization_id' })
|
||||
programmeModules!: ProgrammeModule[]
|
||||
declare programmeModules: Organization['programmeModules']
|
||||
|
||||
@HasMany(() => SISStudyRightModel, { foreignKey: 'facultyCode', sourceKey: 'code' })
|
||||
SISStudyRights!: SISStudyRight[]
|
||||
declare SISStudyRights: Organization['SISStudyRights']
|
||||
|
||||
@BelongsToMany(() => CourseModel, () => CourseProviderModel, 'organizationcode')
|
||||
courses!: CourseModel[]
|
||||
declare courses: Organization['courses']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Organization['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Organization['updatedAt']
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { Organization, ProgrammeModule } from '@oodikone/shared/models'
|
||||
import { DegreeProgrammeType, Name } from '@oodikone/shared/types'
|
||||
|
||||
import { OrganizationModel } from './organization'
|
||||
import { ProgrammeModuleChildModel } from './programmeModuleChild'
|
||||
@@ -22,62 +21,62 @@ import { ProgrammeModuleChildModel } from './programmeModuleChild'
|
||||
modelName: 'programme_module',
|
||||
tableName: 'programme_modules',
|
||||
})
|
||||
export class ProgrammeModuleModel extends Model<ProgrammeModule> implements ProgrammeModule {
|
||||
export class ProgrammeModuleModel extends Model implements ProgrammeModule {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: ProgrammeModule['id']
|
||||
|
||||
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'child_id')
|
||||
parents!: ProgrammeModule[]
|
||||
declare parents: ProgrammeModule[]
|
||||
|
||||
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'parent_id')
|
||||
children!: ProgrammeModule[]
|
||||
declare children: ProgrammeModule[]
|
||||
|
||||
@BelongsTo(() => OrganizationModel, { foreignKey: 'organization_id' })
|
||||
declare organization: Organization
|
||||
|
||||
@Column(DataType.STRING)
|
||||
group_id!: string
|
||||
declare group_id: ProgrammeModule['group_id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
declare code: ProgrammeModule['code']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Name
|
||||
declare name: ProgrammeModule['name']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
type!: 'course' | 'module'
|
||||
declare type: ProgrammeModule['type']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
order!: number
|
||||
declare order: ProgrammeModule['order']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
studyLevel!: string
|
||||
declare studyLevel: ProgrammeModule['studyLevel']
|
||||
|
||||
@ForeignKey(() => OrganizationModel)
|
||||
@Column(DataType.STRING)
|
||||
organization_id!: string
|
||||
|
||||
@BelongsTo(() => OrganizationModel, { foreignKey: 'organization_id' })
|
||||
organization!: Organization
|
||||
declare organization_id: ProgrammeModule['organization_id']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
valid_from!: Date
|
||||
declare valid_from: ProgrammeModule['valid_from']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
valid_to!: Date
|
||||
declare valid_to: ProgrammeModule['valid_to']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
curriculum_period_ids!: string[]
|
||||
declare curriculum_period_ids: ProgrammeModule['curriculum_period_ids']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
degreeProgrammeType!: DegreeProgrammeType | null
|
||||
declare degreeProgrammeType: ProgrammeModule['degreeProgrammeType']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
minimumCredits!: number | null
|
||||
declare minimumCredits: ProgrammeModule['minimumCredits']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: ProgrammeModule['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: ProgrammeModule['updatedAt']
|
||||
}
|
||||
|
||||
@@ -9,23 +9,23 @@ import { ProgrammeModuleModel } from './programmeModule'
|
||||
modelName: 'programme_module_child',
|
||||
tableName: 'programme_module_children',
|
||||
})
|
||||
export class ProgrammeModuleChildModel extends Model<ProgrammeModuleChild> implements ProgrammeModuleChild {
|
||||
export class ProgrammeModuleChildModel extends Model implements ProgrammeModuleChild {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
composite!: string
|
||||
declare composite: ProgrammeModuleChild['composite']
|
||||
|
||||
@ForeignKey(() => ProgrammeModuleModel)
|
||||
@Column(DataType.STRING)
|
||||
parentId!: string
|
||||
declare parentId: ProgrammeModuleChild['parentId']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
childId!: string
|
||||
declare childId: ProgrammeModuleChild['childId']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: ProgrammeModuleChild['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: ProgrammeModuleChild['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,50 +1,49 @@
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { Semester } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'semester',
|
||||
tableName: 'semesters',
|
||||
})
|
||||
export class SemesterModel extends Model<Semester> implements Semester {
|
||||
export class SemesterModel extends Model implements Semester {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
composite!: string
|
||||
declare composite: Semester['composite']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
semestercode!: number
|
||||
declare semestercode: Semester['semestercode']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Name
|
||||
declare name: Semester['name']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
startYear!: number
|
||||
declare startYear: Semester['startYear']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
startdate!: Date
|
||||
declare startdate: Semester['startdate']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
enddate!: Date
|
||||
declare enddate: Semester['enddate']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
yearcode!: number
|
||||
declare yearcode: Semester['yearcode']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
yearname!: string
|
||||
declare yearname: Semester['yearname']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
org!: string
|
||||
declare org: Semester['org']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
termIndex!: number
|
||||
declare termIndex: Semester['termIndex']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Semester['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Semester['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { Student, Credit, Enrollment, SISStudyRight, Studyplan } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
import { GenderCode } from '@oodikone/shared/types'
|
||||
import type { Student } from '@oodikone/shared/models'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { EnrollmentModel } from './enrollment'
|
||||
@@ -14,73 +12,73 @@ import { StudyplanModel } from './studyplan'
|
||||
modelName: 'student',
|
||||
tableName: 'student',
|
||||
})
|
||||
export class StudentModel extends Model<Student> implements Student {
|
||||
export class StudentModel extends Model implements Student {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
declare studentnumber: Student['studentnumber']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
lastname!: string
|
||||
declare lastname: Student['lastname']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
firstnames!: string
|
||||
declare firstnames: Student['firstnames']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
abbreviatedname!: string
|
||||
declare abbreviatedname: Student['abbreviatedname']
|
||||
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
enrollments!: Enrollment[]
|
||||
declare enrollments: Student['enrollments']
|
||||
|
||||
@HasMany(() => SISStudyRightModel, { foreignKey: 'studentNumber', sourceKey: 'studentnumber' })
|
||||
studyRights!: SISStudyRight[]
|
||||
declare studyRights: Student['studyRights']
|
||||
|
||||
@HasMany(() => StudyplanModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
studyplans!: Studyplan[]
|
||||
declare studyplans: Student['studyplans']
|
||||
|
||||
@HasMany(() => CreditModel, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
|
||||
credits!: Credit[]
|
||||
declare credits: Student['credits']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
birthdate!: Date
|
||||
declare birthdate: Student['birthdate']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditcount!: number
|
||||
declare creditcount: Student['creditcount']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
dateofuniversityenrollment!: Date
|
||||
declare dateofuniversityenrollment: Student['dateofuniversityenrollment']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
email!: string
|
||||
declare email: Student['email']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
secondary_email!: string
|
||||
declare secondary_email: Student['secondary_email']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
national_student_number!: string
|
||||
declare national_student_number: Student['national_student_number']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
phone_number!: string
|
||||
declare phone_number: Student['phone_number']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
citizenships!: Name[]
|
||||
declare citizenships: Student['citizenships']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
gender_code!: GenderCode
|
||||
declare gender_code: Student['gender_code']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
sis_person_id!: string
|
||||
declare sis_person_id: Student['sis_person_id']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
hasPersonalIdentityCode!: boolean
|
||||
declare hasPersonalIdentityCode: Student['hasPersonalIdentityCode']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
preferredLanguage!: string
|
||||
declare preferredLanguage: Student['preferredLanguage']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Student['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Student['updatedAt']
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { SISStudyRight, Student, Studyplan } from '@oodikone/shared/models'
|
||||
import type { Studyplan } from '@oodikone/shared/models'
|
||||
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
import { StudentModel } from './student'
|
||||
@@ -20,48 +20,48 @@ import { StudentModel } from './student'
|
||||
modelName: 'studyplan',
|
||||
tableName: 'studyplan',
|
||||
})
|
||||
export class StudyplanModel extends Model<Studyplan> implements Studyplan {
|
||||
export class StudyplanModel extends Model implements Studyplan {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: Studyplan['id']
|
||||
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
declare studentnumber: Studyplan['studentnumber']
|
||||
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
declare student: Studyplan['student']
|
||||
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column(DataType.STRING)
|
||||
sis_study_right_id!: string
|
||||
declare sis_study_right_id: Studyplan['sis_study_right_id']
|
||||
|
||||
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'sis_study_right_id', targetKey: 'id', as: 'studyRight' })
|
||||
studyRight!: SISStudyRight
|
||||
declare studyRight: Studyplan['studyRight']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
programme_code!: string
|
||||
declare programme_code: Studyplan['programme_code']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
included_courses!: string[]
|
||||
declare included_courses: Studyplan['included_courses']
|
||||
|
||||
@Column({ type: DataType.ARRAY(DataType.TEXT), field: 'included_modules' })
|
||||
includedModules!: string[]
|
||||
declare includedModules: Studyplan['includedModules']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
sisu_id!: string
|
||||
declare sisu_id: Studyplan['sisu_id']
|
||||
|
||||
@Column(DataType.DOUBLE)
|
||||
completed_credits!: number
|
||||
declare completed_credits: Studyplan['completed_credits']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
curriculum_period_id!: string
|
||||
declare curriculum_period_id: Studyplan['curriculum_period_id']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Studyplan['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Studyplan['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { StudyrightExtent } from '@oodikone/shared/models'
|
||||
import { ExtentCode } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'studyright_extent',
|
||||
tableName: 'studyright_extents',
|
||||
})
|
||||
export class StudyrightExtentModel extends Model<StudyrightExtent> implements StudyrightExtent {
|
||||
export class StudyrightExtentModel extends Model implements StudyrightExtent {
|
||||
@PrimaryKey
|
||||
@Column(DataType.INTEGER)
|
||||
extentcode!: ExtentCode
|
||||
declare extentcode: StudyrightExtent['extentcode']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
name!: string
|
||||
declare name: StudyrightExtent['name']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: StudyrightExtent['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: StudyrightExtent['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BelongsToMany, Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { Teacher, Credit } from '@oodikone/shared/models'
|
||||
import type { Teacher } from '@oodikone/shared/models'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { CreditTeacherModel } from './creditTeacher'
|
||||
@@ -10,22 +10,22 @@ import { CreditTeacherModel } from './creditTeacher'
|
||||
modelName: 'teacher',
|
||||
tableName: 'teacher',
|
||||
})
|
||||
export class TeacherModel extends Model<Teacher> implements Teacher {
|
||||
export class TeacherModel extends Model implements Teacher {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: Teacher['id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
name!: string
|
||||
declare name: Teacher['name']
|
||||
|
||||
@BelongsToMany(() => CreditModel, () => CreditTeacherModel)
|
||||
credits!: Credit[]
|
||||
declare credits: Teacher['credits']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Teacher['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Teacher['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Default, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
|
||||
|
||||
import type { Language } from '@oodikone/shared/language'
|
||||
import type { User, UserCreation } from '@oodikone/shared/models/user'
|
||||
|
||||
import type { Role } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
@@ -16,33 +12,33 @@ export class UserModel extends Model<User, UserCreation> implements User {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
id!: CreationOptional<string>
|
||||
declare id: User['id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
fullName!: string
|
||||
declare fullName: User['fullName']
|
||||
|
||||
@Unique
|
||||
@Column(DataType.STRING)
|
||||
username!: string
|
||||
declare username: User['username']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
email!: string
|
||||
declare email: User['email']
|
||||
|
||||
@Default('fi')
|
||||
@Column(DataType.STRING)
|
||||
language!: CreationOptional<Language>
|
||||
declare language: User['language']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
sisuPersonId!: string
|
||||
declare sisuPersonId: User['sisuPersonId']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
lastLogin!: Date
|
||||
declare lastLogin: User['lastLogin']
|
||||
|
||||
@Default([])
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
roles!: CreationOptional<Role[]>
|
||||
declare roles: User['roles']
|
||||
|
||||
@Default([])
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
programmeRights!: CreationOptional<string[]>
|
||||
declare programmeRights: User['programmeRights']
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { orderBy } = require('lodash')
|
||||
const { orderBy } = require('lodash-es')
|
||||
|
||||
// dbConnections must be imported to avoid Sequelize errors even if it's not used
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
@@ -3,7 +3,7 @@ import compression from 'compression'
|
||||
import cors from 'cors'
|
||||
import { Express, json as jsonExpress } from 'express'
|
||||
|
||||
import { frontUrl, languageCenterViewEnabled, serviceProvider } from './config'
|
||||
import { frontUrl, languageCenterViewEnabled } from './config'
|
||||
import accessLogger from './middleware/accessLogger'
|
||||
import * as auth from './middleware/auth'
|
||||
import currentUserMiddleware from './middleware/currentUser'
|
||||
@@ -34,8 +34,7 @@ import tags from './routes/tags'
|
||||
import teachers from './routes/teachers'
|
||||
import university from './routes/university'
|
||||
import updater from './routes/updater'
|
||||
import usersToska from './routes/users'
|
||||
import usersFd from './routes/usersFd'
|
||||
import users from './routes/users'
|
||||
|
||||
const routes = (app: Express, url: string) => {
|
||||
app.use(cors({ credentials: true, origin: frontUrl }))
|
||||
@@ -66,12 +65,7 @@ const routes = (app: Express, url: string) => {
|
||||
app.use(`${url}/university`, university)
|
||||
app.use(`${url}/updater`, auth.roles(['admin']), updater)
|
||||
app.use(`${url}/teachers`, teachers)
|
||||
if (serviceProvider === 'toska') {
|
||||
app.use(`${url}/users`, usersToska)
|
||||
} else {
|
||||
app.use(`${url}/users`, usersToska)
|
||||
app.use(`${url}/users`, usersFd)
|
||||
}
|
||||
app.use(`${url}/users`, users)
|
||||
app.use(`${url}/feedback`, feedback)
|
||||
app.use(`${url}/custom-population-search`, customPopulationSearch)
|
||||
app.use(`${url}/studyguidancegroups`, auth.roles(['studyGuidanceGroups']), studyGuidanceGroups)
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import axios from 'axios'
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { Release } from '@oodikone/shared/types'
|
||||
import { isDev } from '../config'
|
||||
import { Fetchios } from '@oodikone/shared/util/fetchios'
|
||||
import { isDev, isTest } from '../config'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const changelog: { data?: Release[] } = {}
|
||||
|
||||
export type ChangelogResBody = Release[]
|
||||
|
||||
router.get<never, ChangelogResBody>('/', async (_req: Request, res: Response) => {
|
||||
router.get<never, Release[]>('/', async (_, res) => {
|
||||
if (changelog.data) {
|
||||
return res.status(200).send(changelog.data)
|
||||
}
|
||||
if (isDev) {
|
||||
|
||||
if (isDev || isTest) {
|
||||
const fakeRelease: Release[] = [
|
||||
{
|
||||
description: '**Feature 1**\n- Added a fancy new feature \n\n**Feature 2**\n- Fixed a bug\n- Fixed another bug',
|
||||
@@ -35,17 +34,29 @@ router.get<never, ChangelogResBody>('/', async (_req: Request, res: Response) =>
|
||||
version: '0.0.1',
|
||||
},
|
||||
]
|
||||
|
||||
changelog.data = fakeRelease
|
||||
return res.status(200).json(fakeRelease)
|
||||
}
|
||||
const response = await axios.get('https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases')
|
||||
const releasesFromAPI: Release[] = response.data.map((release: Record<string, any>) => ({
|
||||
description: release.body,
|
||||
time: release.published_at,
|
||||
title: release.name,
|
||||
version: release.tag_name,
|
||||
}))
|
||||
changelog.data = releasesFromAPI
|
||||
res.status(200).json(releasesFromAPI)
|
||||
|
||||
const { data } = await Fetchios.get<Record<string, any>[]>(
|
||||
'https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases',
|
||||
{}
|
||||
)
|
||||
|
||||
if (data) {
|
||||
const releasesFromAPI: Release[] = data.map(release => ({
|
||||
description: release.body,
|
||||
time: release.published_at,
|
||||
title: release.name,
|
||||
version: release.tag_name,
|
||||
}))
|
||||
|
||||
changelog.data = releasesFromAPI
|
||||
res.status(200).json(releasesFromAPI)
|
||||
}
|
||||
|
||||
res.status(500).send()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { findStudentsCloseToGraduation, getCloseToGraduationData } from '../services/populations/closeToGraduation'
|
||||
import type { CloseToGraduationResBody } from '@oodikone/shared/routes/closeToGraduation'
|
||||
import { getCloseToGraduationData } from '../services/populations/closeToGraduation'
|
||||
import { getAllStudentsUserHasInGroups } from '../services/studyGuidanceGroups'
|
||||
import { hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
type CloseToGraduationResBody = Awaited<ReturnType<typeof findStudentsCloseToGraduation>>
|
||||
|
||||
router.get<never, CloseToGraduationResBody>('/', async (req, res) => {
|
||||
const { user } = req
|
||||
if (hasFullAccessToStudentData(user.roles)) {
|
||||
const result = await getCloseToGraduationData()
|
||||
return res.json(result)
|
||||
|
||||
if (!hasFullAccessToStudentData(user.roles)) {
|
||||
const studentNumbers = await getAllStudentsUserHasInGroups(user.sisPersonId)
|
||||
const result = await getCloseToGraduationData(studentNumbers)
|
||||
|
||||
res.json(result)
|
||||
}
|
||||
const studentsInUsersGuidanceGroups = await getAllStudentsUserHasInGroups(user.sisPersonId)
|
||||
const result = await getCloseToGraduationData(studentsInUsersGuidanceGroups)
|
||||
res.json(result)
|
||||
|
||||
const result = await getCloseToGraduationData()
|
||||
return res.json(result)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from 'express'
|
||||
import { difference, intersection } from 'lodash'
|
||||
import { difference, intersection } from 'lodash-es'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { tryCatch } from '@oodikone/shared/util'
|
||||
@@ -42,18 +42,11 @@ router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, re
|
||||
return res.status(400).json({ error: 'Courses must be of type array' })
|
||||
}
|
||||
|
||||
const answerTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), 6000))
|
||||
|
||||
// Teachers can also get rights to students via Importer if
|
||||
// the students have enrolled to their courses in last 8 months
|
||||
// (acual logic a bit different, see Importer)
|
||||
const { data: teacherRightsToStudents, error } = importerClient
|
||||
? await tryCatch<{ data: string[] }>(
|
||||
Promise.race([
|
||||
importerClient.post('/teacher-rights/', { teacherId, studentNumbers }),
|
||||
answerTimeout as Promise<never>, // This will always reject with an Error
|
||||
])
|
||||
)
|
||||
? await tryCatch(importerClient.post<string[]>('/teacher-rights/', { teacherId, studentNumbers }, {}))
|
||||
: { data: null, error: null }
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import type { ExcludedCoursesResBody, ExcludedCoursesReqBody } from '@oodikone/shared/routes/courseExclusions'
|
||||
import { addExcludedCourses, removeExcludedCourses } from '../services/excludedCourses'
|
||||
import { hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
export type ExcludedCoursesParams = { code: string }
|
||||
export type ExcludedCoursesResBody = void
|
||||
export type ExcludedCoursesReqBody = {
|
||||
courseCodes: string[]
|
||||
curriculumVersion: string
|
||||
}
|
||||
router.post<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async (req, res) => {
|
||||
const { programmeCode, courseCodes, curriculumVersion } = req.body
|
||||
const { roles, programmeRights } = req.user
|
||||
|
||||
const hasFullAccess = hasFullAccessToStudentData(roles)
|
||||
const hasAccessToProgramme = programmeRights.map(({ code }) => code).includes(programmeCode)
|
||||
|
||||
if (!hasFullAccess && !hasAccessToProgramme) return res.status(403).end()
|
||||
|
||||
router.post<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/:code', async (req, res) => {
|
||||
const { code: programmeCode } = req.params
|
||||
const { courseCodes, curriculumVersion } = req.body
|
||||
const result = await addExcludedCourses(courseCodes, curriculumVersion, programmeCode)
|
||||
if (!result) {
|
||||
res.status(400).end()
|
||||
@@ -22,9 +23,15 @@ router.post<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBod
|
||||
res.status(201).end()
|
||||
})
|
||||
|
||||
router.delete<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/:code', async (req, res) => {
|
||||
const { code: programmeCode } = req.params
|
||||
const { courseCodes, curriculumVersion } = req.body
|
||||
router.delete<never, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/', async (req, res) => {
|
||||
const { programmeCode, courseCodes, curriculumVersion } = req.body
|
||||
const { roles, programmeRights } = req.user
|
||||
|
||||
const hasFullAccess = hasFullAccessToStudentData(roles)
|
||||
const hasAccessToProgramme = programmeRights.map(({ code }) => code).includes(programmeCode)
|
||||
|
||||
if (!hasFullAccess && !hasAccessToProgramme) return res.status(403).end()
|
||||
|
||||
const result = await removeExcludedCourses(courseCodes, curriculumVersion, programmeCode)
|
||||
if (!result) {
|
||||
res.status(400).end()
|
||||
|
||||
@@ -2,106 +2,80 @@ import crypto from 'crypto'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import {
|
||||
CoursesMultiResBody,
|
||||
CoursesMultiReqBody,
|
||||
CoursesMultiQuery,
|
||||
CourseYearlyStatsReqBody,
|
||||
CourseYearlyStatsQuery,
|
||||
} from '@oodikone/shared/routes/courses'
|
||||
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
|
||||
import { getCourseYearlyStats } from '../services/courses'
|
||||
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
|
||||
import { CourseWithSubsId } from '../types/course'
|
||||
import { getCoursesByNameAndOrCode } from '../services/courses/courseFinders'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
type CoursesMultiReqBody = never
|
||||
type CoursesMultiResBody = { courses: CourseWithSubsId[] }
|
||||
type CoursesMultiQuery = {
|
||||
name: string
|
||||
code: string
|
||||
combineSubstitutions: string
|
||||
}
|
||||
|
||||
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
|
||||
'/v2/coursesmulti',
|
||||
async (req, res) => {
|
||||
const { name, code, combineSubstitutions } = req.query
|
||||
const { name, code, includeSpecial } = req.query
|
||||
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
|
||||
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
|
||||
}
|
||||
|
||||
const courses: CourseWithSubsId[] = await getCoursesByNameAndOrCode(name, code)
|
||||
const courses = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
|
||||
|
||||
if (combineSubstitutions === 'false') {
|
||||
const courseCodes = courses.map(course => course.code)
|
||||
const substitutions = [
|
||||
...new Set(
|
||||
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
|
||||
),
|
||||
]
|
||||
const substitutionDetails = await getCoursesByCodes(substitutions)
|
||||
for (const substitution of substitutionDetails) {
|
||||
courses.push(substitution.toJSON())
|
||||
}
|
||||
}
|
||||
|
||||
const mergedCourses: Record<string, CourseWithSubsId> = {}
|
||||
const mergedCourses = new Map<string, CourseWithSubsId>()
|
||||
|
||||
for (const course of courses) {
|
||||
const groupId = combineSubstitutions === 'true' ? (course.subsId?.toString() ?? '') : course.code
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) {
|
||||
continue
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) continue
|
||||
|
||||
if (!mergedCourses.has(course.code)) mergedCourses.set(course.code, structuredClone(course))
|
||||
const mergedCourse = mergedCourses.get(course.code)!
|
||||
|
||||
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
|
||||
mergedCourse.max_attainment_date = course.max_attainment_date
|
||||
}
|
||||
if (!mergedCourses[groupId]) {
|
||||
mergedCourses[groupId] = {
|
||||
...course,
|
||||
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
|
||||
}
|
||||
} else {
|
||||
const mergedCourse = mergedCourses[groupId]
|
||||
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
|
||||
mergedCourse.max_attainment_date = course.max_attainment_date
|
||||
}
|
||||
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
|
||||
mergedCourse.min_attainment_date = course.min_attainment_date
|
||||
}
|
||||
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
|
||||
mergedCourse.min_attainment_date = course.min_attainment_date
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ courses: Object.values(mergedCourses) })
|
||||
res.json({ courses: Array.from(mergedCourses.values()) })
|
||||
}
|
||||
)
|
||||
|
||||
type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
|
||||
type CourseYearlyStatsReqBody = never
|
||||
type CourseYearlyStatsQuery = {
|
||||
codes: string[]
|
||||
separate: string
|
||||
combineSubstitutions: string
|
||||
}
|
||||
|
||||
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
|
||||
'/v3/courseyearlystats',
|
||||
async (req, res) => {
|
||||
const { roles, programmeRights } = req.user
|
||||
const { codes, combineSubstitutions, separate } = req.query
|
||||
|
||||
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
|
||||
const userHasCorrectRole = userHasFullAccessToStudentData === true || roles.includes('courseStatistics')
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
// If user has rights to see at least one programme, then they are allowed to see all of them
|
||||
if (!userHasCorrectRole && fullStudyProgrammeRights.length === 0) {
|
||||
return res.status(403).json({ error: 'No programmes so no access to course stats' })
|
||||
}
|
||||
|
||||
const { codes } = req.query
|
||||
if (!codes) {
|
||||
return res.status(422).send({ error: 'Missing required query parameters' })
|
||||
}
|
||||
|
||||
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
|
||||
const separate = req.query.separate === 'true'
|
||||
const { roles, programmeRights } = req.user
|
||||
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
|
||||
const userHasAccessToCourseStats = userHasFullAccessToStudentData || roles.includes('courseStatistics')
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
// If user has rights to see at least one programme, then they are allowed to see all of them
|
||||
if (!userHasAccessToCourseStats && !fullStudyProgrammeRights.length) {
|
||||
return res.status(403).json({ error: 'No valid rights provided' })
|
||||
}
|
||||
|
||||
// Student numbers should be obfuscated to all other users except admins,
|
||||
// fullSisuAccess users, and users with rights to any specific study programmes
|
||||
// fullSisuAccess users, and users with rights to any specific degree programmes
|
||||
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
|
||||
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
|
||||
const results = await getCourseYearlyStats(codes, separate, anonymizationSalt, combineSubstitutions)
|
||||
|
||||
const useCombineSubstitutions = combineSubstitutions !== 'false'
|
||||
const useSeparate = separate === 'true'
|
||||
|
||||
const results = await getCourseYearlyStats(codes, useSeparate, anonymizationSalt, useCombineSubstitutions)
|
||||
res.json(results)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '@oodikone/shared/types'
|
||||
import type { CanError, GenericApplicationError } from '@oodikone/shared/routes'
|
||||
import type {
|
||||
Graduated,
|
||||
Name,
|
||||
NameWithCode,
|
||||
ProgrammeFilter,
|
||||
SpecialGroups,
|
||||
StatsType,
|
||||
Unarray,
|
||||
YearType,
|
||||
} from '@oodikone/shared/types'
|
||||
import * as auth from '../middleware/auth'
|
||||
import { getDegreeProgrammesOfFaculty, getFacultyCodeById } from '../services/faculty/faculty'
|
||||
import { getDegreeProgrammesOfFaculty, getFacultyCodeById, ProgrammesOfOrganization } from '../services/faculty/faculty'
|
||||
import { combineFacultyBasics } from '../services/faculty/facultyBasics'
|
||||
import { getFacultyCredits } from '../services/faculty/facultyCredits'
|
||||
import { countGraduationTimes } from '../services/faculty/facultyGraduationTimes'
|
||||
@@ -18,242 +28,270 @@ import {
|
||||
setFacultyStudentStats,
|
||||
getFacultyProgressStats,
|
||||
setFacultyProgressStats,
|
||||
type BasicData,
|
||||
type ThesisWriterData,
|
||||
GraduationData,
|
||||
} from '../services/faculty/facultyService'
|
||||
import { combineFacultyStudentProgress } from '../services/faculty/facultyStudentProgress'
|
||||
import { combineFacultyStudents } from '../services/faculty/facultyStudents'
|
||||
import { combineFacultyThesisWriters } from '../services/faculty/facultyThesisWriters'
|
||||
import { updateFacultyOverview, updateFacultyProgressOverview } from '../services/faculty/facultyUpdates'
|
||||
import logger from '../util/logger'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
|
||||
// Faculty uses a lot of tools designed for Study programme.
|
||||
// Faculty uses a lot of tools designed for Degree programme.
|
||||
// Some of them have been copied here and slightly edited for faculty purpose.
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const faculties = await getFacultiesForFacultyList()
|
||||
res.json(faculties)
|
||||
})
|
||||
type GetFacultyListResBody = {
|
||||
id: string
|
||||
code: string
|
||||
name: Name
|
||||
}[]
|
||||
|
||||
interface GetStatsRequest extends Request {
|
||||
query: {
|
||||
programme_filter: ProgrammeFilter
|
||||
special_groups: SpecialGroups
|
||||
year_type: YearType
|
||||
}
|
||||
router.get<never, GetFacultyListResBody>('/', async (_req, res) => res.json(await getFacultiesForFacultyList()))
|
||||
|
||||
type GetStatsParams = { id: string }
|
||||
type GetStatsReqBody = never
|
||||
type GetStatsResBody = BasicData
|
||||
type GetStatsQuery = {
|
||||
programme_filter: ProgrammeFilter
|
||||
special_groups: SpecialGroups
|
||||
year_type: YearType
|
||||
}
|
||||
|
||||
router.get('/:id/basicstats', auth.roles(['facultyStatistics']), async (req: GetStatsRequest, res: Response) => {
|
||||
const facultyId = req.params.id
|
||||
const code = await getFacultyCodeById(facultyId)
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
router.get<GetStatsParams, CanError<GetStatsResBody, GenericApplicationError>, GetStatsReqBody, GetStatsQuery>(
|
||||
'/:id/basicstats',
|
||||
auth.roles(['facultyStatistics']),
|
||||
async (req, res) => {
|
||||
const { id: facultyId } = req.params
|
||||
const { year_type: yearType, programme_filter: programmeFilter, special_groups: specialGroups } = req.query
|
||||
|
||||
const { year_type: yearType, programme_filter: programmeFilter, special_groups: specialGroups } = req.query
|
||||
const data = await getBasicStats(code, yearType, programmeFilter, specialGroups)
|
||||
if (data) {
|
||||
return res.json(data)
|
||||
}
|
||||
const faculty = await getFacultyCodeById(facultyId)
|
||||
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
|
||||
|
||||
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
|
||||
if (!programmes.length) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
const data = await getBasicStats(faculty.code, yearType, programmeFilter, specialGroups)
|
||||
if (data) return res.json(data)
|
||||
|
||||
// TODO: The types could be handled better. Possible null value is causing issues with TS.
|
||||
let updatedStats: any = await combineFacultyBasics(code, programmes, yearType, specialGroups)
|
||||
if (updatedStats) {
|
||||
updatedStats = await setBasicStats(updatedStats, yearType, programmeFilter, specialGroups)
|
||||
}
|
||||
return res.json(updatedStats)
|
||||
})
|
||||
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
|
||||
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
|
||||
|
||||
interface GetCreditStatsRequest extends Request {
|
||||
query: {
|
||||
year_type: YearType
|
||||
const updatedStats = await combineFacultyBasics(faculty.code, programmes, yearType, specialGroups)
|
||||
|
||||
if (updatedStats) await setBasicStats(updatedStats, yearType, programmeFilter, specialGroups)
|
||||
else throw new ApplicationError('No data', 204)
|
||||
|
||||
return res.json(updatedStats)
|
||||
}
|
||||
)
|
||||
|
||||
type GetFacultyStatsResBody = ThesisWriterData
|
||||
|
||||
router.get<GetStatsParams, CanError<GetFacultyStatsResBody, GenericApplicationError>, GetStatsReqBody, GetStatsQuery>(
|
||||
'/:id/thesisstats',
|
||||
auth.roles(['facultyStatistics']),
|
||||
async (req, res) => {
|
||||
const { id: facultyId } = req.params
|
||||
const { year_type: yearType, programme_filter: programmeFilter, special_groups: specialGroups } = req.query
|
||||
|
||||
const faculty = await getFacultyCodeById(facultyId)
|
||||
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
|
||||
|
||||
const data = await getThesisWritersStats(faculty.code, yearType, programmeFilter, specialGroups)
|
||||
if (data) return res.json(data)
|
||||
|
||||
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
|
||||
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
|
||||
|
||||
const updatedStats = await combineFacultyThesisWriters(faculty.code, programmes, yearType, specialGroups)
|
||||
if (updatedStats) await setThesisWritersStats(updatedStats, yearType, programmeFilter, specialGroups)
|
||||
else throw new ApplicationError('No data', 204)
|
||||
|
||||
return res.json(updatedStats)
|
||||
}
|
||||
)
|
||||
|
||||
type GetCreditStatsResBody = {
|
||||
codes: string[]
|
||||
ids: string[]
|
||||
programmeNames: Record<string, NameWithCode>
|
||||
}
|
||||
type GetCreditStatsQuery = {
|
||||
year_type: YearType
|
||||
}
|
||||
|
||||
router.get('/:id/creditstats', auth.roles(['facultyStatistics']), async (req: GetCreditStatsRequest, res: Response) => {
|
||||
const facultyId = req.params.id
|
||||
const code = await getFacultyCodeById(facultyId)
|
||||
router.get<
|
||||
GetStatsParams,
|
||||
CanError<GetCreditStatsResBody, GenericApplicationError>,
|
||||
GetStatsReqBody,
|
||||
GetCreditStatsQuery
|
||||
>('/:id/creditstats', auth.roles(['facultyStatistics']), async (req, res) => {
|
||||
const { id: facultyId } = req.params
|
||||
const { year_type: yearType } = req.query
|
||||
const stats = await getFacultyCredits(code, yearType === 'ACADEMIC_YEAR')
|
||||
return res.json(stats)
|
||||
|
||||
const faculty = await getFacultyCodeById(facultyId)
|
||||
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
|
||||
|
||||
return res.json(await getFacultyCredits(faculty.code, yearType === 'ACADEMIC_YEAR'))
|
||||
})
|
||||
|
||||
router.get('/:id/thesisstats', auth.roles(['facultyStatistics']), async (req: GetStatsRequest, res: Response) => {
|
||||
const facultyId = req.params.id
|
||||
const code = await getFacultyCodeById(facultyId)
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
type GetGraduationStatsResBody = GraduationData
|
||||
type GetGraduationStatsQuery = {
|
||||
programme_filter: ProgrammeFilter
|
||||
}
|
||||
|
||||
const { year_type: yearType, programme_filter: programmeFilter, special_groups: specialGroups } = req.query
|
||||
const data = await getThesisWritersStats(code, yearType, programmeFilter, specialGroups)
|
||||
if (data) {
|
||||
return res.json(data)
|
||||
}
|
||||
router.get<
|
||||
GetStatsParams,
|
||||
CanError<GetGraduationStatsResBody, GenericApplicationError>,
|
||||
GetStatsReqBody,
|
||||
GetGraduationStatsQuery
|
||||
>('/:id/graduationtimes', auth.roles(['facultyStatistics']), async (req, res) => {
|
||||
const { id: facultyId } = req.params
|
||||
const { programme_filter: programmeFilter } = req.query
|
||||
|
||||
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
|
||||
if (!programmes.length) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
const faculty = await getFacultyCodeById(facultyId)
|
||||
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
|
||||
|
||||
const data = await getGraduationStats(faculty.code, programmeFilter)
|
||||
if (data) return res.json(data)
|
||||
|
||||
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
|
||||
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
|
||||
|
||||
const updatedStats = await countGraduationTimes(faculty.code, programmes)
|
||||
if (updatedStats) await setGraduationStats(updatedStats, programmeFilter)
|
||||
|
||||
let updatedStats: any = await combineFacultyThesisWriters(code, programmes, yearType, specialGroups)
|
||||
if (updatedStats) {
|
||||
updatedStats = await setThesisWritersStats(updatedStats, yearType, programmeFilter, specialGroups)
|
||||
}
|
||||
return res.json(updatedStats)
|
||||
})
|
||||
|
||||
interface GetGraduationStatsRequest extends Request {
|
||||
query: {
|
||||
programme_filter: ProgrammeFilter
|
||||
}
|
||||
type GetProgressStatsResBody = {
|
||||
bachelorsProgStats: Record<string, number[][]>
|
||||
bcMsProgStats: Record<string, number[][]>
|
||||
creditCounts: Record<string, Record<string, number[]>>
|
||||
doctoralProgStats: Record<string, number[][]>
|
||||
id: string
|
||||
mastersProgStats: Record<string, number[][]>
|
||||
programmeNames: Record<string, NameWithCode>
|
||||
yearlyBachelorTitles: string[][]
|
||||
yearlyBcMsTitles: string[][]
|
||||
yearlyMasterTitles: string[][]
|
||||
years: string[]
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/:id/graduationtimes',
|
||||
auth.roles(['facultyStatistics']),
|
||||
async (req: GetGraduationStatsRequest, res: Response) => {
|
||||
const facultyId = req.params.id
|
||||
const code = await getFacultyCodeById(facultyId)
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const { programme_filter: programmeFilter } = req.query
|
||||
const data = await getGraduationStats(code, programmeFilter)
|
||||
if (data) {
|
||||
return res.json(data)
|
||||
}
|
||||
|
||||
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
|
||||
if (!programmes) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
let updatedStats: any = await countGraduationTimes(code, programmes)
|
||||
if (updatedStats) {
|
||||
updatedStats = await setGraduationStats(updatedStats, programmeFilter)
|
||||
}
|
||||
return res.json(updatedStats)
|
||||
}
|
||||
)
|
||||
|
||||
interface GetProgressStatsRequest extends Request {
|
||||
query: {
|
||||
graduated: Graduated
|
||||
special_groups: SpecialGroups
|
||||
}
|
||||
type GetProgressStatsQuery = {
|
||||
graduated: Graduated
|
||||
special_groups: SpecialGroups
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/:id/progressstats',
|
||||
auth.roles(['facultyStatistics']),
|
||||
async (req: GetProgressStatsRequest, res: Response) => {
|
||||
const facultyId = req.params.id
|
||||
const code = await getFacultyCodeById(facultyId)
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
router.get<
|
||||
GetStatsParams,
|
||||
CanError<GetProgressStatsResBody, GenericApplicationError>,
|
||||
GetStatsReqBody,
|
||||
GetProgressStatsQuery
|
||||
>('/:id/progressstats', auth.roles(['facultyStatistics']), async (req, res) => {
|
||||
const { id: facultyId } = req.params
|
||||
const { special_groups: specialGroups } = req.query
|
||||
|
||||
const programmes = await getDegreeProgrammesOfFaculty(code, true)
|
||||
if (!programmes) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
const faculty = await getFacultyCodeById(facultyId)
|
||||
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
|
||||
|
||||
const { graduated, special_groups: specialGroups } = req.query
|
||||
const data = await getFacultyProgressStats(code, specialGroups, graduated)
|
||||
if (data) {
|
||||
return res.json(data)
|
||||
}
|
||||
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, true)
|
||||
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
|
||||
|
||||
let updatedStats: any = await combineFacultyStudentProgress(code, programmes, specialGroups, graduated)
|
||||
if (updatedStats) {
|
||||
updatedStats = await setFacultyProgressStats(updatedStats, specialGroups, graduated)
|
||||
}
|
||||
return res.json(updatedStats)
|
||||
}
|
||||
)
|
||||
const data = await getFacultyProgressStats(faculty.code, specialGroups)
|
||||
if (data) return res.json(data)
|
||||
|
||||
interface GetStudentStatsRequest extends GetProgressStatsRequest {}
|
||||
const updatedStats = await combineFacultyStudentProgress(faculty.code, programmes, specialGroups)
|
||||
if (updatedStats) await setFacultyProgressStats(updatedStats, specialGroups)
|
||||
|
||||
router.get(
|
||||
'/:id/studentstats',
|
||||
auth.roles(['facultyStatistics']),
|
||||
async (req: GetStudentStatsRequest, res: Response) => {
|
||||
const facultyId = req.params.id
|
||||
const code = await getFacultyCodeById(facultyId)
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
return res.json(updatedStats)
|
||||
})
|
||||
|
||||
const { graduated, special_groups: specialGroups } = req.query
|
||||
export type DegreeProgramme = Pick<
|
||||
Unarray<ProgrammesOfOrganization>,
|
||||
'code' | 'name' | 'degreeProgrammeType' | 'progId'
|
||||
>
|
||||
|
||||
/* Fetch cached data if exists */
|
||||
const data = await getFacultyStudentStats(code, specialGroups, graduated)
|
||||
if (data) {
|
||||
return res.json(data)
|
||||
}
|
||||
|
||||
const newProgrammes = await getDegreeProgrammesOfFaculty(code, true)
|
||||
if (!newProgrammes.length) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const updatedStats = await combineFacultyStudents(code, newProgrammes, specialGroups, graduated)
|
||||
|
||||
if (updatedStats) {
|
||||
void setFacultyStudentStats(updatedStats, specialGroups, graduated)
|
||||
}
|
||||
return res.json(updatedStats)
|
||||
}
|
||||
)
|
||||
|
||||
interface GetUpdateBasicViewRequest extends Request {
|
||||
query: {
|
||||
stats_type: StatsType
|
||||
}
|
||||
type GetStudetStatsResBody = {
|
||||
id: string
|
||||
years: string[]
|
||||
facultyTableStats: Record<string, (number | string)[]>
|
||||
facultyTableStatsExtra: Record<string, Record<string, Record<string, number>>>
|
||||
/**
|
||||
* NOTE: programmeStats order is random (not by the year). Make sure to index properly.
|
||||
*
|
||||
* (Order is based on the order in which studyrights were processed in the backend)
|
||||
*/
|
||||
programmeStats: Record<string, Record<string, (string | number)[]>>
|
||||
titles: string[]
|
||||
programmeNames: Record<string, DegreeProgramme>
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/:id/update_basicview',
|
||||
auth.roles(['facultyStatistics']),
|
||||
async (req: GetUpdateBasicViewRequest, res: Response) => {
|
||||
const facultyId = req.params.id
|
||||
const code = await getFacultyCodeById(facultyId)
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
const { stats_type: statsType } = req.query
|
||||
try {
|
||||
const result = await updateFacultyOverview(code, statsType)
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
const message = `Failed to update basic stats for faculty ${code}`
|
||||
logger.error(message, { error })
|
||||
return res.status(500).json({ error: message })
|
||||
}
|
||||
}
|
||||
)
|
||||
router.get<
|
||||
GetStatsParams,
|
||||
CanError<GetStudetStatsResBody, GenericApplicationError>,
|
||||
GetStatsReqBody,
|
||||
GetProgressStatsQuery
|
||||
>('/:id/studentstats', auth.roles(['facultyStatistics']), async (req, res) => {
|
||||
const { id: facultyId } = req.params
|
||||
const { special_groups: specialGroups } = req.query
|
||||
|
||||
const faculty = await getFacultyCodeById(facultyId)
|
||||
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
|
||||
|
||||
const data = await getFacultyStudentStats(faculty.code, specialGroups)
|
||||
if (data) return res.json(data)
|
||||
|
||||
const programmes = await getDegreeProgrammesOfFaculty(faculty.code, true)
|
||||
if (!programmes.length) throw new ApplicationError('Unprocessable request', 422)
|
||||
|
||||
const updatedStats = await combineFacultyStudents(faculty.code, programmes, specialGroups)
|
||||
if (updatedStats) await setFacultyStudentStats(updatedStats, specialGroups)
|
||||
|
||||
return res.json(updatedStats)
|
||||
})
|
||||
|
||||
type GetUpdateBasicViewResBody = never
|
||||
type GetUpdateBasicViewQuery = {
|
||||
stats_type: StatsType
|
||||
}
|
||||
|
||||
router.get<
|
||||
GetStatsParams,
|
||||
CanError<GetUpdateBasicViewResBody, GenericApplicationError>,
|
||||
GetStatsReqBody,
|
||||
GetUpdateBasicViewQuery
|
||||
>('/:id/update_basicview', auth.roles(['facultyStatistics']), async (req, res) => {
|
||||
const { id: facultyId } = req.params
|
||||
const { stats_type: statsType } = req.query
|
||||
|
||||
const faculty = await getFacultyCodeById(facultyId)
|
||||
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
|
||||
|
||||
router.get('/:id/update_progressview', auth.roles(['facultyStatistics']), async (req: Request, res: Response) => {
|
||||
const facultyId = req.params.id
|
||||
const code = await getFacultyCodeById(facultyId)
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
try {
|
||||
const result = await updateFacultyProgressOverview(code)
|
||||
return res.json(result)
|
||||
await updateFacultyOverview(faculty.code, statsType)
|
||||
return res.json()
|
||||
} catch (error) {
|
||||
const message = `Failed to update progress tab stats for faculty ${code}`
|
||||
logger.error(message, { error })
|
||||
return res.status(500).json({ error: message })
|
||||
throw new ApplicationError(`Failed to update basic stats for faculty ${faculty.code}`, 500)
|
||||
}
|
||||
})
|
||||
|
||||
router.get<GetStatsParams, CanError<never, GenericApplicationError>, GetStatsReqBody, GetStatsQuery>(
|
||||
'/:id/update_progressview',
|
||||
auth.roles(['facultyStatistics']),
|
||||
async (req, res) => {
|
||||
const { id: facultyId } = req.params
|
||||
|
||||
const faculty = await getFacultyCodeById(facultyId)
|
||||
if (!faculty) throw new ApplicationError(`The organization with the id ${facultyId} was not found.`, 422)
|
||||
|
||||
try {
|
||||
await updateFacultyProgressOverview(faculty.code)
|
||||
return res.json()
|
||||
} catch (error) {
|
||||
throw new ApplicationError(`Failed to update progress tab stats for faculty ${faculty.code}`, 500)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { sendFeedbackToToska } from '../services/mailService'
|
||||
import { sendFeedback } from '../services/mailService'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
import logger from '../util/logger'
|
||||
|
||||
@@ -20,7 +20,7 @@ router.post('/email', async (req: EmailRequest, res: Response) => {
|
||||
throw new ApplicationError('User not found', 404)
|
||||
}
|
||||
|
||||
await sendFeedbackToToska({
|
||||
await sendFeedback({
|
||||
feedbackContent: content,
|
||||
user,
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ router.get<never, CanError<LoginResBody>, LoginReqBody>('/', (req, res) => {
|
||||
const { user, logoutUrl } = req
|
||||
|
||||
if (!user) {
|
||||
throw new ApplicationError('User not found', 404)
|
||||
throw new ApplicationError('User not found', 401)
|
||||
}
|
||||
|
||||
res.send({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import crypto from 'crypto'
|
||||
import { Router } from 'express'
|
||||
import { difference, intersection, uniq } from 'lodash'
|
||||
import { difference, intersection, uniq } from 'lodash-es'
|
||||
|
||||
import type { CanError } from '@oodikone/shared/routes'
|
||||
import {
|
||||
@@ -10,34 +9,107 @@ import {
|
||||
type PopulationstatisticsbycourseResBody,
|
||||
type PopulationstatisticsbycourseReqBody,
|
||||
type PopulationstatisticsbycourseParams,
|
||||
type PostByStudentNumbersResBody,
|
||||
type PostByStudentNumbersReqBody,
|
||||
type GetCustomPopulationResBody,
|
||||
type CustomPopulationQuery,
|
||||
type PopulationstatisticsStudyprogrammesResBody,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormQuery,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
|
||||
} from '@oodikone/shared/routes/populations'
|
||||
import { GenderCode, Unification, Unarray } from '@oodikone/shared/types'
|
||||
import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { Unification } from '@oodikone/shared/types'
|
||||
import { keyBy, mapToProviders } from '@oodikone/shared/util'
|
||||
import { rootOrgId } from '../config'
|
||||
import { SISStudyRightModel } from '../models'
|
||||
import { maxYearsToCreatePopulationFrom, getCourseProvidersForCourses } from '../services/courses'
|
||||
import { getCourseProvidersForCourses, maxYearsToCreatePopulationFrom } from '../services/courses'
|
||||
import { encrypt } from '../services/encrypt'
|
||||
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from '../services/faculty/faculty'
|
||||
import { getStudentTags } from '../services/populations/getStudentData'
|
||||
import { getDegreeProgrammesOfOrganization } from '../services/faculty/faculty'
|
||||
import { getStudentTagMap } from '../services/populations/getStudentData'
|
||||
import { parseDateRangeFromParams } from '../services/populations/shared'
|
||||
import { statisticsOf } from '../services/populations/statisticsOf'
|
||||
import { getStudentNumbersWithStudyRights } from '../services/populations/studentNumbersWithStudyRights'
|
||||
import { findByCourseAndSemesters } from '../services/students'
|
||||
import { ParsedCourse } from '../types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const obfuscateStuff = ({
|
||||
result,
|
||||
|
||||
programme,
|
||||
combinedProgramme,
|
||||
|
||||
userRoles,
|
||||
userProgrammeRights,
|
||||
studentsUserCanAccess,
|
||||
}: {
|
||||
result: Awaited<ReturnType<typeof statisticsOf>>
|
||||
[key: string]: any
|
||||
}): Awaited<ReturnType<typeof statisticsOf>> => {
|
||||
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
|
||||
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
|
||||
const hasFullRightsToProgramme = userFullProgrammeRights.includes(programme)
|
||||
const hasFullRightsToCombinedProgramme = userFullProgrammeRights.includes(combinedProgramme)
|
||||
|
||||
if (hasFullAccessToStudents || hasFullRightsToProgramme || hasFullRightsToCombinedProgramme) {
|
||||
return result
|
||||
}
|
||||
|
||||
const { criteria, students, coursestatistics } = result
|
||||
const encryptionMap = new Map<string, string>()
|
||||
|
||||
return {
|
||||
criteria,
|
||||
students: students.map(student => {
|
||||
if (studentsUserCanAccess.has(student.studentNumber)) return student
|
||||
|
||||
// correct year for age distribution calculation but the date is always January 1st
|
||||
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0))
|
||||
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
|
||||
|
||||
encryptionMap.set(student.studentNumber, studentNumber)
|
||||
|
||||
return {
|
||||
...student,
|
||||
|
||||
obfuscated: true,
|
||||
iv,
|
||||
studentNumber,
|
||||
firstnames: '',
|
||||
lastname: '',
|
||||
phoneNumber: '',
|
||||
name: '',
|
||||
email: '',
|
||||
secondaryEmail: '',
|
||||
sis_person_id: '',
|
||||
tags: [],
|
||||
birthdate: obfuscatedBirthDate,
|
||||
}
|
||||
}),
|
||||
coursestatistics: {
|
||||
courses: coursestatistics.courses,
|
||||
enrollments: coursestatistics.enrollments.map(enrollment => ({
|
||||
...enrollment,
|
||||
studentnumber: encryptionMap.get(enrollment.studentnumber) ?? enrollment.studentnumber,
|
||||
})),
|
||||
credits: coursestatistics.credits.map(credit => ({
|
||||
...credit,
|
||||
student_studentnumber: encryptionMap.get(credit.student_studentnumber) ?? credit.student_studentnumber,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
|
||||
'/v3/populationstatistics',
|
||||
async (req, res) => {
|
||||
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights } = req.user
|
||||
const { years, semesters, programme, combinedProgramme, studentStatuses } = req.query
|
||||
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights, studentsUserCanAccess } = req.user
|
||||
const { programme, combinedProgramme } = req.query
|
||||
|
||||
// NOTE: Make sure we have only arrays, "/...&years=2021" is parsed by express as years: 2021, not [2021]
|
||||
const handleQueryArrays = <T>(requestField: T | T[]): T[] => {
|
||||
return Array.isArray(requestField) ? requestField : [requestField]
|
||||
}
|
||||
const years = handleQueryArrays(req.query.years)
|
||||
const semesters = handleQueryArrays(req.query.semesters)
|
||||
const studentStatuses = handleQueryArrays(req.query.studentStatuses) // NOTE: TRANSFERRED means transferred out of the program
|
||||
|
||||
const requiredFields = [years, semesters, programme]
|
||||
if (requiredFields.some(field => !field)) {
|
||||
@@ -48,18 +120,16 @@ router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReq
|
||||
return res.status(400).json({ error: 'Semester should be either SPRING OR FALL' })
|
||||
}
|
||||
|
||||
const hasCorrectStatus = (studentStatuses: string[]) =>
|
||||
studentStatuses.every(status => ['EXCHANGE', 'NONDEGREE', 'TRANSFERRED'].includes(status))
|
||||
if (studentStatuses && !hasCorrectStatus(studentStatuses)) {
|
||||
const hasCorrectStatus = (studentStatuses: (string | undefined)[]) =>
|
||||
studentStatuses.every(status => status && ['EXCHANGE', 'NONDEGREE', 'TRANSFERRED'].includes(status))
|
||||
|
||||
// NOTE: This has always the length of at least 1, [undefined]
|
||||
if (studentStatuses[0] && !hasCorrectStatus(studentStatuses)) {
|
||||
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
|
||||
}
|
||||
|
||||
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
|
||||
|
||||
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
|
||||
const hasFullRightsToProgramme = userFullProgrammeRights.includes(programme)
|
||||
const hasFullRightsToCombinedProgramme = userFullProgrammeRights.includes(combinedProgramme!)
|
||||
|
||||
const userProgrammeRightsCodes = userProgrammeRights.map(({ code }) => code)
|
||||
const hasAccessToProgramme = userProgrammeRightsCodes.includes(programme)
|
||||
const hasAccessToCombinedProgramme = userProgrammeRightsCodes.includes(combinedProgramme!)
|
||||
@@ -68,10 +138,8 @@ router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReq
|
||||
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
|
||||
}
|
||||
|
||||
const { startDate, endDate } = parseDateRangeFromParams({
|
||||
...req.query,
|
||||
years: req.query.years,
|
||||
})
|
||||
// NOTE: Years are academic years
|
||||
const { startDate, endDate } = parseDateRangeFromParams({ semesters, years })
|
||||
|
||||
const studentNumbers = await getStudentNumbersWithStudyRights({
|
||||
programmeCodes: [programme],
|
||||
@@ -81,35 +149,21 @@ router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReq
|
||||
})
|
||||
|
||||
const studyRights = [programme]
|
||||
const tagList = await getStudentTags(studyRights, studentNumbers, userId)
|
||||
const tagMap = await getStudentTagMap(studyRights, studentNumbers, userId)
|
||||
|
||||
const result = await statisticsOf(studentNumbers, studyRights, tagList, startDate)
|
||||
const result = await statisticsOf(studentNumbers, studyRights, tagMap, startDate)
|
||||
const processed = obfuscateStuff({
|
||||
result,
|
||||
|
||||
// Obfuscate if user has only limited study programme rights and there are any students
|
||||
if (!hasFullAccessToStudents && !hasFullRightsToProgramme && !hasFullRightsToCombinedProgramme) {
|
||||
result.students = result.students.map(student => {
|
||||
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
|
||||
// correct year for age distribution calculation but the date is always January 1st
|
||||
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0))
|
||||
return {
|
||||
...student,
|
||||
firstnames: '',
|
||||
lastname: '',
|
||||
phoneNumber: '',
|
||||
iv,
|
||||
studentNumber,
|
||||
name: '',
|
||||
email: '',
|
||||
secondaryEmail: '',
|
||||
sis_person_id: '',
|
||||
tags: [],
|
||||
birthdate: obfuscatedBirthDate,
|
||||
obfuscated: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
programme,
|
||||
combinedProgramme,
|
||||
|
||||
return res.json(result)
|
||||
userRoles,
|
||||
userProgrammeRights,
|
||||
studentsUserCanAccess: new Set(studentsUserCanAccess),
|
||||
})
|
||||
|
||||
return res.json(processed)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -119,7 +173,12 @@ router.get<
|
||||
PopulationstatisticsbycourseReqBody,
|
||||
PopulationstatisticsbycourseParams
|
||||
>('/v3/populationstatisticsbycourse', async (req, res) => {
|
||||
const { id: userId, roles, studentsUserCanAccess: allStudentsUserCanAccess, programmeRights } = req.user
|
||||
const {
|
||||
id: userId,
|
||||
roles: userRoles,
|
||||
programmeRights: userProgrammeRights,
|
||||
studentsUserCanAccess: allStudentsUserCanAccess,
|
||||
} = req.user
|
||||
const { coursecodes: coursecodeJSON, from, to, separate, unifyCourses } = req.query
|
||||
|
||||
if (!coursecodeJSON || !from || !to) {
|
||||
@@ -139,79 +198,51 @@ router.get<
|
||||
|
||||
const studentNumbers = await findByCourseAndSemesters(coursecodes, Number(from), Number(to), isSeparate, unifyCourses)
|
||||
|
||||
const studyRights = []
|
||||
const tagList = await getStudentTags(studyRights, studentNumbers, userId)
|
||||
const result: any = await statisticsOf(studentNumbers, studyRights, tagList)
|
||||
const courseProviders: string[] = !hasFullAccessToStudentData(roles)
|
||||
? await getCourseProvidersForCourses(coursecodes)
|
||||
: []
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const courseProviders: string[] = await getCourseProvidersForCourses(coursecodes)
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
|
||||
const rightsMappedToProviders = mapToProviders(fullStudyProgrammeRights)
|
||||
const found = courseProviders.some(provider => rightsMappedToProviders.includes(provider))
|
||||
|
||||
const studentsUserCanAccess =
|
||||
hasFullAccessToStudentData(roles) || found
|
||||
? new Set(studentNumbers)
|
||||
: new Set(intersection(studentNumbers, allStudentsUserCanAccess))
|
||||
const studentsUserCanAccess = courseProviders.some(provider => rightsMappedToProviders.includes(provider))
|
||||
? new Set(studentNumbers)
|
||||
: new Set(allStudentsUserCanAccess)
|
||||
|
||||
const randomHash = crypto.randomBytes(12).toString('hex')
|
||||
const obfuscateStudent = ({
|
||||
studyRights,
|
||||
studentNumber,
|
||||
courses,
|
||||
gender_code,
|
||||
}: {
|
||||
studyRights: SISStudyRightModel[]
|
||||
studentNumber: string
|
||||
courses: ParsedCourse[]
|
||||
gender_code: GenderCode
|
||||
}) => ({
|
||||
courses,
|
||||
studyRights,
|
||||
gender_code,
|
||||
studentNumber: crypto.createHash('md5').update(`${studentNumber}${randomHash}`).digest('hex'),
|
||||
firstnames: '',
|
||||
lastname: '',
|
||||
phone_number: '',
|
||||
name: '',
|
||||
email: '',
|
||||
started: null,
|
||||
credits: '',
|
||||
tags: [],
|
||||
obfuscated: true,
|
||||
const studyRights = []
|
||||
const tagMap = await getStudentTagMap(studyRights, studentNumbers, userId)
|
||||
const result = await statisticsOf(studentNumbers, studyRights, tagMap)
|
||||
const processed = obfuscateStuff({
|
||||
result,
|
||||
|
||||
userRoles,
|
||||
userProgrammeRights,
|
||||
studentsUserCanAccess,
|
||||
})
|
||||
|
||||
if ('students' in result) {
|
||||
result.students = result.students.map(student =>
|
||||
studentsUserCanAccess.has(student.studentNumber) ? student : obfuscateStudent(student)
|
||||
)
|
||||
}
|
||||
|
||||
res.json(result)
|
||||
return res.json(processed)
|
||||
})
|
||||
|
||||
router.post<never, PostByStudentNumbersResBody, PostByStudentNumbersReqBody>(
|
||||
// Used in custom population and single study guidance groups
|
||||
router.post<never, GetCustomPopulationResBody, CustomPopulationQuery>(
|
||||
'/v3/populationstatisticsbystudentnumbers',
|
||||
async (req, res) => {
|
||||
const { studentnumberlist, tags } = req.body
|
||||
const { studentNumbers, tags } = req.body
|
||||
const { id: userId, roles, studentsUserCanAccess } = req.user
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentnumberlist
|
||||
: intersection(studentnumberlist, studentsUserCanAccess)
|
||||
? studentNumbers
|
||||
: intersection(studentNumbers, studentsUserCanAccess)
|
||||
|
||||
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
|
||||
|
||||
const studyRights = [studyProgrammeCode].filter(value => value !== undefined)
|
||||
const tagList = await getStudentTags(studyRights, filteredStudentNumbers, userId)
|
||||
const tagMap = await getStudentTagMap(studyRights, filteredStudentNumbers, userId)
|
||||
|
||||
const { startDate } = tags?.year
|
||||
? parseDateRangeFromParams({ semesters: ['FALL', 'SPRING'], years: [tags?.year] })
|
||||
: { startDate: undefined }
|
||||
|
||||
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagList, startDate)
|
||||
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagMap, startDate)
|
||||
|
||||
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
|
||||
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
|
||||
const discardedStudentNumbers = difference(studentNumbers, filteredStudentNumbers)
|
||||
|
||||
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
|
||||
}
|
||||
@@ -229,14 +260,13 @@ router.get<never, PopulationstatisticsStudyprogrammesResBody>(
|
||||
const filteredProgrammes = hasFullAccessToStudentData(roles)
|
||||
? programmes
|
||||
: programmes.filter(programme => allRights.includes(programme.code))
|
||||
const formattedProgrammes = filteredProgrammes.reduce<Record<string, Unarray<ProgrammesOfOrganization>>>(
|
||||
(acc, curr) => {
|
||||
acc[curr.code] = curr
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
res.json(formattedProgrammes)
|
||||
const formattedFilteredProgrammes = keyBy(filteredProgrammes, 'code')
|
||||
const formattedAllProgrammes = keyBy(programmes, 'code')
|
||||
|
||||
res.json({
|
||||
allProgrammes: formattedAllProgrammes,
|
||||
filteredProgrammes: formattedFilteredProgrammes,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,65 +1,58 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import type { CanError } from '@oodikone/shared/routes'
|
||||
import type {
|
||||
GetStudentRequestResBody,
|
||||
GetStudentRequestReqBody,
|
||||
GetStudentRequestQuery,
|
||||
GetStudentDetailParams,
|
||||
GetStudentDetailResBody,
|
||||
GetStudentDetailReqBody,
|
||||
} from '@oodikone/shared/routes/students'
|
||||
import { splitByEmptySpace } from '@oodikone/shared/util'
|
||||
import { bySearchTermAndStudentNumbers, withStudentNumber } from '../services/students'
|
||||
import { hasFullAccessToStudentData, splitByEmptySpace } from '../util'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
import { hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
interface GetStudentsRequest extends Request {
|
||||
query: {
|
||||
searchTerm: string
|
||||
router.get<never, CanError<GetStudentRequestResBody>, GetStudentRequestReqBody, GetStudentRequestQuery>(
|
||||
'/',
|
||||
async (req, res) => {
|
||||
const { searchTerm } = req.query
|
||||
const trimmedSearchTerm = searchTerm?.trim()
|
||||
const validSearchTerm =
|
||||
trimmedSearchTerm &&
|
||||
!splitByEmptySpace(trimmedSearchTerm)
|
||||
.slice(0, 2)
|
||||
.find(term => term.length > 2)
|
||||
|
||||
if (!trimmedSearchTerm || validSearchTerm) {
|
||||
return res.status(400).json({ error: 'at least one search term must be longer than 2 characters' })
|
||||
}
|
||||
|
||||
const { roles, studentsUserCanAccess } = req.user
|
||||
const results: ReturnType<typeof bySearchTermAndStudentNumbers> = hasFullAccessToStudentData(roles)
|
||||
? bySearchTermAndStudentNumbers(trimmedSearchTerm)
|
||||
: bySearchTermAndStudentNumbers(trimmedSearchTerm, studentsUserCanAccess)
|
||||
|
||||
return res.json(await results)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.get('/', async (req: GetStudentsRequest, res: Response) => {
|
||||
const {
|
||||
user: { roles, studentsUserCanAccess },
|
||||
query: { searchTerm },
|
||||
} = req
|
||||
router.get<GetStudentDetailParams, CanError<GetStudentDetailResBody>, GetStudentDetailReqBody>(
|
||||
'/:studentNumber',
|
||||
async (req, res) => {
|
||||
const { studentNumber } = req.params
|
||||
const { id, roles, studentsUserCanAccess } = req.user
|
||||
|
||||
const trimmedSearchTerm = searchTerm ? searchTerm.trim() : undefined
|
||||
if (!hasFullAccessToStudentData(roles) && !studentsUserCanAccess.includes(studentNumber))
|
||||
return res.status(403).json({ error: 'User does not have permission to view current student.' })
|
||||
|
||||
if (
|
||||
trimmedSearchTerm &&
|
||||
!splitByEmptySpace(trimmedSearchTerm)
|
||||
.slice(0, 2)
|
||||
.find(term => term.length > 2)
|
||||
) {
|
||||
throw new ApplicationError('at least one search term must be longer than 2 characters', 400)
|
||||
const student = await withStudentNumber(studentNumber, id)
|
||||
if (!student) return res.status(404).json({ error: 'Student not found' })
|
||||
|
||||
res.json(student)
|
||||
}
|
||||
|
||||
let results: Awaited<ReturnType<typeof bySearchTermAndStudentNumbers>> = []
|
||||
if (trimmedSearchTerm) {
|
||||
results = hasFullAccessToStudentData(roles)
|
||||
? await bySearchTermAndStudentNumbers(trimmedSearchTerm)
|
||||
: await bySearchTermAndStudentNumbers(trimmedSearchTerm, studentsUserCanAccess)
|
||||
}
|
||||
res.json(results)
|
||||
})
|
||||
|
||||
interface GetStudentRequest extends Request {
|
||||
params: {
|
||||
studentNumber: string
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/:studentNumber', async (req: GetStudentRequest, res: Response) => {
|
||||
const { studentNumber } = req.params
|
||||
const {
|
||||
user: { id, roles, studentsUserCanAccess },
|
||||
} = req
|
||||
|
||||
if (!hasFullAccessToStudentData(roles) && !studentsUserCanAccess.includes(studentNumber)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: `User does not have permission to view data for student number ${studentNumber}.` })
|
||||
}
|
||||
const student = await withStudentNumber(studentNumber, id)
|
||||
if (!student) {
|
||||
return res.status(404).json({ error: `Student not found with student number ${studentNumber}.` })
|
||||
}
|
||||
res.json(student)
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
|
||||
import { CreditStatsPayload } from '@oodikone/shared/types/studyProgramme'
|
||||
import {
|
||||
getBasicStats,
|
||||
setBasicStats,
|
||||
@@ -11,10 +12,8 @@ import {
|
||||
} from '../services/analyticsService'
|
||||
import { getCreditsProduced } from '../services/providerCredits'
|
||||
import { getBasicStatsForStudytrack } from '../services/studyProgramme/studyProgrammeBasics'
|
||||
import {
|
||||
getStudyProgrammeCoursesForStudyTrack,
|
||||
getStudyProgrammeStatsForColorizedCoursesTable,
|
||||
} from '../services/studyProgramme/studyProgrammeCourses'
|
||||
import { getStudyProgrammeCoursesForStudyTrack } from '../services/studyProgramme/studyProgrammeCourses'
|
||||
import { getStudyProgrammeStatsForColorizedCoursesTable } from '../services/studyProgramme/studyProgrammeCoursesBySemester'
|
||||
import { getGraduationStatsForStudyTrack } from '../services/studyProgramme/studyProgrammeGraduations'
|
||||
import { updateBasicView, updateStudyTrackView } from '../services/studyProgramme/studyProgrammeUpdates'
|
||||
import { getStudyRightsInProgramme, getStudyTracksForProgramme } from '../services/studyProgramme/studyRightFinders'
|
||||
@@ -34,11 +33,11 @@ interface GetCreditStatsRequest extends Request {
|
||||
|
||||
router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) => {
|
||||
const { codes, specialGroups, yearType } = req.query
|
||||
const stats = {}
|
||||
const stats: CreditStatsPayload = {}
|
||||
for (const code of JSON.parse(codes)) {
|
||||
stats[code] = await getCreditsProduced(code, yearType === 'ACADEMIC_YEAR', specialGroups === 'SPECIAL_INCLUDED')
|
||||
}
|
||||
return res.json({ stats })
|
||||
return res.json(stats)
|
||||
})
|
||||
|
||||
interface GetStatsRequest extends Request {
|
||||
@@ -134,13 +133,13 @@ interface GetStudyTrackStatsRequest extends Request {
|
||||
|
||||
router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
const { graduated, special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
|
||||
const { special_groups: specialGroups, combined_programme: combinedProgramme } = req.query
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
void logInfoForGrafana(code, combinedProgramme)
|
||||
const data = await getStudyTrackStats(code, combinedProgramme, graduated, specialGroups)
|
||||
const data = await getStudyTrackStats(code, combinedProgramme, specialGroups)
|
||||
if (data) {
|
||||
return res.json(data)
|
||||
}
|
||||
@@ -150,13 +149,12 @@ router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: R
|
||||
studyProgramme: code,
|
||||
combinedProgramme,
|
||||
settings: {
|
||||
graduated: graduated === 'GRADUATED_INCLUDED',
|
||||
specialGroups: specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
studyRightsOfProgramme,
|
||||
})
|
||||
if (updated) {
|
||||
await setStudyTrackStats(updated, graduated, specialGroups)
|
||||
await setStudyTrackStats(updated, specialGroups)
|
||||
}
|
||||
return res.json(updated)
|
||||
})
|
||||
@@ -180,7 +178,7 @@ router.get('/:id/studytracks', async (req: Request, res: Response) => {
|
||||
const data = await getStudyTracksForProgramme(code)
|
||||
return res.json(data)
|
||||
} catch (error) {
|
||||
logger.error({ message: `Failed to get study tracks for study programme ${code}`, meta: `${error}` })
|
||||
logger.error({ message: `Failed to get study tracks for degree programme ${code}`, meta: `${error}` })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from 'express'
|
||||
import { difference } from 'lodash'
|
||||
import { difference } from 'lodash-es'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { NewTag, Role, StudentTag, Tag } from '@oodikone/shared/types'
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { serviceProvider } from '../config'
|
||||
import { mapToProviders, splitByEmptySpace } from '@oodikone/shared/util'
|
||||
import * as auth from '../middleware/auth'
|
||||
import { getProvidersOfFaculty, isFaculty } from '../services/organizations'
|
||||
import { getTeachersBySearchTerm, getTeacherStatistics, getYearlyStatistics } from '../services/teachers'
|
||||
import { CategoryID, getTeacherStats, findAndSaveTeachers, getCategoriesAndYears } from '../services/teachers/top'
|
||||
import { getFullStudyProgrammeRights, splitByEmptySpace } from '../util'
|
||||
import { getFullStudyProgrammeRights, validateParamLength } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const iamGroupsGivingFullAccess = ['hy-dekaanit', 'hy-varadekaanit-opetus']
|
||||
|
||||
const fullAccessAuth = () =>
|
||||
serviceProvider === 'toska' ? auth.roles([], iamGroupsGivingFullAccess) : auth.roles(['teachers'])
|
||||
const fullAccessAuth = () => auth.roles([], iamGroupsGivingFullAccess)
|
||||
|
||||
interface GetTeachersRequest extends Request {
|
||||
query: {
|
||||
@@ -28,9 +26,10 @@ router.get('/', fullAccessAuth(), async (req: GetTeachersRequest, res: Response)
|
||||
}
|
||||
|
||||
const trimmedSearchTerm = searchTerm.trim()
|
||||
const stringSearchTermIsInvalid = !splitByEmptySpace(trimmedSearchTerm).find(searchTerm => searchTerm.length >= 4)
|
||||
const numericSearchTermIsInvalid = !Number.isNaN(Number(trimmedSearchTerm)) && trimmedSearchTerm.length < 6
|
||||
if (stringSearchTermIsInvalid || numericSearchTermIsInvalid) {
|
||||
const searchTermIsInvalid =
|
||||
!validateParamLength(trimmedSearchTerm, 4) &&
|
||||
!splitByEmptySpace(trimmedSearchTerm).find(searchTerm => searchTerm.length >= 4)
|
||||
if (searchTermIsInvalid) {
|
||||
return res.status(400).json({ error: 'Invalid search term' })
|
||||
}
|
||||
|
||||
@@ -57,7 +56,7 @@ router.get('/top', fullAccessAuth(), async (req: GetTopTeachersRequest, res: Res
|
||||
}
|
||||
await findAndSaveTeachers(Number(yearcode), Number(yearcode))
|
||||
const updatedStats = await getTeacherStats(category, Number(yearcode))
|
||||
res.json(updatedStats)
|
||||
return res.json(updatedStats)
|
||||
})
|
||||
|
||||
interface PostTopTeachersRequest extends Request {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
import { Graduated, NameWithCode } from '@oodikone/shared/types'
|
||||
import { serviceProvider } from '../config'
|
||||
import { magicFacultyCode } from '../config/organizationConstants'
|
||||
import { OrganizationModel } from '../models'
|
||||
import { getDegreeProgrammesOfFaculty } from '../services/faculty/faculty'
|
||||
@@ -39,18 +38,17 @@ interface GetProgressStatsRequest extends Request {
|
||||
|
||||
router.get('/allprogressstats', async (req: GetProgressStatsRequest, res: Response) => {
|
||||
const specialGroups = req.query?.specialsIncluded === 'true' ? 'SPECIAL_INCLUDED' : 'SPECIAL_EXCLUDED'
|
||||
const graduated = req.query?.graduated
|
||||
const allFaculties = await getSortedFaculties()
|
||||
const facultyCodes = allFaculties.map(faculty => faculty.code)
|
||||
const codeToData: Record<string, FacultyProgressData> = {}
|
||||
|
||||
for (const facultyCode of facultyCodes) {
|
||||
let data: any = await getFacultyProgressStats(facultyCode, specialGroups, graduated)
|
||||
let data = await getFacultyProgressStats(facultyCode, specialGroups)
|
||||
if (!data) {
|
||||
logger.info(`Data missing from server: Refreshing progress faculty data for faculty ${facultyCode}`)
|
||||
const programmes = await getDegreeProgrammesOfFaculty(facultyCode, true)
|
||||
data = await combineFacultyStudentProgress(facultyCode, programmes, specialGroups, graduated)
|
||||
await setFacultyProgressStats(data, specialGroups, graduated)
|
||||
data = await combineFacultyStudentProgress(facultyCode, programmes, specialGroups)
|
||||
await setFacultyProgressStats(data, specialGroups)
|
||||
}
|
||||
codeToData[facultyCode] = data
|
||||
}
|
||||
@@ -71,6 +69,12 @@ router.get('/allprogressstats', async (req: GetProgressStatsRequest, res: Respon
|
||||
master: {} as Record<string, number[]>,
|
||||
doctor: {} as Record<string, number[]>,
|
||||
},
|
||||
graduatedCount: {
|
||||
bachelor: {} as Record<string, number>,
|
||||
bachelorMaster: {} as Record<string, number>,
|
||||
master: {} as Record<string, number>,
|
||||
doctor: {} as Record<string, number>,
|
||||
},
|
||||
}
|
||||
|
||||
const unifyProgressStats = (progStats: number[][][]) => {
|
||||
@@ -96,6 +100,14 @@ router.get('/allprogressstats', async (req: GetProgressStatsRequest, res: Respon
|
||||
if (!universityData.creditCounts[fieldName][year]) universityData.creditCounts[fieldName][year] = []
|
||||
universityData.creditCounts[fieldName][year].push(...facultyData.creditCounts[fieldName][year])
|
||||
}
|
||||
|
||||
for (const fieldName of degreeNames) {
|
||||
if (!facultyData.graduatedCount[fieldName] || Object.keys(facultyData.graduatedCount[fieldName]).length === 0)
|
||||
continue
|
||||
if (!universityData.graduatedCount[fieldName][year]) universityData.graduatedCount[fieldName][year] = 0
|
||||
universityData.graduatedCount[fieldName][year] += facultyData.graduatedCount[fieldName][year]
|
||||
}
|
||||
|
||||
const progStats = ['bachelorsProgStats', 'bcMsProgStats', 'mastersProgStats', 'doctoralProgStats'] as const
|
||||
for (const fieldName of progStats) {
|
||||
if (Object.keys(facultyData[fieldName] || {}).length === 0) continue
|
||||
@@ -112,7 +124,7 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
|
||||
const allFaculties = await getSortedFaculties()
|
||||
const facultyCodes = allFaculties.map(faculty => faculty.code)
|
||||
const facultyData: Record<string, Awaited<ReturnType<typeof countGraduationTimes>>> = {}
|
||||
const programmeFilter = serviceProvider === 'toska' ? 'NEW_STUDY_PROGRAMMES' : 'ALL_PROGRAMMES'
|
||||
const programmeFilter = 'NEW_DEGREE_PROGRAMMES'
|
||||
for (const facultyCode of facultyCodes) {
|
||||
let data: any = await getGraduationStats(facultyCode, programmeFilter, true)
|
||||
if (!data) {
|
||||
|
||||
@@ -57,7 +57,7 @@ router.post('/update/v2/customlist/:type', async (req: PostCustomListRequest, re
|
||||
programmes: refreshProgrammesByList,
|
||||
}
|
||||
if (typeof typeToJob[type] !== 'function') {
|
||||
return res.status(400).json('Bad job type')
|
||||
return res.status(400).json('Updater does not recognize this job type: ' + type)
|
||||
}
|
||||
const response = await typeToJob[type](list)
|
||||
if (response) {
|
||||
@@ -88,10 +88,10 @@ router.post('/refresh-teacher-leaderboard', (req: Request, res: Response) => {
|
||||
res.status(200).json('Teacher leaderboard for the current and previous academic year refreshed')
|
||||
})
|
||||
|
||||
router.post('/refresh_study_programmes_v2', async (req: Request, res: Response) => {
|
||||
logger.info(`${req.user.username} requested refresh of study programmes`)
|
||||
router.post('/refresh_degree_programmes_v2', async (req: Request, res: Response) => {
|
||||
logger.info(`${req.user.username} requested refresh of degree programmes`)
|
||||
await refreshProgrammes()
|
||||
res.status(200).json('Added job for refreshing study programme overviews')
|
||||
res.status(200).json('Added job for refreshing degree programme overviews')
|
||||
})
|
||||
|
||||
router.post('/refresh_faculties_v2', async (req: Request, res: Response) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user