mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef683f8fe1 | ||
|
|
6e3da7e1af | ||
|
|
c9fe222dd3 | ||
|
|
084be9df1b | ||
|
|
0b86f82eb7 | ||
|
|
993d5e842e | ||
|
|
6ab7b9bbc4 | ||
|
|
0ccb1e79c8 | ||
|
|
24ade5b222 | ||
|
|
ae9fbf182c | ||
|
|
4be53e7707 | ||
|
|
c980560f53 | ||
|
|
b1928a7843 | ||
|
|
2e60df5804 | ||
|
|
2ccd526805 | ||
|
|
92b6b3cb60 | ||
|
|
fbc842cac5 | ||
|
|
cb334e2865 | ||
|
|
24bd750763 | ||
|
|
eb07173f36 | ||
|
|
815d85d8e2 | ||
|
|
a4a3c0f2ca | ||
|
|
20c3632972 | ||
|
|
7af6374e7b | ||
|
|
9accbc299a | ||
|
|
7a03455c8e | ||
|
|
a72c5eeaea | ||
|
|
1f2d381039 | ||
|
|
a31ef9af07 | ||
|
|
fcd855a3e2 | ||
|
|
6a31bcf4d7 | ||
|
|
d3de30bdec | ||
|
|
1ee4fb8bcf | ||
|
|
3b58c13f4a | ||
|
|
9ca46021aa | ||
|
|
87ade36e4b | ||
|
|
32a670a8d1 | ||
|
|
f2478fbe24 | ||
|
|
f81a5ee1a4 | ||
|
|
bf780d09d7 | ||
|
|
3bbee5c0b3 | ||
|
|
880f5997c1 | ||
|
|
44e2bad801 | ||
|
|
18a5eadbcc | ||
|
|
e871b77192 | ||
|
|
4772ca99c2 | ||
|
|
6f6b059d83 | ||
|
|
2cc016e439 | ||
|
|
7670dbd50f | ||
|
|
86a3081a1c | ||
|
|
01b39926b6 | ||
|
|
790e3d3afd | ||
|
|
b4fbe1b47d | ||
|
|
0d2701269a | ||
|
|
8c6a634df0 | ||
|
|
a0117c0de1 | ||
|
|
3e12a4df14 | ||
|
|
5aaa343720 | ||
|
|
f2f8d5d6d0 | ||
|
|
ede3748c09 | ||
|
|
6cc230512c | ||
|
|
24e42c3b63 | ||
|
|
c3e222c155 | ||
|
|
1fb81f4bbf | ||
|
|
e3e0a24b9a | ||
|
|
66bed7f88a | ||
|
|
c59b0a3b58 | ||
|
|
37a5b17578 | ||
|
|
16d938a423 | ||
|
|
0132a98733 | ||
|
|
d99f3b326f | ||
|
|
61bc346923 | ||
|
|
358eac4de2 | ||
|
|
863a7ac157 | ||
|
|
0faef48179 | ||
|
|
b29eb0c8e6 | ||
|
|
2f8b38617b | ||
|
|
0489155da7 | ||
|
|
8bf1da6047 | ||
|
|
e0bb37a75a | ||
|
|
27d4962fcb | ||
|
|
6ca346a00a | ||
|
|
d1bbd9966e | ||
|
|
25e21ecb14 | ||
|
|
72cdbcb871 | ||
|
|
1a9759f77f | ||
|
|
b1e65803d3 | ||
|
|
e19697a9c0 | ||
|
|
a584581141 | ||
|
|
b7a98483e4 | ||
|
|
20c197f3a7 | ||
|
|
d07ba795df | ||
|
|
b17b714be2 | ||
|
|
22a5e58fe3 | ||
|
|
9a0fbd50bd | ||
|
|
51572ebeed | ||
|
|
139e253d6a | ||
|
|
0df918b47a | ||
|
|
781bc5da3d | ||
|
|
67297e536c | ||
|
|
dcdacc7187 | ||
|
|
684a90c60c | ||
|
|
0c1a1e81cb | ||
|
|
8c8eb34a05 | ||
|
|
f1ebbeb0e9 | ||
|
|
c176552326 | ||
|
|
7419cc169d | ||
|
|
c21ffe9a74 | ||
|
|
2b15168342 | ||
|
|
3e296e33bd | ||
|
|
37df5276f7 | ||
|
|
7a26501d72 | ||
|
|
561e41cab1 | ||
|
|
4409f60195 | ||
|
|
323aae2600 | ||
|
|
fa28d86638 | ||
|
|
1976d2914d | ||
|
|
52e6a49fd3 | ||
|
|
fc4e48aaa0 | ||
|
|
ae83dc3563 | ||
|
|
2109cfcf87 | ||
|
|
1a3facf680 | ||
|
|
d016cec63c | ||
|
|
74bfceaf72 | ||
|
|
a549ae5095 | ||
|
|
863cb9d134 | ||
|
|
eb2576bfad | ||
|
|
77831b3bcf | ||
|
|
99ff14b13f | ||
|
|
e3c2427885 | ||
|
|
a6e52a690f | ||
|
|
eac4a03ea8 | ||
|
|
def64d5612 | ||
|
|
9ddd42aced | ||
|
|
60aa43d520 | ||
|
|
49be4b4f54 | ||
|
|
2f5c68f439 | ||
|
|
54321f349c | ||
|
|
9aba7ab5c5 | ||
|
|
059ccbd42b | ||
|
|
733cfac1be | ||
|
|
bcf1bca28b | ||
|
|
025aca0c15 | ||
|
|
00c4a6b409 | ||
|
|
9be9509562 | ||
|
|
9e6a92c114 | ||
|
|
178ff11b48 | ||
|
|
e7a1cdf098 | ||
|
|
60f10faf9e | ||
|
|
b31b3543bd | ||
|
|
b4263f29c5 | ||
|
|
6a69a3b98b | ||
|
|
921fa2d1de | ||
|
|
c4724f8c07 | ||
|
|
44a7f8bcee | ||
|
|
a632f3757d | ||
|
|
a84ad67b76 | ||
|
|
29331e13d3 | ||
|
|
0358cb49a6 | ||
|
|
78719ee09a | ||
|
|
e66542fe89 | ||
|
|
ca9d43b4a9 | ||
|
|
286b4a90b4 | ||
|
|
cfa65da634 | ||
|
|
3c81a8cbcd | ||
|
|
444a261261 | ||
|
|
a5928b0dba | ||
|
|
27ab354a70 | ||
|
|
e293eb8471 | ||
|
|
4dcacbb2ce | ||
|
|
122eb29068 | ||
|
|
d5e6b52316 | ||
|
|
601d69cc3d | ||
|
|
e8f73adc81 | ||
|
|
c0187ad18e | ||
|
|
f877e1187d | ||
|
|
3d86be5b9a | ||
|
|
fe9aad8043 | ||
|
|
34d9855782 | ||
|
|
e1213bd768 | ||
|
|
5d6df13d8e | ||
|
|
bf08700807 | ||
|
|
c95b5a9cef | ||
|
|
cd7766982b | ||
|
|
02932036fe | ||
|
|
7b3bea1f9c | ||
|
|
09dec983aa | ||
|
|
3e8ac19af1 | ||
|
|
93aacd3e16 | ||
|
|
29108ca2b2 | ||
|
|
bfa14c9959 | ||
|
|
836f797da9 | ||
|
|
45ca6f0083 | ||
|
|
2b44529ed4 | ||
|
|
e27d162921 | ||
|
|
7ebddf076f | ||
|
|
d0283bbc34 | ||
|
|
facbb2da44 | ||
|
|
cafc09f805 | ||
|
|
c3a61ab7b3 | ||
|
|
2a0c39ab05 | ||
|
|
c83646512e | ||
|
|
82c6181cd5 | ||
|
|
cc5f5e01c6 | ||
|
|
7e04951e08 | ||
|
|
5cabca6311 | ||
|
|
96b181d3af | ||
|
|
72acdf9a3b | ||
|
|
0b29b6aa58 | ||
|
|
c272f1fde3 | ||
|
|
7174ed55c1 | ||
|
|
f958ba6701 | ||
|
|
93264fef4d | ||
|
|
a4baf8ae6d | ||
|
|
f3aa59fe1f | ||
|
|
322f19826a | ||
|
|
2a2388cbf6 | ||
|
|
d2c283c1b9 | ||
|
|
3d84fb6a74 | ||
|
|
6a53e30a94 | ||
|
|
f8616ba1c4 | ||
|
|
a87c35069d | ||
|
|
fe87b0b960 | ||
|
|
1a8b88dd0e | ||
|
|
b09cb8d574 | ||
|
|
630df92e9d | ||
|
|
948808ce11 | ||
|
|
30e822b3f6 | ||
|
|
2769d0999e | ||
|
|
86a1ef7b9f | ||
|
|
b4b6d45463 | ||
|
|
dc7ad404cb | ||
|
|
dec0087de0 | ||
|
|
61f91d1cfe | ||
|
|
c11f905a2e | ||
|
|
b2e433a79b | ||
|
|
1e7160dcf5 | ||
|
|
da973fe74a | ||
|
|
309d3128e4 | ||
|
|
4add189096 | ||
|
|
f5ddf6291b | ||
|
|
3989e0da94 | ||
|
|
3035b846a4 | ||
|
|
c88aa37b68 | ||
|
|
884191f352 | ||
|
|
35af223c32 | ||
|
|
71e639efa1 | ||
|
|
546c683c8b | ||
|
|
1c5c7781a5 | ||
|
|
99b08020db | ||
|
|
8c2efa9199 | ||
|
|
7c50c80b15 | ||
|
|
9290fbf32b | ||
|
|
ffaa3eb05b | ||
|
|
77b159739f | ||
|
|
0e63b970f7 | ||
|
|
7bedbe6451 | ||
|
|
dacf5cc51b | ||
|
|
de5fdb7eaf | ||
|
|
c35a632469 | ||
|
|
691b6d6cb3 | ||
|
|
c8a69a15b4 | ||
|
|
665b95a6f8 | ||
|
|
d6ab10bed2 | ||
|
|
a7fb7eda97 | ||
|
|
44ce8ac298 | ||
|
|
7312416e2b | ||
|
|
d967bce32b | ||
|
|
4716299771 | ||
|
|
1036b47523 | ||
|
|
317fb6f655 | ||
|
|
d6d804d288 | ||
|
|
8b3c408700 | ||
|
|
7a94c92181 | ||
|
|
2feeaddb8b | ||
|
|
6d9c4b0b0a | ||
|
|
c9abbf958a | ||
|
|
47692de1ce | ||
|
|
ad6260a313 | ||
|
|
298e43a89a | ||
|
|
055d3b4eac | ||
|
|
5a831cdc86 | ||
|
|
c23ee470d8 | ||
|
|
59b4aad1ab | ||
|
|
de3e0f5e6b | ||
|
|
7ede62760e | ||
|
|
d2fdeeb830 | ||
|
|
220952153f | ||
|
|
bace3c80af | ||
|
|
d9457bf855 | ||
|
|
2d6d12f95f | ||
|
|
93a3014209 | ||
|
|
3d6bc23658 | ||
|
|
1906827760 | ||
|
|
4b8f389dd3 | ||
|
|
5d69a9b53b | ||
|
|
5c0c267606 | ||
|
|
00c7e45672 | ||
|
|
b28d43c7f8 | ||
|
|
c0f15c9dd9 | ||
|
|
64240c9f6f | ||
|
|
cbbcdd0e40 | ||
|
|
dc7a6805a7 | ||
|
|
0321dbed62 | ||
|
|
88a46a8f23 | ||
|
|
5c26f04436 | ||
|
|
e80fa25fb3 | ||
|
|
12f11183cf | ||
|
|
abfefa8c3e | ||
|
|
eb82eee286 | ||
|
|
5ff9b3b788 | ||
|
|
f4d73b649f | ||
|
|
4136c0d3b5 | ||
|
|
2b8cf76053 | ||
|
|
e23c60cb38 | ||
|
|
676804b770 | ||
|
|
2785742934 | ||
|
|
f82638763f | ||
|
|
7253ba1e17 | ||
|
|
e2847a68ea | ||
|
|
687e231e65 | ||
|
|
0629614ed8 | ||
|
|
843e04f154 | ||
|
|
d7c3423a3f | ||
|
|
8be238d4cc | ||
|
|
ecbaa35802 | ||
|
|
732e14cb2d | ||
|
|
e98ef1b94f | ||
|
|
e254de4df3 | ||
|
|
1d169ee70a | ||
|
|
0380cab715 | ||
|
|
4391c06f00 | ||
|
|
36a3044b16 | ||
|
|
db08330cb9 | ||
|
|
0a071ee071 | ||
|
|
2354b696a5 | ||
|
|
f6db0f2fe8 | ||
|
|
e40c219e78 | ||
|
|
2e7ca4d960 | ||
|
|
c3fd874559 | ||
|
|
80768b4d15 | ||
|
|
7de955398c | ||
|
|
2510903d63 | ||
|
|
ef3aa1faf2 | ||
|
|
7444567d64 | ||
|
|
a559dab709 | ||
|
|
34feb51d7b | ||
|
|
a206a1a9b3 | ||
|
|
9a18de83f9 | ||
|
|
d1723e7ad6 | ||
|
|
e1adc24ccd | ||
|
|
1668f9581b | ||
|
|
58b0b97e3a | ||
|
|
90479e5fbf | ||
|
|
7461ace443 | ||
|
|
7490395f99 | ||
|
|
a16195d47a | ||
|
|
c4084faf89 | ||
|
|
09a9958272 | ||
|
|
e4325f67bd | ||
|
|
91145e3a88 | ||
|
|
f4920de7d2 | ||
|
|
d2f96b4657 | ||
|
|
21d252f1c7 | ||
|
|
06f63110b9 | ||
|
|
9bbd67f7a3 | ||
|
|
fb465ec17e | ||
|
|
76f06a6481 | ||
|
|
929c022bf8 | ||
|
|
d32aba88fc | ||
|
|
e9d482f99c | ||
|
|
496fd6bb15 | ||
|
|
016d8da641 | ||
|
|
37d12ac65f | ||
|
|
1b42b51e6e | ||
|
|
36230033ef | ||
|
|
dd1e143367 | ||
|
|
d44ee6738f | ||
|
|
1e8c6a2c79 | ||
|
|
3833720a22 | ||
|
|
90ae26903e | ||
|
|
b68806e6ed | ||
|
|
3a030a163d | ||
|
|
cd726716f5 | ||
|
|
627cd52b5e | ||
|
|
01afe1607c | ||
|
|
fd497422f8 | ||
|
|
fdcc445086 | ||
|
|
36d7b3f7f3 | ||
|
|
b8d8e7df87 | ||
|
|
7c2f2344b8 | ||
|
|
33691621d8 | ||
|
|
93ed56dd38 | ||
|
|
54f47b8607 | ||
|
|
db2649be6f | ||
|
|
17a07de1f0 | ||
|
|
3aa6892bff | ||
|
|
92405c3561 | ||
|
|
cfc2658078 | ||
|
|
209feebb5d | ||
|
|
0fb7a94eb1 | ||
|
|
e80c0aaf85 | ||
|
|
8dda45da48 | ||
|
|
e5a85500b3 | ||
|
|
a4b8844c77 | ||
|
|
7696e5bcd8 | ||
|
|
486561789b | ||
|
|
28fc185a2a | ||
|
|
db59d0e138 | ||
|
|
1687f0a0c6 | ||
|
|
6ac6ad687e | ||
|
|
5e1e71b559 | ||
|
|
66b5e9a65d | ||
|
|
30b1a31bb3 | ||
|
|
d6765bd7d6 | ||
|
|
5ce3337977 | ||
|
|
c5e24c7409 | ||
|
|
34f029da9e | ||
|
|
e95414806c | ||
|
|
456b420321 | ||
|
|
0eb87ea44a | ||
|
|
454fabeb1d | ||
|
|
d3f022adc5 | ||
|
|
8efe3d2aa3 | ||
|
|
d3003d5797 | ||
|
|
0dd5f32f96 | ||
|
|
85b307b306 | ||
|
|
73eadbd1b1 | ||
|
|
b294c560e4 | ||
|
|
455d9dc532 | ||
|
|
df1049a436 | ||
|
|
ac22df8ced | ||
|
|
1fd987221d | ||
|
|
45985f2c44 | ||
|
|
159e11a508 | ||
|
|
5ff68533d7 | ||
|
|
61f20173a3 | ||
|
|
987f1ec42b | ||
|
|
3f4c39600b | ||
|
|
88ef490b1b | ||
|
|
dbc8b684c2 | ||
|
|
7e135d4643 | ||
|
|
06a8760701 | ||
|
|
5eaa035f1d | ||
|
|
ae1f0ceeb7 | ||
|
|
3ca82286b4 | ||
|
|
63add43c0f | ||
|
|
72440609b4 | ||
|
|
c946cd2405 | ||
|
|
96d316ebf5 | ||
|
|
b3eb7dceac | ||
|
|
ee02d002ba | ||
|
|
34448d27d0 | ||
|
|
39ce64edce | ||
|
|
0d210a607b | ||
|
|
f1a6302928 | ||
|
|
359db8f365 | ||
|
|
387a3e0ef2 | ||
|
|
4c957b347b | ||
|
|
10d3d5aeed | ||
|
|
6e365d4a23 | ||
|
|
68292a45d0 | ||
|
|
f0088cf7a0 | ||
|
|
b8469a242e | ||
|
|
8c5d5f1ee9 | ||
|
|
80759cacee | ||
|
|
4df2cc78ff | ||
|
|
8b34f7a5f9 | ||
|
|
e81d6eb877 | ||
|
|
0c47d7c21c | ||
|
|
175996235d | ||
|
|
42409d555e | ||
|
|
23b1a02d42 | ||
|
|
1be53368c6 | ||
|
|
52e0d28a4d | ||
|
|
83114a26f9 | ||
|
|
bdc09f86e6 | ||
|
|
6dd9679267 | ||
|
|
5fb7168d17 | ||
|
|
6ac4b6fb3d | ||
|
|
1a8de4344c | ||
|
|
1493725699 | ||
|
|
6cff94e2ca | ||
|
|
d51a70e259 | ||
|
|
7f84c0b9f0 | ||
|
|
df555ca93f | ||
|
|
b68b98b62c | ||
|
|
640dc741a4 | ||
|
|
5d02b839ee | ||
|
|
ae60a38445 | ||
|
|
83d4bd26c4 | ||
|
|
481b351276 | ||
|
|
23cb8c8ee5 | ||
|
|
9bcdd81772 | ||
|
|
53e443142d | ||
|
|
da72107eb9 | ||
|
|
d2cbb89eb9 | ||
|
|
3bf0c60378 | ||
|
|
59a9854ed5 | ||
|
|
b70bc09359 | ||
|
|
8ef82d01b1 | ||
|
|
ac683175a8 | ||
|
|
5e86967627 | ||
|
|
d971bb993f | ||
|
|
2328f2deff | ||
|
|
ddeb65abef | ||
|
|
0871d8ecbd | ||
|
|
3b8aec2fef | ||
|
|
71dc06728f | ||
|
|
9484ecb456 | ||
|
|
25319d1c8f | ||
|
|
e7fd54b443 | ||
|
|
aed2bc154b | ||
|
|
09a991669c | ||
|
|
41325bfa12 | ||
|
|
17a1d3718f | ||
|
|
8b9c5b8789 | ||
|
|
e11609c335 | ||
|
|
3a5b3c2d65 | ||
|
|
33916312b5 | ||
|
|
dab8a2679f | ||
|
|
390334c820 | ||
|
|
356ffc953e | ||
|
|
a2f3a5de61 | ||
|
|
635a3fbcbc | ||
|
|
7ebcb86924 | ||
|
|
eb1781e6af | ||
|
|
6df983642b | ||
|
|
634c4c9a97 | ||
|
|
9371c7d2ab | ||
|
|
f6678739ad | ||
|
|
2882dd25c6 | ||
|
|
ed3166d08f | ||
|
|
39340108f2 | ||
|
|
94f037b6af | ||
|
|
090e81a432 | ||
|
|
6e8ae9b320 | ||
|
|
0db094655e | ||
|
|
c437ed4e96 | ||
|
|
ed5a778e50 | ||
|
|
13633ce11a | ||
|
|
eab47657e6 | ||
|
|
35267e8b6d | ||
|
|
15cc9cef4a | ||
|
|
f5e1e7267f | ||
|
|
c6eaa18a1f | ||
|
|
63e16e4332 | ||
|
|
074e41de59 | ||
|
|
46b6686f4b | ||
|
|
ede94708d5 | ||
|
|
f20953c8d2 | ||
|
|
1e728593ea | ||
|
|
21f7fcc6d2 | ||
|
|
20c026aa56 | ||
|
|
24eb824163 | ||
|
|
2065afe852 | ||
|
|
d60480d1ae | ||
|
|
1be0f9090e | ||
|
|
f9dad0a183 | ||
|
|
597f1f0c0b | ||
|
|
c26728d24a | ||
|
|
4c1e11692a | ||
|
|
132a21e113 | ||
|
|
324fcb71a9 | ||
|
|
55111201e6 | ||
|
|
067040d92f | ||
|
|
0d857d716a | ||
|
|
ec9b0b22b0 | ||
|
|
32cf9ed185 | ||
|
|
1702e086a3 | ||
|
|
8246818a94 | ||
|
|
e1ee00a331 | ||
|
|
6e22cb5e58 | ||
|
|
f4ebb3d547 | ||
|
|
5683fbc3cb | ||
|
|
0c78f1c7e2 | ||
|
|
5051bbdded | ||
|
|
c7b86856fb | ||
|
|
afcf72e29f | ||
|
|
a83482179a | ||
|
|
1e7a198dec | ||
|
|
41646985e4 | ||
|
|
80f6c7b1fb | ||
|
|
cc9465f141 | ||
|
|
d56c472e77 | ||
|
|
68e5daae13 | ||
|
|
b259f30093 | ||
|
|
804f5df55f | ||
|
|
96ec7000fa | ||
|
|
bf6d6af2ec | ||
|
|
264aaa25f2 | ||
|
|
98eaddca5b | ||
|
|
e8881fb8be | ||
|
|
2824ede5f2 | ||
|
|
218f774f5b | ||
|
|
98926ae7c2 | ||
|
|
c2583e5bc9 | ||
|
|
6bf083cc89 | ||
|
|
c88d5984ef | ||
|
|
e328258071 | ||
|
|
37a16b54e1 | ||
|
|
4729fa457c | ||
|
|
4b37fd4536 | ||
|
|
ec5cfcc6c5 | ||
|
|
b41846a51e | ||
|
|
5b866377a9 | ||
|
|
febba2752c | ||
|
|
71f733817a | ||
|
|
82d2885d99 | ||
|
|
dad7ddc1f2 | ||
|
|
ee9c2c8eb8 | ||
|
|
6c9b11a105 | ||
|
|
afa52f7e54 | ||
|
|
f2865b9145 | ||
|
|
0514f4fdfa | ||
|
|
170a2e40e1 | ||
|
|
5849e89ac6 | ||
|
|
bc6cccfe03 | ||
|
|
53d1890a4a | ||
|
|
4a93c4dce4 | ||
|
|
be79d8bb3f | ||
|
|
97df3bee41 | ||
|
|
e1c4e792f1 | ||
|
|
230d07cbde | ||
|
|
92f848bf21 | ||
|
|
ae8aeede1b | ||
|
|
2db12dcaac | ||
|
|
9672b542b8 | ||
|
|
e6a8946b65 | ||
|
|
3b0ccb5424 | ||
|
|
0c54ef578f | ||
|
|
4ee0f74361 | ||
|
|
362850c2de | ||
|
|
42c41345a0 | ||
|
|
301208cf99 | ||
|
|
b173e27f70 | ||
|
|
1bdbec963b | ||
|
|
80cf4acf01 | ||
|
|
fdd5cbd2e5 | ||
|
|
38e398598f | ||
|
|
0b01d223ae | ||
|
|
c4fd257b42 | ||
|
|
e7ee8749c8 | ||
|
|
fb21d171ff | ||
|
|
a6905313c3 | ||
|
|
7e8e5bc2e5 | ||
|
|
c84259e67a | ||
|
|
63ccd5adc1 | ||
|
|
2d7bc42a9e | ||
|
|
0c6077d287 | ||
|
|
cf24936bc9 | ||
|
|
d0134192c6 | ||
|
|
f7609dca3d | ||
|
|
4add4409eb | ||
|
|
2b5bf09514 | ||
|
|
8ea1308c77 | ||
|
|
faba796303 | ||
|
|
04715e4503 | ||
|
|
39b77e43d7 | ||
|
|
b6a99dda83 | ||
|
|
ff25524d32 | ||
|
|
d37228e68e | ||
|
|
779c1a9627 | ||
|
|
f6d8ca87f1 | ||
|
|
1903f74764 | ||
|
|
181d39a9f9 | ||
|
|
f16b211a88 | ||
|
|
71f3abcf3e | ||
|
|
32197643ef | ||
|
|
d84a02d659 | ||
|
|
5d2b52cfaf | ||
|
|
3d09abb518 | ||
|
|
a69c0b6d00 | ||
|
|
c7185bf3d7 | ||
|
|
920895c583 | ||
|
|
f277ca758d | ||
|
|
afffd17485 | ||
|
|
3b83a59b1a | ||
|
|
f92dab550d | ||
|
|
7b8a160cc9 | ||
|
|
44f40373cc | ||
|
|
2ab3dbf861 | ||
|
|
be10cefba7 | ||
|
|
3d0b6cf864 | ||
|
|
af9ceda816 | ||
|
|
ec58964032 | ||
|
|
b3f7cccae4 | ||
|
|
73ff9f585b | ||
|
|
1c81efd4b6 | ||
|
|
bdda5dcf01 | ||
|
|
cd2cd18eea | ||
|
|
a7810cf774 | ||
|
|
b17b119a59 | ||
|
|
eb60d1bc5d | ||
|
|
3c09f2186d | ||
|
|
9ef0d8b59b | ||
|
|
9aa4150b9a | ||
|
|
0c5cac1c8c | ||
|
|
a52997fbd4 | ||
|
|
b6d68f6b3d | ||
|
|
5e750a4f40 | ||
|
|
0b81a0dbf5 | ||
|
|
12996de95a | ||
|
|
7995635034 | ||
|
|
b4e176a1a6 | ||
|
|
ce982e38d0 | ||
|
|
6c1f49838b | ||
|
|
3739a17b78 | ||
|
|
a1ecc12042 | ||
|
|
e78f2d64a4 | ||
|
|
6991713112 | ||
|
|
60fc1d135d | ||
|
|
89cc24fb81 | ||
|
|
1243e09dfe | ||
|
|
0dff5d89c0 | ||
|
|
b3cc8e3054 | ||
|
|
b89f8a33f1 | ||
|
|
5279a1f6b7 | ||
|
|
6f853d0248 | ||
|
|
bf8aec914e | ||
|
|
e190a66f6e | ||
|
|
dec58cba25 | ||
|
|
a7743cfe06 | ||
|
|
7e2f6b212f | ||
|
|
eb2665b034 | ||
|
|
e5eb58aee6 | ||
|
|
cb820fd2e9 | ||
|
|
d3bc063e39 | ||
|
|
fe25985eec | ||
|
|
361d5da6a4 | ||
|
|
7fc06a5534 | ||
|
|
98bcf78fc7 | ||
|
|
fc42613625 | ||
|
|
62419f0fe4 | ||
|
|
ad6bd96af6 | ||
|
|
cad1fe590c | ||
|
|
f911ae9d1c | ||
|
|
8c05c1c2f3 | ||
|
|
b2a92d9003 | ||
|
|
44d15b5ed8 | ||
|
|
6468aaff87 | ||
|
|
cbf8663997 | ||
|
|
388a605937 | ||
|
|
80cffc5fbf | ||
|
|
0e66802c32 | ||
|
|
49da696a0b | ||
|
|
517638a44e | ||
|
|
abcd00cbad | ||
|
|
3a697b94b1 | ||
|
|
807e5fb2f6 | ||
|
|
4e74a157d7 | ||
|
|
4ffde19997 | ||
|
|
40f3713130 | ||
|
|
81d4921bf0 | ||
|
|
5a508636a0 | ||
|
|
77b7fd960d | ||
|
|
c2a1f25d8d | ||
|
|
1ec3e78ebe | ||
|
|
27c071dca4 | ||
|
|
7432f59a81 | ||
|
|
4da174c5de | ||
|
|
78f894660b | ||
|
|
3ed1b9aaaf | ||
|
|
df6175fb75 | ||
|
|
dbf176ff2c | ||
|
|
f9398f8707 | ||
|
|
fa05ca2bf8 | ||
|
|
25dcd4f009 | ||
|
|
1ac1f01c41 | ||
|
|
85a64bdd2e | ||
|
|
911f1c6156 | ||
|
|
70f937dc74 | ||
|
|
191dd7c148 | ||
|
|
caf62699f1 | ||
|
|
344dafdc88 | ||
|
|
4b79059809 | ||
|
|
24d8a157b3 | ||
|
|
05551f1f14 | ||
|
|
d075a1c24b | ||
|
|
82a9fac4cb | ||
|
|
1cb4a94772 | ||
|
|
da84c544c4 | ||
|
|
0c1959dfb8 | ||
|
|
5240a4bbe9 | ||
|
|
5277f71dd9 | ||
|
|
a1610d15a9 | ||
|
|
6a9d92e1f3 | ||
|
|
6808bfddc7 | ||
|
|
062b81d537 | ||
|
|
48f5c58a3f | ||
|
|
943b78def4 | ||
|
|
2a5f47da36 | ||
|
|
0bdd010b2b | ||
|
|
576051dae3 | ||
|
|
fc70a24b41 | ||
|
|
84504ab0d2 | ||
|
|
f2a8672c22 | ||
|
|
65c6319e87 | ||
|
|
78e3957541 | ||
|
|
37fcd15f06 | ||
|
|
3244499fc6 | ||
|
|
07c25cb9af | ||
|
|
c9592ba954 | ||
|
|
34a479c1f6 | ||
|
|
f34cc2cd44 | ||
|
|
90beeda177 | ||
|
|
2dc74af290 | ||
|
|
f488019790 | ||
|
|
7fe7557f7c | ||
|
|
feca2bbe0a | ||
|
|
179f9737be | ||
|
|
c2653c96a7 | ||
|
|
d83a36206d | ||
|
|
6cdcbb5f5f | ||
|
|
8337e9ac94 | ||
|
|
18fd991175 | ||
|
|
cb3e953be3 | ||
|
|
d45d2c5922 | ||
|
|
3f0e1d947a | ||
|
|
55a475a322 | ||
|
|
e4c703b877 | ||
|
|
f8496edcc4 | ||
|
|
59cb4638ce | ||
|
|
8a7426ae02 | ||
|
|
3d5ced89c6 | ||
|
|
6334fe5696 | ||
|
|
cf280b0a9d | ||
|
|
7f3f4f5dfe | ||
|
|
f8e13c9fea | ||
|
|
e23b843375 | ||
|
|
922f65cf81 | ||
|
|
cee1d0f6cb | ||
|
|
0a3124e90f | ||
|
|
6065329c58 | ||
|
|
d521b8a99f | ||
|
|
bb67309a7c | ||
|
|
fe190a83ea | ||
|
|
ed0bcd7fee | ||
|
|
18df30c09a | ||
|
|
17ef03d05d | ||
|
|
2bf804d7c7 | ||
|
|
04da6d92d3 | ||
|
|
2e3d22baba | ||
|
|
758d35c298 | ||
|
|
f6323229f9 | ||
|
|
28a04770bc | ||
|
|
0a417bec49 | ||
|
|
2423bd45da | ||
|
|
0155624c86 | ||
|
|
1d310b2d9a | ||
|
|
231cf8e836 | ||
|
|
f55c30b21e | ||
|
|
e9f0490ed1 | ||
|
|
f9ab98159d | ||
|
|
ec9746097b | ||
|
|
067e47a99e | ||
|
|
2e760c7473 | ||
|
|
b3449fcf2a | ||
|
|
009f3c13ae | ||
|
|
06c31b3922 | ||
|
|
e9657fec00 | ||
|
|
64f2c8f9f6 | ||
|
|
db374b6d84 | ||
|
|
9056a91857 | ||
|
|
a8d94c3707 | ||
|
|
02243fdf4b | ||
|
|
810bf65030 | ||
|
|
207711b980 | ||
|
|
dce0748b43 | ||
|
|
bd299a0b84 | ||
|
|
454bb610af | ||
|
|
134752f94c | ||
|
|
92c01d1eaa | ||
|
|
4333d6a44f | ||
|
|
34440de0d5 | ||
|
|
b3e352d51d | ||
|
|
3ca2235ca1 | ||
|
|
453586a634 | ||
|
|
4313ba2c94 | ||
|
|
295683ced1 | ||
|
|
b05b11aa06 | ||
|
|
ab7e5d01f5 | ||
|
|
e206e2b956 | ||
|
|
af6cc538da | ||
|
|
8e83da9138 | ||
|
|
7811bdd509 | ||
|
|
63dc848857 | ||
|
|
2d6a9afc80 | ||
|
|
ffb12bc89d | ||
|
|
c08db04472 | ||
|
|
63dec8a96d | ||
|
|
92b01943a0 | ||
|
|
00196be0e7 | ||
|
|
106fa5ed0f | ||
|
|
42074567e8 | ||
|
|
eed4a316da | ||
|
|
feb80d4203 | ||
|
|
89c2e95dd0 | ||
|
|
142896bc0e | ||
|
|
a5f4ab9ad2 | ||
|
|
a99fbbb6c4 | ||
|
|
24f5018861 | ||
|
|
90f6c52469 | ||
|
|
66f603e2ca | ||
|
|
937bb70b08 | ||
|
|
ae509ae8ea | ||
|
|
8beddf655a | ||
|
|
3486112d1c | ||
|
|
7cadd05c5a | ||
|
|
3b19485d44 | ||
|
|
37c000b8dc | ||
|
|
8835d38fd3 | ||
|
|
4bc5ed6558 | ||
|
|
b96d409a00 | ||
|
|
da2e24f4e6 | ||
|
|
80ca4185ed | ||
|
|
c1982596d5 | ||
|
|
f6d3728bd1 | ||
|
|
b0590afc29 | ||
|
|
703c1be7c3 | ||
|
|
416c160667 | ||
|
|
d9e1943c51 | ||
|
|
52a8cd2e75 | ||
|
|
4d712a308c | ||
|
|
b81d179f27 | ||
|
|
083aa87d0a | ||
|
|
420db2dd88 | ||
|
|
5a03c2b426 | ||
|
|
a227d65aa4 | ||
|
|
5236638322 | ||
|
|
16fd293290 | ||
|
|
2d98a776c4 | ||
|
|
be784074ee | ||
|
|
7aedb82dcf | ||
|
|
e93031427a | ||
|
|
5f98df491d | ||
|
|
8970d62413 | ||
|
|
ba5224a3b7 | ||
|
|
934f5afa2a | ||
|
|
8f331e6078 | ||
|
|
e8b51cc5e3 | ||
|
|
1bcf1baa3e | ||
|
|
9c04ca7bf1 | ||
|
|
517e67aeab | ||
|
|
13d8db6ed2 | ||
|
|
2cf2d240f6 | ||
|
|
71c297d345 | ||
|
|
4b718b3cf6 | ||
|
|
6375c816ac | ||
|
|
df081ae3a4 | ||
|
|
dba194c52e | ||
|
|
493f56c28a | ||
|
|
668c37c00c | ||
|
|
7897ef596a | ||
|
|
09b8f75a59 | ||
|
|
ef4f1518cb | ||
|
|
71a3686846 | ||
|
|
c761949d68 | ||
|
|
b9bee01681 | ||
|
|
586985c45f | ||
|
|
da4e495a33 | ||
|
|
88652020ca | ||
|
|
5bd9e7ff9f | ||
|
|
c87ea60b37 | ||
|
|
60f5c23de4 | ||
|
|
57083d03fa | ||
|
|
18f5666daf | ||
|
|
43e746de6f | ||
|
|
3cc0f89dbd | ||
|
|
a9013204d6 | ||
|
|
eb1a124292 | ||
|
|
4db0ef4897 | ||
|
|
21cbfb18d4 | ||
|
|
cfe27ed599 | ||
|
|
7dafd5d9c2 | ||
|
|
76c037dd72 | ||
|
|
053a20f285 | ||
|
|
ae33741452 | ||
|
|
5c55b58278 | ||
|
|
a692a2dc66 | ||
|
|
4e97210422 | ||
|
|
4816db4791 | ||
|
|
66af7cfe07 | ||
|
|
56f3eb98a4 | ||
|
|
3ebff5d4f9 | ||
|
|
f842364c1f | ||
|
|
3d5bc801ca | ||
|
|
d04476d874 | ||
|
|
34444db544 |
+4
-117
@@ -1,119 +1,6 @@
|
||||
// ! If we don't have this file, running ESLint will fail because of the import/no-unused-modules rule
|
||||
// ! See: https://github.com/import-js/eslint-plugin-import/issues/3079
|
||||
// ! When this issue is resolved, this file can be removed
|
||||
{
|
||||
"env": {
|
||||
"commonjs": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "commonjs"
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"airbnb",
|
||||
"plugin:cypress/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.ts"],
|
||||
"env": {
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"airbnb-typescript",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2021,
|
||||
"sourceType": "module",
|
||||
"project": true
|
||||
},
|
||||
"rules": {
|
||||
"import/prefer-default-export": "off",
|
||||
"@typescript-eslint/lines-between-class-members": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-shadow": "off",
|
||||
"@typescript-eslint/return-await": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["services/backend/src/rapodiff/**/*"],
|
||||
"rules": {
|
||||
"no-console": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"camelcase": "off",
|
||||
"consistent-return": "off",
|
||||
"cypress/no-assigning-return-values": "off",
|
||||
"cypress/unsafe-to-chain-command": "off",
|
||||
"default-param-last": "off",
|
||||
"id-denylist": ["error", "c", "d", "e", "err", "t"],
|
||||
"import/extensions": ["error", "never"],
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
"alphabetize": { "order": "asc", "caseInsensitive": true },
|
||||
"groups": [["builtin", "external"], ["internal"], ["parent"], ["sibling", "index"]],
|
||||
"pathGroups": [
|
||||
{
|
||||
"pattern": "../../../**",
|
||||
"group": "internal",
|
||||
"position": "before"
|
||||
},
|
||||
{
|
||||
"pattern": "../../**",
|
||||
"group": "internal",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "../**",
|
||||
"group": "parent",
|
||||
"position": "before"
|
||||
},
|
||||
{
|
||||
"pattern": "./**",
|
||||
"group": "sibling",
|
||||
"position": "before"
|
||||
}
|
||||
],
|
||||
"pathGroupsExcludedImportTypes": ["builtin", "external"]
|
||||
}
|
||||
],
|
||||
"no-async-promise-executor": "off",
|
||||
"no-await-in-loop": "off",
|
||||
"no-console": "error",
|
||||
"no-continue": "off",
|
||||
"no-param-reassign": "off",
|
||||
"no-plusplus": "off",
|
||||
"no-promise-executor-return": "off",
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"selector": "ForInStatement",
|
||||
"message": "for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array."
|
||||
},
|
||||
"LabeledStatement",
|
||||
"WithStatement"
|
||||
],
|
||||
"no-return-await": "off",
|
||||
"no-shadow": "off",
|
||||
"prettier/prettier": "error",
|
||||
"quotes": ["error", "single", { "avoidEscape": true }]
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"extensions": [".js", ".jsx", ".ts", ".tsx"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ignorePatterns": ["build/", "dist/", "instrumented/", "node_modules/", "**/*migrations*/**"]
|
||||
"ignorePatterns": ["**/node_modules/", "**/dist/", "**/build/"]
|
||||
}
|
||||
|
||||
@@ -18,4 +18,4 @@ runs:
|
||||
run: npm run stylelint "services/frontend/src/**/*.css"
|
||||
- name: 'Run ESLint for JS and TS files'
|
||||
shell: bash
|
||||
run: npm run eslint -- --ext .js,.jsx,.ts,.tsx services --report-unused-disable-directives
|
||||
run: npm run eslint -- services
|
||||
|
||||
@@ -11,6 +11,9 @@ inputs:
|
||||
spec:
|
||||
required: true
|
||||
description: 'Path to Cypress file'
|
||||
test_name:
|
||||
required: true
|
||||
description: 'Name of the test'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -35,10 +38,10 @@ runs:
|
||||
CYPRESS_baseUrl: http://localhost:1337
|
||||
|
||||
- name: 'Upload video if tests fail'
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: Cypress videos
|
||||
name: 'Cypress videos - ${{ inputs.test_name }}'
|
||||
path: ./cypress/videos/*
|
||||
|
||||
- name: 'Print logs on failure'
|
||||
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
run: docker cp frontend:/opt/app-root/src/build ./build
|
||||
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v1
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_URL: https://toska.cs.helsinki.fi/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
run: docker cp backend:/opt/app-root/src/dist ./dist
|
||||
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v1
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_URL: https://toska.cs.helsinki.fi/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
|
||||
|
||||
@@ -14,6 +14,7 @@ on:
|
||||
|
||||
jobs:
|
||||
lint_backend_and_frontend:
|
||||
name: 'Lint backend and frontend'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -40,8 +41,8 @@ jobs:
|
||||
spec: cypress/e2e/Students.js
|
||||
- name: 'Test completed courses'
|
||||
spec: cypress/e2e/Completed_courses.js
|
||||
- name: 'Test studyprogramme overview'
|
||||
spec: cypress/e2e/Studyprogramme_overview.js
|
||||
- name: 'Test study programme overview'
|
||||
spec: cypress/e2e/Study_programme_overview.js
|
||||
- name: 'Test teachers and users'
|
||||
spec: |
|
||||
cypress/e2e/Teachers.js
|
||||
@@ -54,8 +55,6 @@ jobs:
|
||||
spec: cypress/e2e/Language_center.js
|
||||
- name: 'Test study guidance groups'
|
||||
spec: cypress/e2e/Study_guidance_groups.js
|
||||
- name: 'Test evaluation overview'
|
||||
spec: cypress/e2e/Evaluation_overview.js
|
||||
- name: 'Test university'
|
||||
spec: cypress/e2e/University.js
|
||||
name: ${{ matrix.name }}
|
||||
@@ -67,3 +66,4 @@ jobs:
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
spec: ${{ matrix.spec }}
|
||||
test_name: ${{ matrix.name }}
|
||||
|
||||
+47
-29
@@ -38,9 +38,12 @@ jobs:
|
||||
push: false
|
||||
tags: oodikone-backend:staging
|
||||
outputs: type=docker,dest=/tmp/backend.tar
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
- name: 'Upload image for other jobs'
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-image
|
||||
path: /tmp/backend.tar
|
||||
|
||||
build_frontend:
|
||||
@@ -65,18 +68,37 @@ jobs:
|
||||
BASE_PATH=/oodikone
|
||||
STAGING=true
|
||||
|
||||
build_and_push_anon_sis_db:
|
||||
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
|
||||
check_migration_files:
|
||||
name: Check if new migrations have been added
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
kone_db_migrations: ${{ steps.check_migrations.outputs.kone_db_migrations }}
|
||||
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
|
||||
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
- name: Check if new migrations have been added
|
||||
id: check_migrations
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
migrations:
|
||||
kone_db_migrations:
|
||||
- 'services/backend/src/database/migrations_kone/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
user_db_migrations:
|
||||
- 'services/backend/src/database/migrations_user/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
sis_db_migrations:
|
||||
- 'updater/sis-updater-worker/src/db/migrations/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
|
||||
build_and_push_anon_sis_db:
|
||||
name: 'Build anon sis-db and push to toska hub if new migrations have been added'
|
||||
needs: check_migration_files
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -84,7 +106,6 @@ jobs:
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
- name: 'Let sis-updater-worker run migrations, then push new version of image to toska hub'
|
||||
if: steps.check_migrations.outputs.migrations == 'true'
|
||||
run: |
|
||||
docker compose --file docker-compose.ci.yml up --detach sis-updater-worker
|
||||
sleep 30 # wait for sis-updater-worker to start and run migrations
|
||||
@@ -95,22 +116,17 @@ jobs:
|
||||
needs:
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- check_migration_files
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: check_migrations
|
||||
- name: 'Download backend image'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
filters: |
|
||||
migrations:
|
||||
- 'services/backend/src/database/migrations_kone/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
- name: 'Download images'
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: /tmp/
|
||||
name: backend-image
|
||||
- name: 'Load built backend image'
|
||||
run: docker load --input /tmp/artifact/backend.tar
|
||||
run: docker load --input backend.tar
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -118,10 +134,9 @@ jobs:
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
- name: 'Let backend run migrations, then push new version of image to toska hub'
|
||||
if: steps.check_migrations.outputs.migrations == 'true'
|
||||
run: |
|
||||
docker compose --file docker-compose.ci.yml up --detach backend
|
||||
sleep 30 # wait for sis-updater-worker to start and run migrations
|
||||
sleep 30 # wait for backend to start and run migrations
|
||||
./anonyymioodi/commit_and_push_to_toska_hub.sh kone-db
|
||||
|
||||
build_and_push_anon_user_db:
|
||||
@@ -129,7 +144,9 @@ jobs:
|
||||
needs:
|
||||
- build_backend
|
||||
- build_frontend
|
||||
- check_migration_files
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
@@ -139,12 +156,12 @@ jobs:
|
||||
migrations:
|
||||
- 'services/backend/src/database/migrations_user/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
- name: 'Download images'
|
||||
uses: actions/download-artifact@v3
|
||||
- name: 'Download backend image'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/
|
||||
name: backend-image
|
||||
- name: 'Load built backend image'
|
||||
run: docker load --input /tmp/artifact/backend.tar
|
||||
run: docker load --input backend.tar
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -152,10 +169,9 @@ jobs:
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
- name: 'Let backend run migrations, then push new version of image to toska hub'
|
||||
if: steps.check_migrations.outputs.migrations == 'true'
|
||||
run: |
|
||||
docker compose --file docker-compose.ci.yml up --detach backend
|
||||
sleep 30 # wait for sis-updater-worker to start and run migrations
|
||||
sleep 30 # wait for backend to start and run migrations
|
||||
./anonyymioodi/commit_and_push_to_toska_hub.sh user-db
|
||||
|
||||
# === End-to-end tests ===
|
||||
@@ -165,6 +181,8 @@ jobs:
|
||||
- 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()
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -181,8 +199,8 @@ jobs:
|
||||
spec: cypress/e2e/Students.js
|
||||
- name: 'Test completed courses'
|
||||
spec: cypress/e2e/Completed_courses.js
|
||||
- name: 'Test studyprogramme overview'
|
||||
spec: cypress/e2e/Studyprogramme_overview.js
|
||||
- name: 'Test study_programme overview'
|
||||
spec: cypress/e2e/Study_programme_overview.js
|
||||
- name: 'Test teachers and users'
|
||||
spec: |
|
||||
cypress/e2e/Teachers.js
|
||||
@@ -195,8 +213,6 @@ jobs:
|
||||
spec: cypress/e2e/Language_center.js
|
||||
- name: 'Test study guidance groups'
|
||||
spec: cypress/e2e/Study_guidance_groups.js
|
||||
- name: 'Test evaluation overview'
|
||||
spec: cypress/e2e/Evaluation_overview.js
|
||||
- name: 'Test university'
|
||||
spec: cypress/e2e/University.js
|
||||
name: ${{ matrix.name }}
|
||||
@@ -208,6 +224,7 @@ jobs:
|
||||
username: ${{ secrets.username }}
|
||||
password: ${{ secrets.password }}
|
||||
spec: ${{ matrix.spec }}
|
||||
test_name: ${{ matrix.name }}
|
||||
|
||||
# === Other tests ===
|
||||
test_service_exit_statuses:
|
||||
@@ -216,6 +233,7 @@ jobs:
|
||||
- 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@v4
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: 'Install dependencies'
|
||||
run: npm ci && cd updater/sis-updater-scheduler && npm ci && cd ../sis-updater-worker && npm ci
|
||||
- name: 'Run linter'
|
||||
run: npm run eslint updater -- --report-unused-disable-directives
|
||||
run: npm run eslint -- updater
|
||||
|
||||
build_worker:
|
||||
name: 'Build Updater worker'
|
||||
@@ -42,9 +42,12 @@ jobs:
|
||||
push: false
|
||||
outputs: type=docker,dest=/tmp/worker.tar
|
||||
tags: toska/sis-updater-worker:latest
|
||||
- name: 'Store built image'
|
||||
uses: actions/upload-artifact@v3
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
- name: 'Upload image for other jobs'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: updater-worker-image
|
||||
path: /tmp/worker.tar
|
||||
|
||||
build_scheduler:
|
||||
@@ -60,9 +63,12 @@ jobs:
|
||||
push: false
|
||||
outputs: type=docker,dest=/tmp/scheduler.tar
|
||||
tags: toska/sis-updater-scheduler:latest
|
||||
- name: 'Store built image'
|
||||
uses: actions/upload-artifact@v3
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
- name: 'Upload image for other jobs'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: updater-scheduler-image
|
||||
path: /tmp/scheduler.tar
|
||||
|
||||
test_updater:
|
||||
@@ -81,13 +87,14 @@ jobs:
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
- name: 'Download built images'
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/
|
||||
pattern: 'updater-{worker,scheduler}-image'
|
||||
merge-multiple: true
|
||||
- name: 'Load worker image'
|
||||
run: docker load --input /tmp/artifact/worker.tar
|
||||
run: docker load --input worker.tar
|
||||
- name: 'Load scheduler image'
|
||||
run: docker load --input /tmp/artifact/scheduler.tar
|
||||
run: docker load --input scheduler.tar
|
||||
- name: 'Start worker'
|
||||
run: docker compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker
|
||||
- name: 'Run hourly jobs'
|
||||
@@ -128,6 +135,9 @@ jobs:
|
||||
context: updater/sis-updater-worker
|
||||
containerfiles: |
|
||||
updater/sis-updater-worker/Dockerfile
|
||||
build-args: |
|
||||
SENTRY_RELEASE=${{ github.sha }}
|
||||
SENTRY_ENVIRONMENT=production
|
||||
|
||||
- name: Push to quay.io
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
@@ -155,6 +165,9 @@ jobs:
|
||||
context: updater/sis-updater-scheduler
|
||||
containerfiles: |
|
||||
updater/sis-updater-scheduler/Dockerfile
|
||||
build-args: |
|
||||
SENTRY_RELEASE=${{ github.sha }}
|
||||
SENTRY_ENVIRONMENT=production
|
||||
|
||||
- name: Push to quay.io
|
||||
uses: redhat-actions/push-to-registry@v2
|
||||
|
||||
@@ -70,3 +70,4 @@ environments/
|
||||
|
||||
# Target of shared symlink
|
||||
services/**/src/shared
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ const relativeFilePaths = files => [...files.map(file => path.relative(cwd, file
|
||||
module.exports = {
|
||||
'{services,updater}/**/*.{js,jsx,ts,tsx}': files =>
|
||||
`eslint --fix ${files.join(' ')} --report-unused-disable-directives`,
|
||||
'services/backend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
|
||||
'services/frontend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/frontend/tsconfig.json',
|
||||
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files => `prettier --write ${files.join(' ')}`,
|
||||
'*.css': files => `stylelint --fix ${files.join(' ')}`,
|
||||
Dockerfile: files =>
|
||||
|
||||
@@ -70,12 +70,9 @@ graph TB
|
||||
end
|
||||
server -->|Read| sisdb[(sis-db)]
|
||||
subgraph Updater
|
||||
updaterscheduler[Scheduler] --> |Push jobs| nats
|
||||
nats(NATS server)
|
||||
updaterworker --> |Fetch jobs| nats
|
||||
updaterscheduler[Scheduler] --> |Push BullMQ jobs| redis(Redis)
|
||||
updaterworker[Worker] -->|Write| sisdb
|
||||
updaterscheduler --> redis(Redis)
|
||||
updaterworker --> redis
|
||||
updaterworker --> |Fetch BullMQ jobs| redis
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
|
||||
sis-db:
|
||||
<<: *defaults
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
shm_size: 2gb
|
||||
|
||||
sis-importer-db:
|
||||
@@ -28,7 +28,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-importer-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
|
||||
user-db:
|
||||
<<: *defaults
|
||||
@@ -37,4 +37,4 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable global-require */
|
||||
/* eslint-disable import/extensions */
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
/* eslint-disable import/no-commonjs */
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
|
||||
/* eslint-disable import/no-unused-modules */
|
||||
const { defineConfig } = require('cypress')
|
||||
|
||||
module.exports = defineConfig({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const openCompletedCoursesModal = () => {
|
||||
cy.get('button').contains('Search completed courses of students').click()
|
||||
cy.contains('button', 'Search completed courses of students').click()
|
||||
}
|
||||
|
||||
const hasLanded = () => {
|
||||
@@ -15,22 +15,40 @@ const openSearch = () => {
|
||||
cy.init('/completedcoursessearch')
|
||||
hasLanded()
|
||||
}
|
||||
|
||||
const selectSavedCourselist = name => {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(name).type('{enter}')
|
||||
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}')
|
||||
}
|
||||
|
||||
const deleteAllSearches = () => {
|
||||
cy.contains('Saved courselists')
|
||||
.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).type('{enter}')
|
||||
cy.get('button').contains('Delete').click()
|
||||
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()
|
||||
}
|
||||
|
||||
const generateCourseListName = () => `TEST-course-list-${new Date().getTime()}`
|
||||
|
||||
const deleteAllPreviousSearches = () => {
|
||||
cy.get('[data-cy=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()
|
||||
if (index < $list.length - 1) {
|
||||
cy.get('[data-cy=history-search]').click()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
cy.contains('You have no previous searches.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('When search modal is opened', () => {
|
||||
@@ -39,8 +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()
|
||||
deleteAllSearches()
|
||||
cy.get('[data-cy=open-completed-courses-modal-button]').click()
|
||||
})
|
||||
|
||||
it('Modal opens correctly', () => {
|
||||
@@ -49,26 +66,33 @@ describe('When search modal is opened', () => {
|
||||
openCompletedCoursesModal()
|
||||
cy.contains('Search completed courses of students')
|
||||
cy.contains('Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.')
|
||||
cy.contains('Insert one or more courses, separated by a space, a newline, or a comma.')
|
||||
cy.contains('Insert one or more courses, separated by a space, a newline, a comma, or a semicolon.')
|
||||
cy.contains('Insert name for this course list if you wish to save it')
|
||||
})
|
||||
|
||||
it('Modal gets the correct course codes and student numbers from the URL', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
describe('When a search is executed with invalid input', () => {
|
||||
it('Returns empty table and notification', () => {
|
||||
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.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(
|
||||
'contain.text',
|
||||
'The following students information could not be displayed'
|
||||
'The information for the following students could not be displayed'
|
||||
)
|
||||
cy.get('[data-cy="rights-notification"]').should('contain.text', '1')
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', 'Completed courses search')
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', 'Student number')
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('not.contain', '1')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,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.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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,63 +119,61 @@ describe('When search modal is opened', () => {
|
||||
|
||||
it('Finds correct students and courses', () => {
|
||||
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[0])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[1])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[2])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[3])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[4])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[0])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[1])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[2])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[3])
|
||||
for (const studentNumber of studentSet1) {
|
||||
cy.contains('[data-cy=completed-courses-table-div]', studentNumber)
|
||||
}
|
||||
for (const courseCode of coursesSet1) {
|
||||
cy.contains('[data-cy=completed-courses-table-div]', courseCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const courseListNames = ['TEST-courselist1', 'TEST-courselist2']
|
||||
|
||||
describe('Courselist saving-related functions work', () => {
|
||||
beforeEach(() => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy="course-list-input"]').type('CSM14204\nTKT10004')
|
||||
cy.get('[data-cy="search-name"]').type(courseListNames[0])
|
||||
cy.get('button').contains('Save').click()
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('[data-cy=history-search]').length > 0) {
|
||||
deleteAllPreviousSearches()
|
||||
} else {
|
||||
cy.contains('You have no previous searches.')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('Course list can be saved', () => {
|
||||
selectSavedCourselist(courseListNames[0])
|
||||
const courseList = generateCourseListName()
|
||||
const courses = ['CSM14204', 'TKT10004']
|
||||
createCourseList(courses, courseList)
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseListNames[0])
|
||||
cy.get('[data-cy="course-list-input"]').contains('TKT10004').contains('CSM14204')
|
||||
selectSavedCourselist(courseList)
|
||||
cy.contains('[data-cy=course-list-input]', courses.join(', '))
|
||||
})
|
||||
|
||||
it('Course list can be deleted', () => {
|
||||
const courseList = generateCourseListName()
|
||||
createCourseList(['CSM14204', 'TKT10004'], courseList)
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy="search-name"]').type(courseListNames[1])
|
||||
cy.get('button').contains('Save').click()
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseListNames[1])
|
||||
cy.get('button').contains('Delete').click()
|
||||
cy.get('[data-cy="history-search"]')
|
||||
.type(courseListNames[1])
|
||||
.type('{enter}')
|
||||
.should('not.contain', courseListNames[1])
|
||||
selectSavedCourselist(courseList)
|
||||
cy.get('[data-cy=delete-courselist]').click()
|
||||
cy.contains('You have no previous searches.')
|
||||
})
|
||||
|
||||
it('Course list can be updated', () => {
|
||||
const courseList = generateCourseListName()
|
||||
createCourseList(['CSM14204', 'TKT10004'], courseList)
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseListNames[0])
|
||||
cy.get('[data-cy="course-list-input"]').type(',TKT10001')
|
||||
cy.get('button').contains('Save').click()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.get('[data-cy=course-list-input]').type(',TKT10001')
|
||||
cy.get('[data-cy=save-courselist]').click()
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseListNames[0])
|
||||
cy.get('[data-cy="course-list-input"]').contains('TKT10001')
|
||||
selectSavedCourselist(courseList)
|
||||
cy.contains('[data-cy=course-list-input]', 'CSM14204, TKT10004, TKT10001')
|
||||
})
|
||||
})
|
||||
|
||||
+872
-315
File diff suppressed because it is too large
Load Diff
@@ -1,514 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const { getEmptyYears } = require('../support/commands')
|
||||
|
||||
describe('Evaluation overview', () => {
|
||||
describe('Programme view', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/evaluationoverview/programme/KH50_001', 'admin')
|
||||
})
|
||||
|
||||
it('Page opens correctly', () => {
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('KH50_001')
|
||||
cy.contains("This view is an abridged version of Oodikone's Studyprogramme Overview")
|
||||
})
|
||||
|
||||
it('Progress data is shown correctly with graduated included', () => {
|
||||
cy.get('[data-cy="Graph-StudytrackProgress"]').within(() => {
|
||||
const totalStats = ['12.9%', '6.9%', '9.7%', '9.2%', '3.7%', '6.0%', '51.6%']
|
||||
for (const stat of totalStats) {
|
||||
cy.contains(stat)
|
||||
}
|
||||
})
|
||||
|
||||
const years = getEmptyYears(true)
|
||||
|
||||
const tableContents = [
|
||||
...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', 32, 8, 3, 8, 8, 5, 0, 0],
|
||||
['2020 - 2021', 27, 2, 1, 3, 6, 2, 4, 9],
|
||||
['2019 - 2020', 33, 1, 0, 2, 1, 0, 3, 26],
|
||||
['2018 - 2019', 44, 0, 1, 1, 2, 0, 3, 37],
|
||||
['2017 - 2018', 47, 0, 1, 3, 0, 1, 2, 40],
|
||||
['Total', 217, 28, 15, 21, 20, 8, 13, 112],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudytrackProgress')
|
||||
})
|
||||
|
||||
it('Progress data is shown correctly with graduated excluded', () => {
|
||||
cy.get('[data-cy=GraduatedToggle]').click()
|
||||
cy.get('[data-cy="Graph-StudytrackProgress"]').within(() => {
|
||||
const totalStats = ['30.8%', '15.4%', '22.0%', '20.9%', '4.4%', '5.5%']
|
||||
for (const stat of totalStats) {
|
||||
cy.contains(stat)
|
||||
}
|
||||
})
|
||||
|
||||
const years = getEmptyYears(true)
|
||||
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 24, 9, 8, 4, 3, 0, 0, 0],
|
||||
['2021 - 2022', 31, 8, 3, 8, 8, 4, 0, 0],
|
||||
['2020 - 2021', 11, 2, 1, 2, 5, 0, 0, 1],
|
||||
['2019 - 2020', 7, 1, 0, 2, 1, 0, 0, 3],
|
||||
['2018 - 2019', 5, 0, 1, 1, 2, 0, 0, 1],
|
||||
['2017 - 2018', 5, 0, 1, 3, 0, 0, 1, 0],
|
||||
['Total', 91, 28, 14, 20, 19, 4, 1, 5],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudytrackProgress')
|
||||
})
|
||||
|
||||
it('Graduation times breakdown data is shown correctly', () => {
|
||||
cy.get('[data-cy=graduation-times-graph-breakdown]').within(() => {
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2021')
|
||||
cy.get('[aria-label="2021, 8. Overtime."]')
|
||||
cy.get('[aria-label="2021, 24. Max. year overtime."]')
|
||||
cy.get('[aria-label="2021, 14. On time."]').trigger('mouseover')
|
||||
cy.contains('Graduated On time: 14 students')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=YearToggle]').click()
|
||||
|
||||
cy.get('[data-cy=graduation-times-graph-breakdown]').within(() => {
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2021 - 2022')
|
||||
cy.get('[aria-label="2021 - 2022, 13. Overtime."]')
|
||||
cy.get('[aria-label="2021 - 2022, 21. Max. year overtime."]')
|
||||
cy.get('[aria-label="2021 - 2022, 21. On time."]').trigger('mouseover')
|
||||
cy.contains('Graduated On time: 21 students')
|
||||
})
|
||||
})
|
||||
|
||||
it('Graduation times median time data is shown correctly', () => {
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
|
||||
cy.get('[data-cy=graduation-times-graph]').within(() => {
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2021')
|
||||
cy.contains('46 graduated').trigger('mouseover')
|
||||
cy.contains('46 students graduated in year 2021')
|
||||
cy.contains('median study time: 41 months')
|
||||
cy.contains('14 graduated on time')
|
||||
cy.contains('24 graduated max year overtime')
|
||||
cy.contains('8 graduated over year late')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=YearToggle]').click()
|
||||
|
||||
cy.get('[data-cy=graduation-times-graph]').within(() => {
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2021 - 2022')
|
||||
cy.contains('55 graduated').trigger('mouseover')
|
||||
cy.contains('55 students graduated in year 2021 - 2022')
|
||||
cy.contains('median study time: 40 months')
|
||||
cy.contains('21 graduated on time')
|
||||
cy.contains('21 graduated max year overtime')
|
||||
cy.contains('13 graduated over year late')
|
||||
})
|
||||
})
|
||||
|
||||
it("'Programmes before or after' data is shown correctly", () => {
|
||||
cy.get('[data-cy=Graph-ProgrammesBeforeOrAfter]').within(() => {
|
||||
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
cy.contains('Tietojenkäsittelytieteen maisteriohjelma')
|
||||
cy.contains('Datatieteen maisteriohjelma')
|
||||
cy.contains('2021')
|
||||
cy.contains('32')
|
||||
cy.contains('19')
|
||||
})
|
||||
|
||||
let emptyYears = new Array(getEmptyYears(false).length).fill(0)
|
||||
|
||||
let tableContents = [
|
||||
['MH50_001', 'MAST', 'Matematiikan ja tilastotieteen maisteriohjelma', 0, 0, 1, 9, 32, 19, 15, ...emptyYears],
|
||||
['MH50_002', 'LSI', 'Life Science Informatics -maisteriohjelma', 0, 0, 0, 1, 2, 1, 1, ...emptyYears],
|
||||
[
|
||||
'MH50_003',
|
||||
'TCM',
|
||||
'Teoreettisten ja laskennallisten menetelmien maisteriohjelma',
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
...emptyYears,
|
||||
],
|
||||
['MH50_009', 'CSM', 'Tietojenkäsittelytieteen maisteriohjelma', 0, 0, 0, 0, 2, 3, 2, ...emptyYears],
|
||||
['MH50_010', 'DATA', 'Datatieteen maisteriohjelma', 0, 0, 0, 1, 5, 10, 1, ...emptyYears],
|
||||
['MH70_009', 'ECON', 'Taloustieteen maisteriohjelma', 0, 0, 0, 0, 4, 7, 4, ...emptyYears],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'undefined')
|
||||
|
||||
cy.get('[data-cy=YearToggle]').click()
|
||||
|
||||
cy.get('[data-cy=Graph-ProgrammesBeforeOrAfter]').within(() => {
|
||||
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
cy.contains('Tietojenkäsittelytieteen maisteriohjelma')
|
||||
cy.contains('Datatieteen maisteriohjelma')
|
||||
cy.contains('2021 - 2022')
|
||||
cy.contains('28')
|
||||
cy.contains('16')
|
||||
})
|
||||
|
||||
emptyYears = new Array(getEmptyYears(true).length).fill(0)
|
||||
|
||||
tableContents = [
|
||||
['MH50_001', 'MAST', 'Matematiikan ja tilastotieteen maisteriohjelma', 0, 0, 4, 24, 28, 16, 4, ...emptyYears],
|
||||
['MH50_002', 'LSI', 'Life Science Informatics -maisteriohjelma', 0, 0, 1, 0, 3, 1, 0, ...emptyYears],
|
||||
[
|
||||
'MH50_003',
|
||||
'TCM',
|
||||
'Teoreettisten ja laskennallisten menetelmien maisteriohjelma',
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
...emptyYears,
|
||||
],
|
||||
['MH50_009', 'CSM', 'Tietojenkäsittelytieteen maisteriohjelma', 0, 0, 0, 0, 4, 3, 0, ...emptyYears],
|
||||
['MH50_010', 'DATA', 'Datatieteen maisteriohjelma', 0, 0, 1, 0, 12, 4, 0, ...emptyYears],
|
||||
['MH70_009', 'ECON', 'Taloustieteen maisteriohjelma', 0, 0, 0, 4, 6, 5, 0, ...emptyYears],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'undefined')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Faculty view', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/evaluationoverview/faculty/H50', 'admin')
|
||||
})
|
||||
|
||||
it('Page opens correctly', () => {
|
||||
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
|
||||
cy.contains('H50')
|
||||
cy.contains("This view is an abridged version of Oodikone's Faculty Overview")
|
||||
})
|
||||
|
||||
describe('Progress stats', () => {
|
||||
describe('Bachelor stats', () => {
|
||||
it('graph with graduated included is shown correctly', () => {
|
||||
cy.get('[data-cy="Graph-FacultyBachelorsProgress"]').within(() => {
|
||||
const totalStats = ['12.2%', '5.3%', '7.5%', '8.2%', '5.3%', '6.3%', '55.2%']
|
||||
for (const stat of totalStats) {
|
||||
cy.contains(stat)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('graph with graduated excluded is shown correctly', () => {
|
||||
cy.get('[data-cy="GraduatedToggle"]').click()
|
||||
cy.get('[data-cy="Graph-FacultyBachelorsProgress"]').within(() => {
|
||||
const totalStats = ['30.5%', '12.5%', '18.0%', '19.5%', '7.0%', '3.1%', '9.4%']
|
||||
for (const stat of totalStats) {
|
||||
cy.contains(stat)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('table with graduated included is shown correctly', () => {
|
||||
cy.get('[data-cy="Table-FacultyBachelorsProgress"]')
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 14, 13, 0, 0, 0, 0, 0, 1],
|
||||
['2022 - 2023', 32, 11, 10, 6, 4, 0, 1, 0],
|
||||
['2021 - 2022', 44, 10, 4, 9, 11, 7, 1, 2],
|
||||
['2020 - 2021', 38, 3, 1, 3, 7, 3, 8, 13],
|
||||
['2019 - 2020', 50, 2, 0, 2, 1, 4, 5, 36],
|
||||
['2018 - 2019', 75, 0, 1, 1, 2, 2, 3, 66],
|
||||
['2017 - 2018', 66, 0, 1, 3, 1, 1, 2, 58],
|
||||
['Total', 319, 39, 17, 24, 26, 17, 20, 176],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'FacultyBachelorsProgress')
|
||||
})
|
||||
|
||||
it('table with graduated excluded is shown correctly', () => {
|
||||
cy.get('[data-cy="GraduatedToggle"]').click()
|
||||
cy.get('[data-cy="Table-FacultyBachelorsProgress"]')
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 14, 13, 0, 0, 0, 0, 0, 1],
|
||||
['2022 - 2023', 30, 11, 9, 6, 4, 0, 0, 0],
|
||||
['2021 - 2022', 41, 10, 4, 9, 11, 6, 0, 1],
|
||||
['2020 - 2021', 14, 3, 1, 2, 6, 0, 1, 1],
|
||||
['2019 - 2020', 14, 2, 0, 2, 1, 3, 2, 4],
|
||||
['2018 - 2019', 6, 0, 1, 1, 2, 0, 0, 2],
|
||||
['2017 - 2018', 9, 0, 1, 3, 1, 0, 1, 3],
|
||||
['Total', 128, 39, 16, 23, 25, 9, 4, 12],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'FacultyBachelorsProgress')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bachelor + master stats', () => {
|
||||
it('graph with graduated included is shown correctly', () => {
|
||||
cy.get('[data-cy=Graph-FacultyBachelorMastersProgress]').within(() => {
|
||||
const totalStats = ['21.3%', '8.4%', '10.1%', '14.0%', '12.4%', '6.7%', '27.0%']
|
||||
for (const stat of totalStats) {
|
||||
cy.contains(stat)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('graph with graduated excluded is shown correctly', () => {
|
||||
cy.get('[data-cy="GraduatedToggle"]').click()
|
||||
cy.get('[data-cy=Graph-FacultyBachelorMastersProgress]').within(() => {
|
||||
const totalStats = ['28.7%', '11.6%', '14.0%', '18.6%', '15.5%', '3.1%', '8.5%']
|
||||
for (const stat of totalStats) {
|
||||
cy.contains(stat)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('table with graduated included is shown correctly', () => {
|
||||
cy.get('[data-cy="Table-FacultyBachelorMastersProgress"]')
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 2, 2, 0, 0, 0, 0, 0, 0],
|
||||
['2021 - 2022', 4, 2, 0, 0, 0, 1, 0, 1],
|
||||
['2020 - 2021', 23, 13, 2, 4, 0, 4, 0, 0],
|
||||
['2019 - 2020', 32, 7, 7, 4, 7, 3, 3, 1],
|
||||
['2018 - 2019', 63, 10, 2, 5, 13, 7, 4, 22],
|
||||
['2017 - 2018', 54, 4, 4, 5, 5, 7, 5, 24],
|
||||
['Total', 178, 38, 15, 18, 25, 22, 12, 48],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'FacultyBachelorMastersProgress')
|
||||
})
|
||||
|
||||
it('table with graduated excluded is shown correctly', () => {
|
||||
cy.get('[data-cy="GraduatedToggle"]').click()
|
||||
cy.get('[data-cy="Table-FacultyBachelorMastersProgress"]')
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 2, 2, 0, 0, 0, 0, 0, 0],
|
||||
['2021 - 2022', 4, 2, 0, 0, 0, 1, 0, 1],
|
||||
['2020 - 2021', 21, 13, 2, 4, 0, 2, 0, 0],
|
||||
['2019 - 2020', 31, 7, 7, 4, 7, 3, 2, 1],
|
||||
['2018 - 2019', 41, 9, 2, 5, 12, 7, 1, 5],
|
||||
['2017 - 2018', 30, 4, 4, 5, 5, 7, 1, 4],
|
||||
['Total', 129, 37, 15, 18, 24, 20, 4, 11],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'FacultyBachelorMastersProgress')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Master stats', () => {
|
||||
it('graph with graduated included is shown correctly', () => {
|
||||
cy.get('[data-cy=Graph-FacultyMastersProgress]').within(() => {
|
||||
const totalStats = ['16.1%', '12.9%', '6.5%', '12.9%', '9.7%', '16.1%', '25.8%']
|
||||
for (const stat of totalStats) {
|
||||
cy.contains(stat)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('graph with graduated excluded is shown correctly', () => {
|
||||
cy.get('[data-cy="GraduatedToggle"]').click()
|
||||
cy.get('[data-cy=Graph-FacultyMastersProgress]').within(() => {
|
||||
const totalStats = ['23.8%', '19.0%', '9.5%', '9.5%', '9.5%', '14.3%', '14.3%']
|
||||
for (const stat of totalStats) {
|
||||
cy.contains(stat)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('table with graduated included is shown correctly', () => {
|
||||
cy.get('[data-cy="Table-FacultyMastersProgress"]')
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 4, 4, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 5, 0, 1, 1, 2, 0, 0, 1],
|
||||
['2021 - 2022', 8, 1, 2, 0, 2, 1, 1, 1],
|
||||
['2020 - 2021', 3, 0, 0, 0, 0, 0, 2, 1],
|
||||
['2019 - 2020', 7, 0, 0, 1, 0, 1, 1, 4],
|
||||
['2018 - 2019', 2, 0, 0, 0, 0, 1, 0, 1],
|
||||
['2017 - 2018', 2, 0, 1, 0, 0, 0, 1, 0],
|
||||
['Total', 31, 5, 4, 2, 4, 3, 5, 8],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'FacultyMastersProgress')
|
||||
})
|
||||
|
||||
it('table with graduated excluded is shown correctly', () => {
|
||||
cy.get('[data-cy="GraduatedToggle"]').click()
|
||||
cy.get('[data-cy="Table-FacultyMastersProgress"]')
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 4, 4, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 5, 0, 1, 1, 2, 0, 0, 1],
|
||||
['2021 - 2022', 5, 1, 2, 0, 0, 0, 1, 1],
|
||||
['2020 - 2021', 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
['2019 - 2020', 3, 0, 0, 1, 0, 1, 1, 0],
|
||||
['2018 - 2019', 2, 0, 0, 0, 0, 1, 0, 1],
|
||||
['2017 - 2018', 2, 0, 1, 0, 0, 0, 1, 0],
|
||||
['Total', 21, 5, 4, 2, 2, 2, 3, 3],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'FacultyMastersProgress')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Doctor stats', () => {
|
||||
it('graph with graduated included is shown correctly', () => {
|
||||
cy.get('[data-cy=Graph-FacultyDoctoralProgress]').within(() => {
|
||||
const totalStats = ['6.0%', '18.0%', '16.0%', '14.0%', '46.0%']
|
||||
for (const stat of totalStats) {
|
||||
cy.contains(stat)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('graph with graduated excluded is shown correctly', () => {
|
||||
cy.get('[data-cy="GraduatedToggle"]').click()
|
||||
cy.get('[data-cy=Graph-FacultyDoctoralProgress]').within(() => {
|
||||
const totalStats = ['9.1%', '24.2%', '12.1%', '15.2%', '39.4%']
|
||||
for (const stat of totalStats) {
|
||||
cy.contains(stat)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('table with graduated included is shown correctly', () => {
|
||||
cy.get('[data-cy="Table-FacultyDoctoralProgress"]')
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 1, 1, 0, 0, 0, 0],
|
||||
['2022 - 2023', 9, 1, 7, 1, 0, 0],
|
||||
['2021 - 2022', 3, 1, 1, 0, 0, 1],
|
||||
['2020 - 2021', 5, 0, 0, 2, 1, 2],
|
||||
['2019 - 2020', 11, 0, 0, 1, 4, 6],
|
||||
['2018 - 2019', 14, 0, 1, 4, 2, 7],
|
||||
['2017 - 2018', 7, 0, 0, 0, 0, 7],
|
||||
['Total', 50, 3, 9, 8, 7, 23],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'FacultyDoctoralProgress')
|
||||
})
|
||||
|
||||
it('table with graduated excluded is shown correctly', () => {
|
||||
cy.get('[data-cy="GraduatedToggle"]').click()
|
||||
cy.get('[data-cy="Table-FacultyDoctoralProgress"]')
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 1, 1, 0, 0, 0, 0],
|
||||
['2022 - 2023', 9, 1, 7, 1, 0, 0],
|
||||
['2021 - 2022', 3, 1, 1, 0, 0, 1],
|
||||
['2020 - 2021', 4, 0, 0, 2, 0, 2],
|
||||
['2019 - 2020', 9, 0, 0, 1, 4, 4],
|
||||
['2018 - 2019', 5, 0, 0, 0, 1, 4],
|
||||
['2017 - 2018', 2, 0, 0, 0, 0, 2],
|
||||
['Total', 33, 3, 8, 4, 5, 13],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'FacultyDoctoralProgress')
|
||||
})
|
||||
})
|
||||
|
||||
it('Years in the tables can be clicked to show programme level breakdown', () => {
|
||||
cy.get('[data-cy="Table-FacultyBachelorsProgress"]').within(() => {
|
||||
cy.contains('2021 - 2022').click()
|
||||
cy.contains('31.3%').trigger('mouseover', { force: true })
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('MAT - KH50_001')
|
||||
cy.contains('0 Credits: 0')
|
||||
cy.contains('1 ≤ Credits < 45: 10')
|
||||
cy.contains('45 ≤ Credits < 90: 9')
|
||||
cy.contains('90 ≤ Credits < 135: 11')
|
||||
cy.contains('135 ≤ Credits < 180: 2')
|
||||
cy.contains('180 ≤ Credits: 0')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graduation times stats', () => {
|
||||
it('All the correct sections are displayed', () => {
|
||||
const checkSections = breakdown => {
|
||||
const sections = ['bachelor', 'bcMsCombo', 'master', 'doctor']
|
||||
for (const section of sections) {
|
||||
cy.get(`[data-cy=Section-${section}]`).within(() => {
|
||||
cy.get('div.graduations-chart-container').within(() => {
|
||||
cy.get(`div.faculty-${breakdown ? 'breakdown-' : ''}graph`)
|
||||
cy.get('div.graduations-message').contains("Click a bar to view that year's programme level breakdown")
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
checkSections(true)
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
checkSections(false)
|
||||
})
|
||||
|
||||
it('Graduation times breakdown data is shown correctly', () => {
|
||||
cy.get('[data-cy=Section-bachelor]').within(() => {
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2022')
|
||||
|
||||
cy.get('[aria-label="2022, 29. On time."]').trigger('mouseover')
|
||||
cy.contains('On time: 29')
|
||||
|
||||
cy.get('[aria-label="2022, 29. On time."]').trigger('click')
|
||||
cy.contains('Year 2022 by graduation year')
|
||||
|
||||
cy.get('[aria-label="MAT, 22. On time."]').trigger('mouseover')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('KH50_001')
|
||||
cy.contains('On time: 22')
|
||||
|
||||
cy.get('[aria-label="MFKK, 7. On time."]').trigger('mouseover')
|
||||
cy.contains('Matematiikan, fysiikan ja kemian opettajan kandiohjelma')
|
||||
cy.contains('KH50_004')
|
||||
cy.contains('On time: 7')
|
||||
})
|
||||
})
|
||||
|
||||
it('Graduation times median time data is shown correctly', () => {
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
|
||||
cy.get('[data-cy=Section-bachelor]').within(() => {
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2021')
|
||||
|
||||
cy.contains('70 graduated').trigger('mouseover')
|
||||
cy.contains('70 students graduated in year 2021')
|
||||
cy.contains('median study time: 39.5 months')
|
||||
cy.contains('28 graduated on time')
|
||||
cy.contains('32 graduated max year overtime')
|
||||
cy.contains('10 graduated over year late')
|
||||
|
||||
cy.contains('70 graduated').trigger('click')
|
||||
cy.contains('Year 2021 by graduation year')
|
||||
|
||||
cy.get('[aria-label="MAT, 41."]').trigger('mouseover')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('KH50_001')
|
||||
cy.contains('47 students graduated in year 2021')
|
||||
cy.contains('median study time: 41 months')
|
||||
cy.contains('14 graduated on time')
|
||||
cy.contains('25 graduated max year overtime')
|
||||
cy.contains('8 graduated over year late')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+228
-276
@@ -5,331 +5,283 @@ const path = require('path')
|
||||
const timestamp = new Date().toLocaleDateString('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' }) // YYYY-MM-DD, taking the local time zone into account
|
||||
const downloadsFolder = Cypress.config('downloadsFolder')
|
||||
|
||||
describe('Faculty overview', () => {
|
||||
describe('Faculty can be selected', () => {
|
||||
it('Faculties are listed and one can be chosen', () => {
|
||||
describe('Faculty statistics', () => {
|
||||
describe('Faculty list', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.get('[data-cy=select-faculty]').contains('a', 'Teologinen tiedekunta')
|
||||
cy.contains('a', 'H99').should('not.exist')
|
||||
})
|
||||
|
||||
it('contains faculty names and faculty codes', () => {
|
||||
cy.contains('Faculties')
|
||||
cy.contains('Teologinen tiedekunta')
|
||||
cy.contains('H10')
|
||||
cy.contains('Oikeustieteellinen tiedekunta')
|
||||
cy.contains('H20')
|
||||
cy.contains('Lääketieteellinen tiedekunta')
|
||||
cy.contains('H30')
|
||||
cy.contains('Humanistinen tiedekunta')
|
||||
cy.contains('H40')
|
||||
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
|
||||
cy.contains('H50')
|
||||
cy.contains('Farmasian tiedekunta')
|
||||
cy.contains('H55')
|
||||
cy.contains('Bio- ja ympäristötieteellinen tiedekunta')
|
||||
cy.contains('H57')
|
||||
cy.contains('Kasvatustieteellinen tiedekunta')
|
||||
cy.contains('H60')
|
||||
cy.contains('Valtiotieteellinen tiedekunta')
|
||||
cy.contains('H70')
|
||||
cy.contains('Svenska social- och kommunalhögskolan')
|
||||
cy.contains('H74')
|
||||
cy.contains('Maatalous-metsätieteellinen tiedekunta')
|
||||
cy.contains('H80')
|
||||
cy.contains('Eläinlääketieteellinen tiedekunta')
|
||||
cy.contains('H90')
|
||||
})
|
||||
|
||||
it('contains a working link to faculty page', () => {
|
||||
cy.contains('Faculties')
|
||||
cy.contains('a', 'Teologinen tiedekunta').click()
|
||||
cy.contains('.header', 'Teologinen tiedekunta')
|
||||
cy.location('pathname').should('eq', '/faculties/hy-org-1000000580')
|
||||
cy.contains('Teologinen tiedekunta')
|
||||
cy.contains('H10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Basic information: admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties', 'admin')
|
||||
cy.contains('a', 'Eläinlääketieteellinen tiedekunta').click()
|
||||
describe('Basic information tab', () => {
|
||||
describe('Admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties', 'admin')
|
||||
cy.contains('a', 'Eläinlääketieteellinen tiedekunta').click()
|
||||
})
|
||||
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.cs('faculty-tabs').should('contain', 'Basic information')
|
||||
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
|
||||
cy.cs('faculty-tabs').should('contain', 'Graduation times')
|
||||
cy.cs('faculty-tabs').should('contain', 'Update statistics')
|
||||
})
|
||||
})
|
||||
|
||||
it('Credits produced by faculty are shown', () => {
|
||||
cy.get('[data-cy="Section-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
})
|
||||
describe('Basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
})
|
||||
|
||||
it('Update statistics tab is shown', () => {
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Update statistics')
|
||||
})
|
||||
})
|
||||
describe('Info boxes', () => {
|
||||
it('Students of the faculty', () => {
|
||||
cy.cs('students-of-the-faculty-info-box-button').click()
|
||||
cy.cs('students-of-the-faculty-info-box-content').contains('Taulukon luvut on')
|
||||
})
|
||||
|
||||
describe('Basic information: basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
})
|
||||
it('Graduated of the faculty', () => {
|
||||
cy.cs('graduated-of-the-faculty-info-box-button').click()
|
||||
cy.cs('graduated-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
|
||||
})
|
||||
|
||||
it('Basic information tab show all graphs and tables', () => {
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-ThesisWritersOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-StudentsOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-GraduatedOfTheFaculty"]').should('be.visible')
|
||||
})
|
||||
it('Thesis writers of the faculty', () => {
|
||||
cy.cs('thesis-writers-of-the-faculty-info-box-button').click()
|
||||
cy.cs('thesis-writers-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
|
||||
})
|
||||
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Basic information')
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Graduation times')
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Programmes and student populations')
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('not.contain', 'Update statistics')
|
||||
})
|
||||
it('Credits produced by the faculty', () => {
|
||||
cy.cs('credits-produced-by-the-faculty-info-box-button').click()
|
||||
cy.cs('credits-produced-by-the-faculty-info-box-content').contains('Sisältää opintopisteet')
|
||||
})
|
||||
})
|
||||
|
||||
it('Toggle years works', () => {
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('contain', '2022')
|
||||
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('contain', '2022')
|
||||
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('contain', '2022')
|
||||
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('contain', '2022')
|
||||
cy.get('[data-cy="YearToggle"]').click()
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('contain', '2022 - 2023')
|
||||
})
|
||||
it('All graphs and tables are shown', () => {
|
||||
cy.cs('students-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('graduated-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('thesis-writers-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('credits-produced-by-the-faculty-stacked-bar-chart-section').should('be.visible')
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('be.visible')
|
||||
})
|
||||
|
||||
it('Toggle programmes works', () => {
|
||||
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('not.exist')
|
||||
cy.get('[data-cy="ProgrammeToggle"]').click()
|
||||
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('be.visible')
|
||||
})
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.cs('faculty-tabs').should('contain', 'Basic information')
|
||||
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
|
||||
cy.cs('faculty-tabs').should('contain', 'Graduation times')
|
||||
cy.cs('faculty-tabs').should('not.contain', 'Update statistics')
|
||||
})
|
||||
|
||||
it('Students of the faculty infobox works', () => {
|
||||
cy.get('[data-cy="StudentsOfTheFaculty-info-content"]').should('not.exist')
|
||||
cy.get('[data-cy="StudentsOfTheFaculty-open-info"]').click()
|
||||
cy.get('[data-cy="StudentsOfTheFaculty-info-content"]').should('be.visible')
|
||||
cy.get('[data-cy="StudentsOfTheFaculty-close-info"]').click()
|
||||
cy.get('[data-cy="StudentsOfTheFaculty-info-content"]').should('not.exist')
|
||||
})
|
||||
it('Toggle years works', () => {
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('year-toggle').click()
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
})
|
||||
|
||||
it('Data can be exported to Excel files', () => {
|
||||
const sections = [
|
||||
'StudentsOfTheFaculty',
|
||||
'GraduatedOfTheFaculty',
|
||||
'ThesisWritersOfTheFaculty',
|
||||
'CreditsProducedByTheFaculty',
|
||||
]
|
||||
sections.forEach(section => {
|
||||
cy.get(`[data-cy="Graph-${section}"]`).within(() => {
|
||||
cy.get('[aria-label="View chart menu, Chart"]').click()
|
||||
cy.contains('li.highcharts-menu-item', 'Download XLS').click()
|
||||
const downloadedFile = `oodikone_${section}_H50_${timestamp}.xls`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
it('Toggle programmes works', () => {
|
||||
cy.cs('faculty-programmes-shown-info').should('not.exist')
|
||||
cy.cs('programme-toggle').click()
|
||||
cy.cs('faculty-programmes-shown-info').should('be.visible')
|
||||
})
|
||||
|
||||
it('Toggle study rights works', () => {
|
||||
cy.cs('faculty-exclude-specials-info').should('not.exist')
|
||||
cy.cs('study-right-toggle').click()
|
||||
cy.cs('faculty-exclude-specials-info').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Data can be exported to Excel files', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
const sections = [
|
||||
'StudentsOfTheFaculty',
|
||||
'GraduatedOfTheFaculty',
|
||||
'ThesisWritersOfTheFaculty',
|
||||
'CreditsProducedByTheFaculty',
|
||||
]
|
||||
sections.forEach(section => {
|
||||
cy.cs(`${section}Graph`).within(() => {
|
||||
cy.get('[aria-label="View chart menu, Chart"]').click({ force: true })
|
||||
cy.contains('li.highcharts-menu-item', 'Download XLS').click()
|
||||
const downloadedFile = `oodikone_${section}_H50_${timestamp}.xls`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Study programme information: users with access', () => {
|
||||
describe('Students by starting year tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Maatalous-metsätieteellinen tiedekunta').click()
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Students by starting year').click()
|
||||
})
|
||||
|
||||
it('Study programme credit information is not visible in the beginning', () => {
|
||||
cy.get('table[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
|
||||
it('Export button', { retries: 2 }, () => {
|
||||
cy.cs('faculty-student-table-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_programme_stats_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it('Study programme credit information can be toggled', () => {
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
|
||||
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-0"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-3"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Button-Hide-CreditsProducedByTheFaculty-0"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
|
||||
it('Info box', () => {
|
||||
cy.cs('faculty-student-table-info-box-button').click()
|
||||
cy.cs('faculty-student-table-info-box-content').contains('Opiskelijat, joiden')
|
||||
})
|
||||
|
||||
it('Graph stays open when sorted', () => {
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-0"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
|
||||
cy.get('[data-cy="Menu-CreditsProducedByTheFaculty-Total"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
|
||||
it.skip('Study right toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Graduated toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Hoverable country list', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Expandable rows', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Population links', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it('Percentage toggle works', () => {
|
||||
cy.cs('faculty-student-stats-table').should('be.visible')
|
||||
cy.cs('faculty-student-stats-table').should('not.contain', '92.3 %')
|
||||
cy.cs('percentage-toggle').click()
|
||||
cy.cs('faculty-student-stats-table').should('contain', '92.3 %')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Average graduation times', () => {
|
||||
describe('Progress tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Progress').click()
|
||||
})
|
||||
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.cs('FacultyProgress-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_progress_tab_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it.skip('Info boxes', () => {
|
||||
cy.cs('FacultyProgress-info-box-button').click()
|
||||
cy.cs('FacultyProgress-info-box-content').contains('Kuvaa tiedekuntaan')
|
||||
// TODO: Click open the accordion first
|
||||
cy.cs('BachelorMastersProgress-info-box-button').click()
|
||||
cy.cs('BachelorMastersProgress-info-box-content').contains('The starting year is the')
|
||||
})
|
||||
|
||||
it.skip('Progress bar charts exist', () => {
|
||||
// TODO: Click open the accordion first
|
||||
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyBachelorMastersProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyMastersProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Progress tables exist', () => {
|
||||
// TODO: Click open the accordion first
|
||||
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
|
||||
cy.cs('FacultyBachelorMasterProgressTable').should('be.visible')
|
||||
cy.cs('FacultyMastersProgressTable').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Graduated toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Study right toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graduation times tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Graduation times').click()
|
||||
})
|
||||
|
||||
it('User can view graduation graphs', () => {
|
||||
cy.get('[data-cy="Section-AverageGraduationTimes"]').should('be.visible')
|
||||
cy.get('[data-cy="Section-bachelor"]').should('be.visible')
|
||||
cy.get('[data-cy="Section-master"]').should('be.visible')
|
||||
cy.get('[data-cy="Section-master"]').within(() => {
|
||||
cy.contains('text.highcharts-axis-title', 'Graduation year')
|
||||
cy.contains('.message', "Click a bar to view that year's programme level breakdown").should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
it('Graphs display data', () => {
|
||||
cy.get('[data-cy="Section-bachelor"]').within(() => {
|
||||
cy.get('div[class="faculty-breakdown-graph"]')
|
||||
})
|
||||
|
||||
cy.get('[data-cy="GraduationTimeToggle"]').click()
|
||||
|
||||
cy.get('[data-cy="Section-bachelor"]').within(() => {
|
||||
cy.get('div[class="faculty-graph"]')
|
||||
cy.contains('44 graduated').trigger('mouseover')
|
||||
cy.contains('44 students graduated in year 2023')
|
||||
cy.contains('median study time: 37.5 months')
|
||||
cy.contains('21 graduated on time')
|
||||
cy.contains('11 graduated max year overtime')
|
||||
cy.contains('12 graduated over year late')
|
||||
|
||||
cy.contains('44 graduated').click()
|
||||
cy.contains('Year 2023 by graduation year')
|
||||
cy.get('div[class="programmes-graph"]').should('be.visible')
|
||||
cy.get('div[class="programmes-graph"]').within(() => {
|
||||
cy.contains('MAT')
|
||||
cy.get('[aria-label="MAT, 36."]').trigger('mouseover')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('KH50_001')
|
||||
cy.contains('26 students graduated in year 2023')
|
||||
cy.contains('median study time: 36 months')
|
||||
cy.contains('14 graduated on time')
|
||||
cy.contains('6 graduated max year overtime')
|
||||
cy.contains('6 graduated over year late')
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy="Section-master"]').within(() => {
|
||||
cy.get('div[class="faculty-graph"]')
|
||||
cy.contains('4 graduated').trigger('mouseover')
|
||||
cy.contains('4 students graduated in year 2023')
|
||||
cy.contains('median study time: 23 months')
|
||||
cy.contains('3 graduated on time')
|
||||
cy.contains('0 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
|
||||
cy.contains('4 graduated').click()
|
||||
cy.contains('Year 2023 by graduation year')
|
||||
cy.get('div[class="programmes-graph"]').should('be.visible')
|
||||
cy.get('div[class="programmes-graph"]').within(() => {
|
||||
cy.contains('MAST')
|
||||
cy.get('[aria-label="MAST, 23."]').trigger('mouseover')
|
||||
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
cy.contains('MH50_001')
|
||||
cy.contains('3 students graduated in year 2023')
|
||||
cy.contains('median study time: 23 months')
|
||||
cy.contains('2 graduated on time')
|
||||
cy.contains('0 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Graduation times grouping and time types can be toggled', () => {
|
||||
cy.get('[data-cy="GraduationTimeToggle"]').click()
|
||||
cy.get('[data-cy="GroupByToggle"]').click()
|
||||
cy.get('[data-cy="Section-master"]').should('be.visible')
|
||||
|
||||
cy.get('[data-cy="Section-bachelor"]').within(() => {
|
||||
cy.get('div[class="faculty-graph"]')
|
||||
cy.contains('24 graduated (64.9 % of class)').trigger('mouseover')
|
||||
cy.contains('From class of 2020 - 2021, 24/37 students have graduated')
|
||||
})
|
||||
|
||||
cy.get('[data-cy="GraduationTimeToggle"]').click()
|
||||
cy.get('[data-cy="Section-bachelor"]').within(() => {
|
||||
cy.get('[aria-label="2018 - 2019, 29. On time."]').click()
|
||||
cy.get('div[class="programmes-breakdown-graph"]').should('be.visible')
|
||||
cy.get('div[class="programmes-breakdown-graph"]').within(() => {
|
||||
cy.contains('Year 2018 - 2019 by start year')
|
||||
cy.contains('MAT')
|
||||
cy.get('[aria-label="MAT, 20. Max. year overtime."]').trigger('mouseover')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('KH50_001')
|
||||
cy.contains('Max. year overtime: 20')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Data can be exported to an Excel file', () => {
|
||||
cy.get('[data-cy="DownloadButton-AverageGraduationTimes"]').click()
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.cs('average-graduation-times-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_graduation_times_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Faculty Progress and student population tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Programmes and student populations').click()
|
||||
it('Info boxes', () => {
|
||||
cy.cs('average-graduation-times-info-box-button').click()
|
||||
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset')
|
||||
})
|
||||
|
||||
it('User can view studentdata table without %', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', '0 %')
|
||||
it.skip('Study times toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it('User can view studentdata table with %', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="HidePercentagesToggle"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('contain', '0 %')
|
||||
it.skip('Year toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it('Percentage Graphs exists', () => {
|
||||
cy.get('[data-cy="Graph-FacultyBachelorsProgress"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-FacultyBachelorMastersProgress"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-FacultyMastersProgress"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-FacultyBachelorsProgress"]').should('be.visible')
|
||||
it.skip('Study programme toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it('Graduations can be excluded', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="StudentToggle"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('Transfers can be excluded', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="GraduatedToggle"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('Progress tables exists', () => {
|
||||
cy.get('[data-cy="FacultyBachelorsProgressTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorMasterProgressTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyMastersProgressTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorsProgressTable"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('Progress tables can be toggled', () => {
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-5"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Button-Show-FacultyBachelorsProgressTable-5"]').click()
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-0"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-3"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-5"]').should('be.visible')
|
||||
cy.get('[data-cy="Button-Hide-FacultyBachelorsProgressTable-5"]').click()
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-5"]').should('not.be.visible')
|
||||
})
|
||||
|
||||
it('Studentstats table can be toggled', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', 'TKT')
|
||||
cy.get('[data-cy="Button-FacultyStudentStatsTable-6"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('contain', 'TKT')
|
||||
cy.get('[data-cy="Button-FacultyStudentStatsTable-6"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', 'TKT')
|
||||
})
|
||||
|
||||
it('Progress infobox can be toggled', () => {
|
||||
cy.get('[data-cy="InfoFacultyProgress-info-content"]').should('not.exist')
|
||||
cy.get('[data-cy="InfoFacultyProgress-open-info"]').click()
|
||||
cy.get('[data-cy="InfoFacultyProgress-info-content"]').should('be.visible')
|
||||
cy.get('[data-cy="InfoFacultyProgress-close-info"]').click()
|
||||
cy.get('[data-cy="InfoFacultyProgress-info-content"]').should('not.exist')
|
||||
it('Graphs are visible', () => {
|
||||
cy.cs('bachelor-graduation-times-section').should('be.visible')
|
||||
// ! No test data for bachelor + master
|
||||
cy.cs('master-graduation-times-section').should('be.visible')
|
||||
cy.cs('doctor-graduation-times-section').should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+107
-51
@@ -18,7 +18,6 @@ const clearSingleDropdownSelection = dataCyAttribute => {
|
||||
}
|
||||
|
||||
const testRangeFilter = (parentEl, min, max, expected) => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
cy.cs(parentEl)
|
||||
.cs('range-selector-min')
|
||||
.find('input')
|
||||
@@ -29,13 +28,17 @@ const testRangeFilter = (parentEl, min, max, expected) => {
|
||||
.find('input')
|
||||
.invoke('val')
|
||||
.then(initialMax => {
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').type(`{selectall}{backspace}${min}`).blur()
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').type(`{selectall}{backspace}${max}`).blur()
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').clear()
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').type(min)
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').clear()
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').type(max)
|
||||
|
||||
checkFilteringResult(expected)
|
||||
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').type(`{selectall}{backspace}${initialMin}`).blur()
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').type(`{selectall}{backspace}${initialMax}`).blur()
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').clear()
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').type(initialMin)
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').clear()
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').type(initialMax)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -45,23 +48,23 @@ const testRangeFilter = (parentEl, min, max, expected) => {
|
||||
// functions don't take any parameters and using global object for matching test step name seemed overcomplicated.
|
||||
const createRunTestStepWithPreAndPostPartsFunction = amountWithoutFiltering => {
|
||||
return (filterName, testStepFunctionToRun) => {
|
||||
const card = cy.cs(`${filterName}-filter-card`)
|
||||
cy.cs(`${filterName}-filter-card`)
|
||||
.invoke('attr', 'data-open')
|
||||
.then(open => {
|
||||
const getHeader = () => cy.cs(`${filterName}-header`)
|
||||
|
||||
card.invoke('attr', 'data-open').then(open => {
|
||||
const getHeader = () => cy.cs(`${filterName}-header`)
|
||||
if (open === 'false') {
|
||||
getHeader().click({ force: true })
|
||||
}
|
||||
|
||||
if (open === 'false') {
|
||||
getHeader().click({ force: true })
|
||||
}
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
testStepFunctionToRun()
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
testStepFunctionToRun()
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
|
||||
if (open === 'false') {
|
||||
getHeader().click({ force: true })
|
||||
}
|
||||
})
|
||||
if (open === 'false') {
|
||||
getHeader().click({ force: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,34 +79,32 @@ describe("Population statistics with a master's programme", () => {
|
||||
|
||||
it('Study track filter works', () => {
|
||||
runTestStepWithPreAndPostParts('StudyTrack', () => {
|
||||
const card = cy.cs('StudyTrack-filter-card')
|
||||
const programmeDropdown = card.cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
|
||||
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(15)
|
||||
programmeDropdown.get('i.delete').click()
|
||||
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').get('i.delete').click()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Studyright type filter', () => {
|
||||
describe('Study right type filter', () => {
|
||||
it('is visible', () => {
|
||||
cy.contains('Studyright type')
|
||||
cy.contains('Study right type')
|
||||
})
|
||||
|
||||
it('is set to all by default', () => {
|
||||
runTestStepWithPreAndPostParts('studyright-type', () => {
|
||||
const card = cy.cs('studyright-type-filter-card')
|
||||
card.get('[data-cy="all"] input').should('be.checked')
|
||||
runTestStepWithPreAndPostParts('studyRightTypeFilter', () => {
|
||||
cy.cs('studyRightTypeFilter-filter-card').get('[data-cy="all"] input').should('be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
it('works', () => {
|
||||
runTestStepWithPreAndPostParts('studyright-type', () => {
|
||||
runTestStepWithPreAndPostParts('studyRightTypeFilter', () => {
|
||||
const expectedBaMaStudents = 24
|
||||
const expectedMasterOnlyStudents = 2
|
||||
cy.cs('studyright-type-filter-card').cs('bama').click()
|
||||
cy.cs('studyRightTypeFilter-filter-card').cs('bama').click()
|
||||
checkFilteringResult(expectedBaMaStudents)
|
||||
cy.cs('studyright-type-filter-card').cs('master').click()
|
||||
cy.cs('studyRightTypeFilter-filter-card').cs('master').click()
|
||||
checkFilteringResult(expectedMasterOnlyStudents)
|
||||
cy.cs('studyright-type-filter-card').cs('all').click()
|
||||
cy.cs('studyRightTypeFilter-filter-card').cs('all').click()
|
||||
checkFilteringResult(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
@@ -124,8 +125,7 @@ describe('Population Statistics', () => {
|
||||
cy.contains('By default only students who have not transferred to this study programme are shown.')
|
||||
|
||||
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
|
||||
const card = cy.cs('TransferredToProgramme-filter-card')
|
||||
card.get('[data-cy="option-havenot"] input').should('be.checked')
|
||||
cy.cs('TransferredToProgramme-filter-card').get('[data-cy="option-havenot"] input').should('be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -169,6 +169,7 @@ describe('Population Statistics', () => {
|
||||
})
|
||||
|
||||
it('Age filter works', { retries: 3 }, () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
testRangeFilter('Age-filter-card', 23, 30, 21)
|
||||
})
|
||||
@@ -215,19 +216,24 @@ describe('Population Statistics', () => {
|
||||
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
|
||||
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
|
||||
]
|
||||
cy.cs('courseFilter-course-dropdown').click().contains(`${courses[0].code} - ${courses[0].name}`).click()
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[0].code} - ${courses[0].name}`).click()
|
||||
checkFilteringResult(10)
|
||||
cy.cs(`courseFilter-${courses[0].code}-dropdown`).selectFromDropdown(1) // Passed students
|
||||
checkFilteringResult(8)
|
||||
cy.cs('courseFilter-course-dropdown').click().contains(`${courses[1].code} - ${courses[1].name}`).click()
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[1].code} - ${courses[1].name}`).click()
|
||||
checkFilteringResult(6)
|
||||
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(1)
|
||||
checkFilteringResult(5)
|
||||
courses.forEach(({ code }) => cy.cs(`courseFilter-${code}-clear`).click())
|
||||
courses.forEach(({ code }) => {
|
||||
cy.cs(`courseFilter-${code}-clear`).click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Filter combinations work', () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
|
||||
@@ -249,8 +255,52 @@ describe('Population Statistics', () => {
|
||||
checkFilteringResult(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Studyright type filter is not visible', () => {
|
||||
cy.contains('Studyright type').should('not.exist')
|
||||
it('Study right type filter is not visible', () => {
|
||||
cy.contains('Study right type').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Population Statistics with Bachelor + Master', () => {
|
||||
const pathToMathBSc2017 =
|
||||
'/populations?months=91&semesters=FALL&semesters=SPRING&showBachelorAndMaster=true&studyRights=%7B%22programme%22%3A%22KH50_001%22%2C%22combinedProgramme%22%3A%22%22%7D&year=2017'
|
||||
const defaultAmountOfStudents = 47
|
||||
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
|
||||
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathBSc2017)
|
||||
})
|
||||
|
||||
it('Graduation filter works', () => {
|
||||
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
|
||||
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
|
||||
|
||||
getCard().cs('option-graduated-bachelor').click()
|
||||
checkFilteringResult(42)
|
||||
getCard().cs('option-graduated-master').click()
|
||||
checkFilteringResult(18)
|
||||
getCard().cs('option-not-graduated-bachelor').click()
|
||||
checkFilteringResult(5)
|
||||
getCard().cs('option-not-graduated-master').click()
|
||||
checkFilteringResult(29)
|
||||
getCard().cs('option-all').click()
|
||||
})
|
||||
})
|
||||
|
||||
it('Study right status filter works', () => {
|
||||
runTestStepWithPreAndPostParts('studyRightStatusFilter', () => {
|
||||
const getCard = () => cy.cs('studyRightStatusFilter-filter-card')
|
||||
|
||||
// First two are 0 because there are no semester enrollments for the current semester
|
||||
getCard().cs('option-active').click()
|
||||
checkFilteringResult(0)
|
||||
getCard().cs('option-active-combined').click()
|
||||
checkFilteringResult(0)
|
||||
getCard().cs('option-inactive').click()
|
||||
checkFilteringResult(5)
|
||||
getCard().cs('option-inactive-combined').click()
|
||||
checkFilteringResult(24)
|
||||
getCard().cs('option-activity-status-all').click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -283,14 +333,14 @@ describe('Course Statistics', () => {
|
||||
|
||||
it('Programme filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
const card = cy.cs('Programme-filter-card')
|
||||
const programmeDropdown = card.cs('Programme-filter-dropdown').selectFromDropdown(2)
|
||||
checkFilteringResult(30)
|
||||
programmeDropdown.get('i.delete').click()
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(33)
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
|
||||
})
|
||||
})
|
||||
|
||||
it('Age filter works', () => {
|
||||
it('Age filter works', { retries: 3 }, () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
testRangeFilter('Age-filter-card', 21, 30, 36)
|
||||
})
|
||||
@@ -322,6 +372,7 @@ describe('Course Statistics', () => {
|
||||
})
|
||||
|
||||
it('Filter combinations work', () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('Grade', () => {
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
cy.cs('gradeFilter-5').click()
|
||||
@@ -342,12 +393,14 @@ describe('Custom Population Statistics', () => {
|
||||
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1, studentNumbersForCSStudentsSet2 }) => {
|
||||
const students = [...studentNumbersForCSStudentsSet1, ...studentNumbersForCSStudentsSet2]
|
||||
runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(students.length)
|
||||
cy.cs('student-number-input').click().type(students.join('\n'))
|
||||
cy.cs('student-number-input').click()
|
||||
cy.cs('student-number-input').type(students.join('{enter}'))
|
||||
cy.cs('search-button').click()
|
||||
})
|
||||
cy.cs('search-button').click()
|
||||
})
|
||||
|
||||
it('Age filter works', () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
testRangeFilter('Age-filter-card', 24, 28, 5)
|
||||
})
|
||||
@@ -361,10 +414,9 @@ describe('Custom Population Statistics', () => {
|
||||
|
||||
it('Programme filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
const card = cy.cs('Programme-filter-card')
|
||||
const programmeDropdown = card.cs('Programme-filter-dropdown').selectFromDropdown(1)
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(3)
|
||||
programmeDropdown.get('i.delete').click()
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -394,15 +446,19 @@ describe('Custom Population Statistics', () => {
|
||||
{ code: 'MAT11005', name: 'Integraalilaskenta' },
|
||||
{ code: 'MAT21007', name: 'Mitta ja integraali' },
|
||||
]
|
||||
cy.cs('courseFilter-course-dropdown').click().contains(`${courses[0].code} - ${courses[0].name}`).click()
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[0].code} - ${courses[0].name}`).click()
|
||||
checkFilteringResult(7)
|
||||
cy.cs(`courseFilter-${courses[0].code}-dropdown`).selectFromDropdown(1)
|
||||
checkFilteringResult(6)
|
||||
cy.cs('courseFilter-course-dropdown').click().contains(`${courses[1].code} - ${courses[1].name}`).click()
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[1].code} - ${courses[1].name}`).click()
|
||||
checkFilteringResult(5)
|
||||
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(3)
|
||||
checkFilteringResult(2)
|
||||
courses.forEach(({ code }) => cy.cs(`courseFilter-${code}-clear`).click())
|
||||
courses.forEach(({ code }) => {
|
||||
cy.cs(`courseFilter-${code}-clear`).click()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,13 +16,14 @@ const chooseSemester = (semester, fromOrTo) => {
|
||||
})
|
||||
}
|
||||
|
||||
const checkNumbers = (numbers, numberOfColumns) => {
|
||||
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 + 1)
|
||||
.eq(index + offset)
|
||||
.contains(number)
|
||||
})
|
||||
})
|
||||
@@ -33,7 +34,7 @@ const checkStyle = (styles, numberOfColumns) => {
|
||||
cy.get('td').should('have.length', numberOfColumns)
|
||||
styles.forEach((style, index) => {
|
||||
cy.get('td')
|
||||
.eq(index + 1)
|
||||
.eq(index + 2)
|
||||
.should('have.attr', 'style')
|
||||
.and('include', style)
|
||||
})
|
||||
@@ -64,7 +65,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, 751, 15, 40, 6, 49, 1, 24, 1059, 53, 0, 2076]
|
||||
const numbers = [36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0, 2076]
|
||||
numbers.forEach((number, index) => {
|
||||
cy.get('td')
|
||||
.eq(index + 1)
|
||||
@@ -75,12 +76,12 @@ describe('When language center is opened', () => {
|
||||
|
||||
it('Faculties tab "exceeding" button works', () => {
|
||||
cy.get('[data-cy="difference-button"]').click()
|
||||
checkNumbers([...new Array(12).fill(0), 1, 218], 15)
|
||||
checkNumbers([...new Array(12).fill(0), 1, 221], 15)
|
||||
})
|
||||
|
||||
it('Faculties tab semester selector changes numbers', () => {
|
||||
chooseSemester('Syksy 2020', 'from')
|
||||
checkNumbers([28, 6, 27, 491, 4, 10, 2, 34, 0, 9, 535, 38, 0, 1184], 15)
|
||||
checkNumbers([28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,7 +93,7 @@ describe('When language center is opened', () => {
|
||||
})
|
||||
|
||||
it('Semester tab shows numbers', () => {
|
||||
checkNumbers([69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1, 2076], 16)
|
||||
checkNumbers([69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1, 2076], 17, 'semesters')
|
||||
})
|
||||
|
||||
it('Coloring mode works on semester tab', () => {
|
||||
@@ -101,10 +102,10 @@ describe('When language center is opened', () => {
|
||||
|
||||
checkStyle(
|
||||
expectedAlphas.map(alpha => `background-color: rgba(0, 170, 0, ${alpha})`),
|
||||
16
|
||||
17
|
||||
)
|
||||
cy.get('table > tbody > tr:first').within(() => {
|
||||
cy.get('td').eq(15).should('have.attr', 'style').and('not.include', 'background-color')
|
||||
cy.get('td').eq(16).should('have.attr', 'style').and('not.include', 'background-color')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,65 +1,83 @@
|
||||
/// <reference types="cypress" />
|
||||
// Now "Class statistics" in UI
|
||||
|
||||
describe('Population Statistics tests', () => {
|
||||
const pathToMathBSc2020 =
|
||||
'/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"KH50_001"%7D&year=2020'
|
||||
const pathToMathMSc2020 =
|
||||
'/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"MH50_001"%7D&year=2020'
|
||||
describe('when using basic user', () => {
|
||||
const selectStudyProgramme = programme => {
|
||||
cy.contains('Select study programme')
|
||||
cy.get('[data-cy=select-study-programme]').click()
|
||||
cy.get('[data-cy=select-study-programme]').children().contains(programme).click()
|
||||
}
|
||||
|
||||
const selectStudyTrack = studyTrack => {
|
||||
cy.contains('Select study track')
|
||||
cy.get('[data-cy=select-study-track]').click()
|
||||
cy.get('[data-cy=select-study-track]').children().contains(studyTrack).click()
|
||||
}
|
||||
|
||||
describe('Population statistics tests', () => {
|
||||
const getPath = programme => {
|
||||
return `/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"${programme}"%7D&year=2020`
|
||||
}
|
||||
const pathToMathBSc2020 = getPath('KH50_001')
|
||||
const pathToMathMSc2020 = getPath('MH50_001')
|
||||
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/populations')
|
||||
})
|
||||
|
||||
it('Population statistics search infobox works', () => {
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('not.exist')
|
||||
cy.get('[data-cy="PopulationSearch-open-info"]').click()
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('be.visible')
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should(
|
||||
'contain',
|
||||
'lukuvuosi, jolloin opiskelija on ilmoittautunut'
|
||||
)
|
||||
cy.get('[data-cy="PopulationSearch-close-info"]').click()
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('not.exist')
|
||||
})
|
||||
describe('Population search', () => {
|
||||
it('Info box works', () => {
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('not.exist')
|
||||
cy.get('[data-cy="PopulationSearch-open-info"]').click()
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('be.visible')
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('contain', 'Tässä osiossa voi tarkastella')
|
||||
cy.get('[data-cy="PopulationSearch-close-info"]').click()
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('not.exist')
|
||||
})
|
||||
|
||||
it('Population statistics search form is usable', () => {
|
||||
cy.contains('See class').should('be.disabled')
|
||||
cy.contains('Search for class')
|
||||
cy.contains('Class of')
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get('.form-control').as('enrollmentSelect')
|
||||
it('Form is usable', () => {
|
||||
cy.contains('Search for class')
|
||||
cy.contains('See class').should('be.disabled')
|
||||
cy.contains('Class of')
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get('.form-control').as('enrollmentSelect')
|
||||
})
|
||||
|
||||
cy.get('@enrollmentSelect')
|
||||
.its(`${[0]}.value`)
|
||||
.then(beforeVal => {
|
||||
cy.get('@enrollmentSelect').click()
|
||||
cy.get('.yearSelectInput .rdtPrev').click({ force: true })
|
||||
cy.get('.yearSelectInput table').contains('2018-2019').click({ force: true })
|
||||
cy.get('@enrollmentSelect').should('not.have.value', beforeVal)
|
||||
})
|
||||
|
||||
cy.contains('No study tracks available for this programme')
|
||||
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
cy.contains('See class').should('be.enabled')
|
||||
selectStudyTrack('Matematiikka ja soveltava matematiikka')
|
||||
cy.contains('See class').should('be.enabled')
|
||||
})
|
||||
|
||||
describe('Correct population is shown for programme', () => {
|
||||
it('without study tracks', () => {
|
||||
cy.contains('Select study programme')
|
||||
selectStudyProgramme('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('See class').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2017 - 2018')
|
||||
cy.contains('class size 47 students')
|
||||
})
|
||||
|
||||
cy.get('@enrollmentSelect')
|
||||
.its(`${[0]}.value`)
|
||||
.then(beforeVal => {
|
||||
cy.get('@enrollmentSelect').click()
|
||||
cy.get('.yearSelectInput .rdtPrev').click({ force: true })
|
||||
cy.get('.yearSelectInput table').contains('2018-2019').click({ force: true })
|
||||
cy.get('@enrollmentSelect').should('not.have.value', beforeVal)
|
||||
it('with study tracks', () => {
|
||||
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
selectStudyTrack('Matematiikka ja soveltava matematiikka')
|
||||
cy.contains('See class').click()
|
||||
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma 2017 - 2018')
|
||||
cy.contains('studytrack MAST-MSM')
|
||||
cy.contains('class size 1 students')
|
||||
})
|
||||
|
||||
cy.contains('Select study programme')
|
||||
cy.get('[data-cy=select-study-programme]')
|
||||
.click()
|
||||
.children()
|
||||
.contains('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
.click()
|
||||
cy.contains('See class').should('be.enabled')
|
||||
})
|
||||
|
||||
it('Searching for population really shows population', () => {
|
||||
cy.contains('Select study programme')
|
||||
cy.get('[data-cy=select-study-programme]')
|
||||
.click()
|
||||
.children()
|
||||
.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
.click()
|
||||
cy.contains('See class').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2017 - 2018')
|
||||
cy.contains('class size 47 students')
|
||||
})
|
||||
})
|
||||
|
||||
it('Population statistics is usable on general level', () => {
|
||||
@@ -73,43 +91,6 @@ describe('Population Statistics tests', () => {
|
||||
cy.cs('filtered-students')
|
||||
})
|
||||
|
||||
it('Courses of class is displayed and link to individual course stats page works', () => {
|
||||
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.wait('@coursePage')
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT11001, 57033, AYMAT11001, A57033 Johdatus yliopistomatematiikkaan')
|
||||
})
|
||||
|
||||
it('Courses of class curriculum selection works', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.get('[data-cy=curriculum-picker]').contains('2020 - 2023')
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]')
|
||||
cy.get('[data-cy=curriculum-picker]').click().contains('2023 - 2026').click()
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
|
||||
})
|
||||
|
||||
it("Empty 'tags' tab has a link to the page where tags can be created", { retries: 2 }, () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Students (27)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
|
||||
})
|
||||
cy.get('[data-cy=student-table-tabs]').within(() => {
|
||||
cy.contains('Tags').click()
|
||||
})
|
||||
cy.contains('No tags defined. You can define them here.').find('a').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?p_m_tab=0&p_tab=4')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Create tags for study programme')
|
||||
cy.contains('button', 'Create a new tag').should('be.disabled')
|
||||
})
|
||||
|
||||
// This test sometimes fails on headless mode. It seems that the click on the
|
||||
// 'Fetch class with new settings' button doesn't always trigger history.push()
|
||||
// so the page doesn't reload. This is why waiting also doesn't help.
|
||||
@@ -136,125 +117,253 @@ describe('Population Statistics tests', () => {
|
||||
cy.url().should('include', 'semesters=FALL&semesters=SPRING')
|
||||
})
|
||||
|
||||
it("'Credits gained' tab of 'Credit statistics' shows correct statistics for all students of the class and also students grouped by admission type", () => {
|
||||
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
|
||||
cy.visit(pathToMathBSc2020)
|
||||
const totalStudents = 27
|
||||
cy.contains('Credit statistics')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.contains('Credits gained').click()
|
||||
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the class')
|
||||
const limits = [1, 45, 90, 135, 180, null]
|
||||
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
|
||||
|
||||
const getTableData = (selector, numbersOfStudents) => {
|
||||
const studentsInCategory = numbersOfStudents.reduce((acc, val) => acc + val, 0)
|
||||
return ranges.map((range, index) => ({
|
||||
selector: `[data-cy='credits-gained-table-${selector}']`,
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
students: numbersOfStudents[index],
|
||||
percentage: `${((numbersOfStudents[index] / studentsInCategory) * 100).toFixed(1)}%`,
|
||||
}))
|
||||
}
|
||||
|
||||
cy.get("[data-cy='credits-gained-table-All students of the class'] table thead tr").within(() => {
|
||||
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024 (49 months)')
|
||||
cy.get('th').eq(2).contains('Number of students')
|
||||
cy.get('th').eq(2).contains(`(n = ${totalStudents})`)
|
||||
cy.get('th').eq(3).contains('Percentage of population')
|
||||
})
|
||||
|
||||
for (const category of [
|
||||
getTableData('All students of the class', [9, 5, 7, 4, 2, 0]),
|
||||
getTableData('Avoin väylä', [0, 1, 3, 2, 0, 0]),
|
||||
getTableData('Muu', [0, 1, 1, 1, 0, 0]),
|
||||
getTableData('Todistusvalinta', [9, 3, 3, 1, 2, 0]),
|
||||
]) {
|
||||
category.forEach(({ selector, start, end, students, percentage }, index) => {
|
||||
let value
|
||||
if (start === null) {
|
||||
value = '0'
|
||||
} else if (end === null) {
|
||||
value = `${start} ≤ credits`
|
||||
} else {
|
||||
value = `${start} ≤ credits < ${end}`
|
||||
}
|
||||
cy.get(`${selector} [data-cy='credits-gained-table-body']`).within(() => {
|
||||
cy.get('tr')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
cy.get('td').eq(1).contains(value)
|
||||
cy.get('td').eq(2).contains(students)
|
||||
cy.get('td').eq(3).contains(percentage)
|
||||
})
|
||||
describe('Credit statistics', () => {
|
||||
it("'Credits gained' tab shows correct statistics for all students of the class and also students grouped by admission type", () => {
|
||||
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
|
||||
cy.visit(pathToMathBSc2020)
|
||||
const totalStudents = 27
|
||||
cy.contains('Credit statistics')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.contains('Credits gained').click()
|
||||
cy.get("[data-cy='credits-gained-main-table']").should('contain', 'All students of the class')
|
||||
const limits = [1, 45, 90, 135, 180, null]
|
||||
const ranges = limits.map((limit, i) => (i === 0 ? [null, 0] : [limits[i - 1], limit])).reverse()
|
||||
|
||||
const getTableData = (selector, numbersOfStudents) => {
|
||||
const studentsInCategory = numbersOfStudents.reduce((acc, val) => acc + val, 0)
|
||||
return ranges.map((range, index) => ({
|
||||
selector: `[data-cy='credits-gained-table-${selector}']`,
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
students: numbersOfStudents[index],
|
||||
percentage: `${((numbersOfStudents[index] / studentsInCategory) * 100).toFixed(1)}%`,
|
||||
}))
|
||||
}
|
||||
|
||||
cy.get("[data-cy='credits-gained-table-All students of the class'] table thead tr").within(() => {
|
||||
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024 (49 months)')
|
||||
cy.get('th').eq(2).contains('Number of students')
|
||||
cy.get('th').eq(2).contains(`(n = ${totalStudents})`)
|
||||
cy.get('th').eq(3).contains('Percentage of population')
|
||||
})
|
||||
}
|
||||
|
||||
cy.get(
|
||||
"[data-cy='credits-gained-table-All students of the class'] [data-cy='credits-gained-table-body'] td:nth-child(3)"
|
||||
).then(tds => {
|
||||
const sum = [...tds].reduce((acc, td) => acc + parseInt(td.innerText, 10), 0)
|
||||
expect(sum).to.equal(totalStudents)
|
||||
})
|
||||
})
|
||||
|
||||
it("'Statistics' tab of 'Credit statistics' shows correct statistics for all students of the class and also students grouped by admission type", () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Credit statistics')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
|
||||
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
|
||||
const categories = [
|
||||
{
|
||||
selector: 'All students of the population',
|
||||
data: ['3773.50', '139.76', '141.00', '60.07', '19', '268'],
|
||||
size: 27,
|
||||
},
|
||||
{
|
||||
selector: 'Muu',
|
||||
data: ['359.00', '119.67', '111.00', '43.71', '71', '177'],
|
||||
size: 3,
|
||||
},
|
||||
{
|
||||
selector: 'Todistusvalinta',
|
||||
data: ['2742.50', '152.36', '178.00', '65.75', '19', '268'],
|
||||
size: 18,
|
||||
},
|
||||
{
|
||||
selector: 'Avoin väylä',
|
||||
data: ['672.00', '112.00', '104.00', '29.40', '82', '169'],
|
||||
size: 6,
|
||||
},
|
||||
]
|
||||
|
||||
for (const { selector, data, size } of categories) {
|
||||
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
|
||||
cy.contains('h3', selector)
|
||||
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
|
||||
cy.get('table tbody').within(() => {
|
||||
rows.forEach((text, index) => {
|
||||
for (const category of [
|
||||
getTableData('All students of the class', [9, 5, 7, 4, 2, 0]),
|
||||
getTableData('Avoin väylä', [0, 1, 3, 2, 0, 0]),
|
||||
getTableData('Muu', [0, 1, 1, 1, 0, 0]),
|
||||
getTableData('Todistusvalinta', [9, 3, 3, 1, 2, 0]),
|
||||
]) {
|
||||
category.forEach(({ selector, start, end, students, percentage }, index) => {
|
||||
let value
|
||||
if (start === null) {
|
||||
value = '0'
|
||||
} else if (end === null) {
|
||||
value = `${start} ≤ credits`
|
||||
} else {
|
||||
value = `${start} ≤ credits < ${end}`
|
||||
}
|
||||
cy.get(`${selector} [data-cy='credits-gained-table-body']`).within(() => {
|
||||
cy.get('tr')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains(text)
|
||||
cy.get('td').eq(1).contains(data[index])
|
||||
cy.get('td').eq(1).contains(value)
|
||||
cy.get('td').eq(2).contains(students)
|
||||
cy.get('td').eq(3).contains(percentage)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
cy.get(
|
||||
"[data-cy='credits-gained-table-All students of the class'] [data-cy='credits-gained-table-body'] td:nth-child(3)"
|
||||
).then(tds => {
|
||||
const sum = [...tds].reduce((acc, td) => acc + parseInt(td.innerText, 10), 0)
|
||||
expect(sum).to.equal(totalStudents)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it("'Statistics' tab shows correct statistics for all students of the class and also students grouped by admission type", () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Credit statistics')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
|
||||
const rows = ['Total credits', 'Average', 'Median', 'Standard deviation', 'Minimum', 'Maximum']
|
||||
const categories = [
|
||||
{
|
||||
selector: 'All students of the population',
|
||||
data: ['3773.50', '139.76', '141.00', '60.07', '19', '268'],
|
||||
size: 27,
|
||||
},
|
||||
{
|
||||
selector: 'Muu',
|
||||
data: ['359.00', '119.67', '111.00', '43.71', '71', '177'],
|
||||
size: 3,
|
||||
},
|
||||
{
|
||||
selector: 'Todistusvalinta',
|
||||
data: ['2742.50', '152.36', '178.00', '65.75', '19', '268'],
|
||||
size: 18,
|
||||
},
|
||||
{
|
||||
selector: 'Avoin väylä',
|
||||
data: ['672.00', '112.00', '104.00', '29.40', '82', '169'],
|
||||
size: 6,
|
||||
},
|
||||
]
|
||||
|
||||
for (const { selector, data, size } of categories) {
|
||||
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
|
||||
cy.contains('h3', selector)
|
||||
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
|
||||
cy.get('table tbody').within(() => {
|
||||
rows.forEach((text, index) => {
|
||||
cy.get('tr')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains(text)
|
||||
cy.get('td').eq(1).contains(data[index])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Courses of class', () => {
|
||||
it('Is displayed and link to individual course stats page works', () => {
|
||||
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.wait('@coursePage')
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT11001 • Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('AYMAT11001 • Avoin yo: Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('57033 • Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('A57033 • Avoin yo: Johdatus yliopistomatematiikkaan')
|
||||
})
|
||||
|
||||
it('Curriculum selection works', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.get('[data-cy=curriculum-picker]').contains('2020–2023')
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]')
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
|
||||
})
|
||||
|
||||
it('New fetch of courses data is done when curriculum is changed', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v4/populationstatistics/courses').as('courseData')
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2020–2023').click({ force: true })
|
||||
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(true)
|
||||
})
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('New fetch of courses data is done when filtered students change', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v4/populationstatistics/courses').as('courseData')
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
})
|
||||
cy.get('[data-cy=GraduatedFromProgramme-filter-card]').within(() => {
|
||||
cy.get('[data-cy=GraduatedFromProgramme-header]').click()
|
||||
cy.get('[data-cy=option-graduated-true]').click()
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(16)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Students', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Students (27)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
|
||||
})
|
||||
})
|
||||
|
||||
it("'General tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('522142')
|
||||
cy.contains('Tilastotiede')
|
||||
cy.contains('Matematiikka')
|
||||
cy.contains('Teoreettisten ja laskennallisten menetelmien maisteriohjelma')
|
||||
cy.contains('Todistusvalinta')
|
||||
})
|
||||
})
|
||||
|
||||
it("'Courses tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Courses').click()
|
||||
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.contains('MAT011')
|
||||
cy.contains('MAT110')
|
||||
})
|
||||
cy.contains('Courses of class').click()
|
||||
cy.cs('curriculum-picker').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.contains('Courses of class').click()
|
||||
cy.cs('student-table-tabs').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.
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Tags').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')
|
||||
cy.cs('create-button').should('be.disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when using admin', () => {
|
||||
describe('When using admin', () => {
|
||||
it('Student list checking works as intended', () => {
|
||||
cy.init(pathToMathBSc2020, 'admin')
|
||||
const existing = '433237'
|
||||
@@ -268,7 +377,9 @@ 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).type('{enter}').type(nonExisting)
|
||||
cy.get('textarea').type(existing)
|
||||
cy.get('textarea').type('{enter}')
|
||||
cy.get('textarea').type(nonExisting)
|
||||
cy.contains('button', 'Check students').click()
|
||||
cy.contains('#checkstudentsresults', 'Results').within(() => {
|
||||
cy.contains('Student numbers in list and in Sisu').click()
|
||||
@@ -283,7 +394,7 @@ describe('Population Statistics tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('when using IAM user', () => {
|
||||
describe('When using IAM user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathBSc2020, 'onlyiamrights')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
|
||||
+108
-77
@@ -1,20 +1,24 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const student = {
|
||||
firstnames: 'Varpu Roope',
|
||||
lastname: 'Mårtensson',
|
||||
studentnumber: '550003',
|
||||
sis_person_id: 'hy-hlo-115926826',
|
||||
firstNames: 'Varpu Roope',
|
||||
lastName: 'Mårtensson',
|
||||
studentNumber: '550003',
|
||||
sisPersonId: 'hy-hlo-115926826',
|
||||
email: 'sisutestidata134902@testisisudata.fi',
|
||||
}
|
||||
|
||||
const typeToSearch = text => {
|
||||
cy.cs('student-search').type(text)
|
||||
}
|
||||
|
||||
const typeStudentNumberAndClick = studentNumber => {
|
||||
cy.get('.prompt').type(studentNumber)
|
||||
cy.contains('td a', studentNumber).click()
|
||||
typeToSearch(studentNumber)
|
||||
cy.contains('td', studentNumber).click()
|
||||
}
|
||||
|
||||
describe('Students tests', () => {
|
||||
describe('when using basic user', () => {
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init()
|
||||
cy.contains('Students').click()
|
||||
@@ -23,92 +27,112 @@ describe('Students tests', () => {
|
||||
})
|
||||
|
||||
it('Students search form is usable', () => {
|
||||
cy.get('.prompt').type(student.lastname)
|
||||
typeToSearch(student.lastName)
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name').should('not.exist')
|
||||
cy.contains('First names').should('not.exist')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.firstnames)
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name')
|
||||
cy.contains('First names')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains(student.lastName)
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name').should('not.exist')
|
||||
cy.contains('First names').should('not.exist')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
})
|
||||
|
||||
it('Search term must be at least 4 characters long', () => {
|
||||
cy.get('.prompt').type(student.lastname.slice(0, 3))
|
||||
cy.contains('No search results or search term is not accurate enough')
|
||||
cy.get('.prompt').type(student.lastname.slice(3))
|
||||
typeToSearch(student.lastName.slice(0, 3))
|
||||
cy.contains('Search term is not accurate enough')
|
||||
typeToSearch(student.lastName.slice(3))
|
||||
cy.get('table tbody tr').should('have.length', 1)
|
||||
})
|
||||
|
||||
it('can search with studentnumber too', () => {
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains(student.studentnumber)
|
||||
it('Can search with student number too', () => {
|
||||
typeToSearch(student.studentNumber)
|
||||
cy.contains(student.studentNumber)
|
||||
})
|
||||
|
||||
it('Can get student specific page by clicking student', () => {
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains('td a', student.studentnumber).click()
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma (01.08.2020–31.07.2027)')
|
||||
cy.contains(student.lastname).should('not.exist')
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.email).should('not.exist')
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.lastname)
|
||||
cy.contains(student.firstnames)
|
||||
cy.contains(student.lastName)
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains(student.email)
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.lastname).should('not.exist')
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.email).should('not.exist')
|
||||
})
|
||||
|
||||
it("'Update student' button is not shown", () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('div.ui.fluid.card').within(() => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('button', 'Update student').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('Can get back to search menu', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.go('back')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
cy.contains('Credits').should('not.exist')
|
||||
})
|
||||
|
||||
it('Can jump to course', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('Tilastollinen päättely I (MAT12004)')
|
||||
.parent()
|
||||
.siblings()
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.get('.level').click()
|
||||
cy.get('a').click()
|
||||
})
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT12004, 57046, AYMAT12004 Tilastollinen päättely I')
|
||||
cy.contains('MAT12004 • Tilastollinen päättely I')
|
||||
cy.contains('AYMAT12004 • Avoin yo: Tilastollinen päättely I')
|
||||
cy.contains('57046 • Johdatus tilastolliseen päättelyyn')
|
||||
})
|
||||
|
||||
it('Has correct Sisu link', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('[data-cy=sisulink] > a')
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('sisu-link')
|
||||
.should('have.attr', 'href')
|
||||
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sis_person_id}/basic/basic-info`)
|
||||
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sisPersonId}/basic/basic-info`)
|
||||
})
|
||||
|
||||
it('Semester enrollments can be toggled', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
const programmes = [
|
||||
'Oikeustieteen tohtoriohjelma',
|
||||
'Matemaattisten tieteiden kandiohjelma',
|
||||
'Oikeustieteen maisterin koulutusohjelmaOikeusnotaarin koulutusohjelma',
|
||||
]
|
||||
cy.get('div.ui.fluid.card').within(() => {
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('Enrollments').click()
|
||||
cy.get('table tbody tr')
|
||||
.should('have.length', 3)
|
||||
@@ -120,52 +144,57 @@ describe('Students tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Searching with bad inputs doesnt yield results', () => {
|
||||
cy.get('.prompt').type('SWAG LITTINEN')
|
||||
it("Searching with bad name doesn't yield results", () => {
|
||||
typeToSearch('SWAG LITTINEN')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
})
|
||||
|
||||
cy.get('.prompt').clear().type('01114')
|
||||
it("Searching with bad student number doesn't yield results", () => {
|
||||
typeToSearch('01114')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
})
|
||||
|
||||
it('Can jump to population page', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.contains('.ui.table', 'Completed').within(() => {
|
||||
cy.get('i.level.up.alternate.icon').click()
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('study-rights-section').within(() => {
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
})
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('class size 30 students')
|
||||
})
|
||||
|
||||
it('Grade graph works in all three different modes', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.contains('a.item', 'Grade graph').click()
|
||||
cy.contains('text.highcharts-title', 'Grade plot')
|
||||
cy.contains('a.item', 'Show group mean').click()
|
||||
cy.get('.labeled.input').within(() => {
|
||||
cy.contains('.label', 'Group size')
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('button', 'Grade graph').click()
|
||||
cy.contains('button', 'Show group mean').click()
|
||||
cy.cs('group-size-input').within(() => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').should('have.value', '5')
|
||||
})
|
||||
cy.contains('.highcharts-container text', "Nov '22")
|
||||
cy.contains('.highcharts-container text', "Jul '22").should('not.exist')
|
||||
cy.contains('text.highcharts-title', 'Grade plot')
|
||||
cy.get('.labeled.input').within(() => {
|
||||
cy.contains('.label', 'Group size')
|
||||
cy.get('input').clear().type('10')
|
||||
cy.cs('group-size-input').within(() => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').clear()
|
||||
cy.get('input').type('10')
|
||||
cy.get('input').should('have.value', '10')
|
||||
})
|
||||
cy.contains('.highcharts-container text', "Jul '22")
|
||||
cy.contains('.highcharts-container text', "Nov '22").should('not.exist')
|
||||
cy.contains('a.item', 'Show semester mean').click()
|
||||
cy.contains('button', 'Show semester mean').click()
|
||||
cy.contains('.highcharts-container text', "Jan '22")
|
||||
})
|
||||
|
||||
describe('Bachelor Honours section', () => {
|
||||
it("Shows 'Qualified for Honours' tag and main modules info when the student is qualified", () => {
|
||||
cy.visit('/students/495976')
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.green.tag.label', 'Qualified for Honours')
|
||||
cy.contains('Main courses and other modules').click()
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-qualified]', 'Qualified for Honours')
|
||||
cy.contains('Study modules').click()
|
||||
cy.get('[data-cy=main-modules] tbody tr')
|
||||
.should('have.length', 3)
|
||||
.each(($tr, index) => {
|
||||
@@ -184,54 +213,54 @@ describe('Students tests', () => {
|
||||
|
||||
it("Shows 'Did not graduate in time' when the student has graduated but not in time", () => {
|
||||
cy.visit('/students/540355')
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.red.tag.label', 'Not qualified for Honours')
|
||||
cy.contains('.red.tag.label', 'Did not graduate in time')
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
|
||||
cy.contains('[data-cy=honours-chip-error]', 'Did not graduate in time')
|
||||
})
|
||||
|
||||
it("Shows 'Module grades too low' when the student has graduated in time but has too low grades", () => {
|
||||
cy.visit('/students/547934')
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.red.tag.label', 'Not qualified for Honours')
|
||||
cy.contains('.red.tag.label', 'Module grades too low')
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
|
||||
cy.contains('[data-cy=honours-chip-error]', 'Module grades too low')
|
||||
})
|
||||
|
||||
it("Shows 'Might need further inspection' when the student has graduated in time but has more than four main modules", () => {
|
||||
cy.visit('/students/478837')
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.blue.tag.label', 'Might need further inspection')
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-inspection]', 'Might need further inspection')
|
||||
})
|
||||
|
||||
it("Shows 'Has not graduated' when the student has not graduated", () => {
|
||||
cy.visit(`students/${student.studentnumber}`)
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.red.tag.label', 'Not qualified for Honours')
|
||||
cy.contains('.red.tag.label', 'Has not graduated')
|
||||
cy.visit(`students/${student.studentNumber}`)
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
|
||||
cy.contains('[data-cy=honours-chip-error]', 'Has not graduated')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Use admin to see all students
|
||||
describe('when using admin user', () => {
|
||||
describe('When using admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/students', 'admin')
|
||||
})
|
||||
|
||||
it('Does not crash if student has no studyright or courses', () => {
|
||||
it('Does not crash if student has no study rights or courses', () => {
|
||||
typeStudentNumberAndClick('450730')
|
||||
cy.contains('Credits: 0')
|
||||
})
|
||||
|
||||
it("'Update student' button is shown", () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('div.ui.fluid.card').within(() => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('button', 'Update student')
|
||||
})
|
||||
})
|
||||
|
||||
it('Bachelor Honours section is not shown for students outside of Faculty of Science', () => {
|
||||
cy.visit('/students/453146')
|
||||
cy.contains('.divider h4', 'Bachelor Honours').should('not.exist')
|
||||
cy.contains('h2', 'Bachelor Honours').should('not.exist')
|
||||
})
|
||||
|
||||
it('When a study plan is selected, courses included in the study plan are highlighted with a blue background', () => {
|
||||
@@ -239,15 +268,17 @@ describe('Students tests', () => {
|
||||
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
|
||||
cy.get('td').eq(0).click()
|
||||
})
|
||||
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)')
|
||||
.should('have.attr', 'style')
|
||||
.and('equal', 'background-color: rgb(232, 244, 255);')
|
||||
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)').should(
|
||||
'have.css',
|
||||
'background-color',
|
||||
'rgb(232, 244, 255)'
|
||||
)
|
||||
})
|
||||
|
||||
it('When a study plan is selected, the time frame of the credit graph is updated', () => {
|
||||
cy.clock(new Date('2024-08-30').getTime(), ['Date'])
|
||||
cy.visit('/students/550789')
|
||||
cy.contains('.highcharts-container text', '2 Aug 2020')
|
||||
cy.contains('.highcharts-container text', '1 Aug 2020')
|
||||
cy.contains('.highcharts-container text', '30 Aug 2024')
|
||||
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
|
||||
cy.get('td').eq(0).click()
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('Study guidance group tests', () => {
|
||||
cy.cs('Students (3)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
|
||||
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
|
||||
expect(tabNames).to.deep.equal(['General', 'Courses', 'Progress'])
|
||||
expect(tabNames).to.deep.equal(['General', 'Courses', 'Modules', 'Progress'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('Study guidance group tests', () => {
|
||||
const firstRowHeadings = [
|
||||
'Student number',
|
||||
'Credits',
|
||||
'Start of studyright',
|
||||
'Start of study right',
|
||||
'Started in programme',
|
||||
'Semesters present',
|
||||
'Graduation date',
|
||||
@@ -141,7 +141,7 @@ describe('Study guidance group tests', () => {
|
||||
'Transferred from',
|
||||
'Admission type',
|
||||
'Gender',
|
||||
'Citizenship',
|
||||
'Citizenships',
|
||||
'Curriculum period',
|
||||
'Latest attainment date',
|
||||
'Tags',
|
||||
|
||||
+290
-214
@@ -1,29 +1,64 @@
|
||||
/* eslint-disable cypress/unsafe-to-chain-command */
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const { getEmptyYears } = require('../support/commands')
|
||||
|
||||
const deleteTag = name => {
|
||||
cy.contains('td', name).siblings().contains('Delete').click()
|
||||
cy.contains('Are you sure you want to delete tag')
|
||||
cy.contains('Confirm').click()
|
||||
cy.contains('td', name).should('not.exist')
|
||||
const tagName = `tag-${new Date().getTime()}`
|
||||
|
||||
const selectYear = year => {
|
||||
// ? Testing the MUI X DatePicker seems very difficult. It does not detect data-cy and the tests fail in CI/CD
|
||||
cy.contains('Associated start year (optional)').click({ force: true })
|
||||
cy.get('[placeholder=YYYY]').type(year)
|
||||
}
|
||||
|
||||
describe('Studyprogramme overview', () => {
|
||||
/* Basic information overview -tests */
|
||||
describe('Basic information -view works for basic user', () => {
|
||||
const deleteTag = tagName => {
|
||||
cy.cs(`delete-tag-${tagName}-button`).click()
|
||||
cy.contains('Delete tag')
|
||||
cy.contains('Are you sure you want to delete tag')
|
||||
cy.cs('confirm-delete-tag-button').click()
|
||||
}
|
||||
|
||||
describe('Study programme overview', () => {
|
||||
describe('Study programme selector', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme', 'admin')
|
||||
})
|
||||
|
||||
it('Correct categories', () => {
|
||||
cy.contains('Bachelor programmes')
|
||||
cy.contains('Master programmes')
|
||||
cy.contains('Combined programmes')
|
||||
cy.contains('Doctoral programmes')
|
||||
})
|
||||
|
||||
it('Study 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')
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').should('not.exist')
|
||||
})
|
||||
|
||||
it('Old and specialized programmes filter', () => {
|
||||
cy.contains('Biotekniikka (ylempi)').should('not.exist')
|
||||
cy.cs('filter-old-programmes-toggle').click()
|
||||
cy.contains('Biotekniikka (ylempi)').should('exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Basic information tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
})
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('Basic information -tab loads', () => {
|
||||
cy.get('[data-cy=Section-StudentsOfTheStudyprogramme]')
|
||||
cy.get('[data-cy=Section-CreditsProducedByTheStudyprogramme]')
|
||||
cy.get('[data-cy=Section-GraduatedAndThesisWritersOfTheProgramme]')
|
||||
cy.get('[data-cy=Section-ProgrammesBeforeOrAfter]')
|
||||
cy.get('[data-cy=Section-AverageGraduationTimes]')
|
||||
it('Basic information tab loads', () => {
|
||||
cy.cs('students-of-the-study-programme-section')
|
||||
cy.cs('credits-produced-by-the-study-programme-section')
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
|
||||
cy.cs('programmes-before-or-after-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
})
|
||||
|
||||
it('Basic information contains correct students', () => {
|
||||
@@ -40,7 +75,7 @@ describe('Studyprogramme overview', () => {
|
||||
[2017, 41, 47, 0, 0, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudentsOfTheStudyprogramme')
|
||||
cy.checkTableStats(tableContents, 'students-of-the-study-programme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct credits', () => {
|
||||
@@ -51,16 +86,16 @@ describe('Studyprogramme overview', () => {
|
||||
[2022, 3235, 3205, 0, 30, 209],
|
||||
[2021, 5133, 5108, 0, 25, 428],
|
||||
[2020, 5801, 5796, 0, 5, 94],
|
||||
[2019, 5295, 5295, 0, 0, 162],
|
||||
[2019, 5305, 5305, 0, 0, 162],
|
||||
[2018, 3442, 3432, 0, 10, 21],
|
||||
[2017, 1211, 1211, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyprogramme')
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
it("Toggling 'Show special categories' on displays additional information", () => {
|
||||
cy.contains('Show special categories').click()
|
||||
cy.cs('special-categories-toggle').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0]),
|
||||
@@ -68,12 +103,12 @@ describe('Studyprogramme overview', () => {
|
||||
[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, 5295, 5295, 0, 0, 0, 0, 162],
|
||||
[2019, 5305, 5305, 0, 0, 0, 0, 162],
|
||||
[2018, 3442, 3432, 0, 10, 0, 0, 21],
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyprogramme')
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct thesis writers and graduates', () => {
|
||||
@@ -81,20 +116,20 @@ describe('Studyprogramme overview', () => {
|
||||
const tableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 26, 0],
|
||||
[2022, 48, 0],
|
||||
[2021, 47, 66],
|
||||
[2020, 12, 18],
|
||||
[2019, 1, 15],
|
||||
[2023, 26, 5],
|
||||
[2022, 48, 13],
|
||||
[2021, 47, 76],
|
||||
[2020, 12, 23],
|
||||
[2019, 1, 19],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'GraduatedAndThesisWritersOfTheProgramme')
|
||||
cy.checkTableStats(tableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
it('Special studyrights can be excluded and basic data changes accordingly', () => {
|
||||
cy.get('[data-cy=StudentToggle]').click()
|
||||
it('Special study rights can be excluded and basic data changes accordingly', () => {
|
||||
cy.cs('study-right-toggle').click()
|
||||
const years = getEmptyYears()
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated]
|
||||
@@ -108,25 +143,25 @@ describe('Studyprogramme overview', () => {
|
||||
[2017, 41, 47, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyprogramme')
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const graduatedTableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 24, 0],
|
||||
[2022, 44, 0],
|
||||
[2021, 46, 60],
|
||||
[2020, 11, 17],
|
||||
[2019, 1, 15],
|
||||
[2023, 24, 5],
|
||||
[2022, 44, 12],
|
||||
[2021, 46, 69],
|
||||
[2020, 11, 22],
|
||||
[2019, 1, 18],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(graduatedTableContents, 'GraduatedAndThesisWritersOfTheProgramme')
|
||||
cy.checkTableStats(graduatedTableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
it('Year can be changed to academic year, and data changes accordingly', () => {
|
||||
cy.get('[data-cy=YearToggle]').click({ force: true })
|
||||
cy.cs('year-toggle').click()
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
const studentTableContents = [
|
||||
@@ -140,7 +175,7 @@ describe('Studyprogramme overview', () => {
|
||||
['2018 - 2019', 40, 45, 0, 0, 0],
|
||||
['2017 - 2018', 41, 47, 0, 0, 0],
|
||||
]
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyprogramme')
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const creditTableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
@@ -149,37 +184,37 @@ describe('Studyprogramme overview', () => {
|
||||
['2021 - 2022', 4092, 4042, 0, 50, 198],
|
||||
['2020 - 2021', 5420, 5415, 0, 5, 321],
|
||||
['2019 - 2020', 6043, 6043, 0, 0, 101],
|
||||
['2018 - 2019', 4846, 4841, 0, 5, 107],
|
||||
['2018 - 2019', 4856, 4851, 0, 5, 107],
|
||||
['2017 - 2018', 2350, 2345, 0, 5, 26],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyprogramme')
|
||||
cy.get('[data-cy=YearToggle]').click()
|
||||
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
|
||||
cy.cs('year-toggle').click()
|
||||
})
|
||||
|
||||
it('Basic information graphs render', () => {
|
||||
cy.get('[data-cy=Graph-StudentsOfTheStudyprogramme')
|
||||
cy.cs('students-of-the-study-programme-line-graph-section')
|
||||
.should('contain', 'Started studying')
|
||||
.should('contain', 'Accepted')
|
||||
.should('contain', 'Graduated')
|
||||
.should('contain', 'Transferred away')
|
||||
.should('contain', 'Transferred to')
|
||||
|
||||
cy.get('[data-cy=Graph-CreditsProducedByTheStudyprogramme')
|
||||
cy.cs('credits-produced-by-the-study-programme-stacked-bar-chart-section')
|
||||
.should('contain', 'Degree students')
|
||||
.should('contain', 'Transferred')
|
||||
.should('contain', 5796)
|
||||
.should('contain', 428)
|
||||
|
||||
cy.get('[data-cy=Graph-GraduatedAndThesisWritersOfTheProgramme')
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-bar-chart-section')
|
||||
.should('contain', 'Graduated students')
|
||||
.should('contain', 'Wrote thesis')
|
||||
.should('contain', 47)
|
||||
.should('contain', 66)
|
||||
.should('contain', 76)
|
||||
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
|
||||
cy.cs('bachelor-breakdown-bar-chart-section')
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2022')
|
||||
cy.contains('48 graduated').trigger('mouseover')
|
||||
@@ -190,7 +225,7 @@ describe('Studyprogramme overview', () => {
|
||||
cy.contains('14 graduated over year late')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=Graph-ProgrammesBeforeOrAfter')
|
||||
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
|
||||
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
|
||||
.should('contain', 'Datatieteen maisteriohjelma')
|
||||
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
@@ -205,12 +240,12 @@ describe('Studyprogramme overview', () => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
|
||||
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownMaster]')
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
|
||||
cy.cs('bachelor-breakdown-bar-chart-section')
|
||||
cy.cs('master-breakdown-bar-chart-section')
|
||||
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get('[data-cy=graduation-times-graphMaster]').within(() => {
|
||||
cy.contains('Master studyright')
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('master-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Master study right')
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2021')
|
||||
cy.contains('2 graduated').trigger('mouseover')
|
||||
@@ -221,8 +256,8 @@ describe('Studyprogramme overview', () => {
|
||||
cy.contains('0 graduated over year late')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
|
||||
cy.contains('Bachelor + master studyright')
|
||||
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Bachelor + master study right')
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2023')
|
||||
cy.contains('11 graduated').trigger('mouseover')
|
||||
@@ -235,39 +270,56 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/* Studytrack overview -tests */
|
||||
describe('Studytrack overview works for basic user', () => {
|
||||
describe('Study tracks and class statistics tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Studytracks and class statistics').click()
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
})
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('Studytracks and class statistics -tab loads', () => {
|
||||
cy.get('[data-cy=Section-StudytrackOverview]')
|
||||
cy.get('[data-cy=Section-StudytrackProgress]')
|
||||
cy.get('[data-cy=Section-AverageGraduationTimesStudytracks]')
|
||||
describe('Info boxes', () => {
|
||||
it('Study track overview section', () => {
|
||||
cy.cs('study-track-overview-info-box-button').click()
|
||||
cy.cs('study-track-overview-info-box-content').contains('Opiskelijat, joiden')
|
||||
})
|
||||
|
||||
it('Progress of students section', () => {
|
||||
cy.cs('progress-of-students-info-box-button').click()
|
||||
cy.cs('progress-of-students-info-box-content').contains('Kuvaa koulutusohjelmassa')
|
||||
})
|
||||
|
||||
it('Average graduation times section', () => {
|
||||
cy.cs('average-graduation-times-info-box-button').click()
|
||||
cy.cs('average-graduation-times-info-box-content').contains('Yksittäinen palkki')
|
||||
})
|
||||
})
|
||||
|
||||
it('Students of the studyprogramme are shown correctly', () => {
|
||||
it('All sections are visible', () => {
|
||||
cy.cs('study-track-selector-section')
|
||||
cy.cs('study-track-overview-section')
|
||||
cy.cs('progress-of-students-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
})
|
||||
|
||||
it('Students of the study programme are shown correctly', () => {
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 0],
|
||||
['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, 35, 3],
|
||||
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 1],
|
||||
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 34, 1],
|
||||
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
|
||||
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 3],
|
||||
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 35, 2],
|
||||
['2018 - 2019', 45, 40, 0, 0, 6, 39, 26, 19, 0, 44, 1],
|
||||
['2017 - 2018', 47, 41, 0, 0, 5, 42, 31, 16, 0, 47, 0],
|
||||
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 222, 7],
|
||||
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 224, 12],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudytrackOverview')
|
||||
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
it('Years in the students table can be expanded and study track data will be shown', () => {
|
||||
cy.get('[data-cy=Table-StudytrackOverview]').within(() => {
|
||||
it.skip('Years in the students table can be expanded and study track data will be shown', () => {
|
||||
// TODO: Fix this test
|
||||
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
@@ -276,7 +328,7 @@ describe('Studyprogramme overview', () => {
|
||||
|
||||
const dataForStudyTracks = [
|
||||
['Ekonometria, MAT-EKO', 2, 2, 0, 0, 0, 2, 1, 1, 0, 2, 0],
|
||||
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 1],
|
||||
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
|
||||
['Tietojenkäsittelyteoria, MAT-TIE', 2, 1, 0, 0, 1, 1, 2, 0, 0, 2, 0],
|
||||
['Tilastotiede, MAT-TIL', 4, 4, 0, 0, 0, 4, 1, 3, 0, 4, 0],
|
||||
]
|
||||
@@ -301,45 +353,30 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Links to class statistics page work', () => {
|
||||
cy.get('[data-cy=Table-StudytrackOverview]').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td i.level.up.alternate.icon').click()
|
||||
})
|
||||
describe('Population link button works for', () => {
|
||||
it('a single year', () => {
|
||||
cy.cs('2023-population-link-button').first().click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2023 - 2024')
|
||||
cy.contains('class size 8 students')
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains('class size 26 students')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with all years combined work', () => {
|
||||
cy.get('[data-cy=Table-StudytrackOverview]').within(() => {
|
||||
cy.get('td.total-row-cell a').click()
|
||||
it('total', () => {
|
||||
cy.cs('total-population-link-button').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('class size 227 students')
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('class size 227 students')
|
||||
})
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.cs('show-study-tracks-button').first().click()
|
||||
cy.contains('td', 'Matematiikka (MAT-MAT)').within(() => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.get('[data-cy=Table-StudytrackOverview]').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.get('td i.angle.right.icon').click()
|
||||
})
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains('studytrack MAT-MAT')
|
||||
cy.contains('class size 26 students')
|
||||
cy.contains('3 students out of 26 shown')
|
||||
})
|
||||
|
||||
cy.contains('td', 'Matematiikka, MAT-MAT').within(() => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('div.sub.header', 'studytrack MAT-MAT')
|
||||
cy.contains('class size 30 students')
|
||||
cy.contains('10 students out of 30 shown')
|
||||
})
|
||||
|
||||
it('Student progress data is shown correctly', () => {
|
||||
@@ -357,30 +394,37 @@ describe('Studyprogramme overview', () => {
|
||||
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudytrackProgress')
|
||||
cy.checkTableStats(tableContents, 'study-programme-progress')
|
||||
})
|
||||
|
||||
it('Studytrack overview graphs render', () => {
|
||||
cy.get('[data-cy=Graph-StudytrackProgress]')
|
||||
it('Progress section', () => {
|
||||
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
|
||||
|
||||
cy.get('[data-cy=Graph-StudytrackProgress]').contains('49.8%').trigger('mouseover', { force: true })
|
||||
cy.cs('programme-progress-bar-chart-section').contains('49.8%').trigger('mouseover', { force: true })
|
||||
cy.contains('At least 180 credits: 114')
|
||||
})
|
||||
|
||||
cy.get("[data-cy='graduation-times-graph-breakdownBachelor']").within(() => {
|
||||
cy.contains('Start year')
|
||||
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('Graduated On time: 15 students')
|
||||
cy.contains('On time: 15')
|
||||
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.contains('Matematiikka')
|
||||
cy.contains('MAT-MAT')
|
||||
cy.contains('On time: 14')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
|
||||
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.cs('average-graduation-times-section').within(() => {
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('19 graduated').trigger('mouseover')
|
||||
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
|
||||
@@ -391,52 +435,47 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Studytrack can be changed', () => {
|
||||
describe('Study track can be changed', () => {
|
||||
beforeEach(() => {
|
||||
cy.get('.studytrack-selector').contains('All students of the programme, KH50_001').click()
|
||||
cy.get('.studytrack-selector .visible.menu').contains('Matematiikka, MAT-MAT').click()
|
||||
cy.cs('study-track-select').click()
|
||||
cy.contains('All students of the programme')
|
||||
cy.contains('Ekonometria')
|
||||
cy.contains('Tietojenkäsittelyteoria')
|
||||
cy.contains('Tilastotiede')
|
||||
cy.contains('Matematiikka').click()
|
||||
})
|
||||
|
||||
it('Students of the study track are shown correctly', () => {
|
||||
cy.get("[data-cy='Section-StudytrackOverview']").contains('Students of the studytrack MAT-MAT by starting year')
|
||||
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]
|
||||
['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, 1],
|
||||
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 16, 1],
|
||||
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
|
||||
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 17, 1],
|
||||
['2018 - 2019', 23, 21, 0, 0, 2, 21, 11, 12, 0, 23, 0],
|
||||
['2017 - 2018', 28, 24, 0, 0, 1, 27, 15, 13, 0, 28, 0],
|
||||
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 87, 2],
|
||||
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 88, 4],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'StudytrackOverview')
|
||||
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.get('[data-cy=Table-StudytrackOverview]').within(() => {
|
||||
cy.get('tbody tr.regular-row')
|
||||
.eq(2)
|
||||
.within(() => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
})
|
||||
cy.cs('2020-population-link-button').first().click()
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('div.sub.header', 'studytrack MAT-MAT')
|
||||
cy.contains('studytrack MAT-MAT')
|
||||
cy.contains('class size 30 students')
|
||||
cy.contains('10 students out of 30 shown')
|
||||
})
|
||||
|
||||
it('Info message about missing progress stats is displayed', () => {
|
||||
cy.contains('.divider', 'Progress of students of the studytrack MAT-MAT by starting year')
|
||||
cy.contains(
|
||||
'.message',
|
||||
'Currently progress data is only available for all students of the study programme. Please select ”All students of the programme” to view the progress data.'
|
||||
)
|
||||
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.')
|
||||
})
|
||||
|
||||
it('Average graduation times are displayed correctly', () => {
|
||||
cy.get("[data-cy='Section-AverageGraduationTimesStudytracks']")
|
||||
it.skip('Average graduation times are displayed correctly', () => {
|
||||
cy.get("[data-cy='Section-averageGraduationTimesStudyTracks']")
|
||||
cy.get("[data-cy='graduation-times-graph-breakdownBachelor']").within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
@@ -444,8 +483,8 @@ describe('Studyprogramme overview', () => {
|
||||
cy.contains('Graduated On time: 9 students')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get("[data-cy='graduation-times-graphBachelor']").within(() => {
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('bachelor-median-time-bar-chart').within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('12 graduated').trigger('mouseover')
|
||||
@@ -459,17 +498,16 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/* Programme courses -tests */
|
||||
describe('Programme courses works for basic user', () => {
|
||||
describe('Programme courses tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Programme courses').click()
|
||||
cy.cs('programme-courses-tab').click()
|
||||
})
|
||||
|
||||
it('content loads', () => {
|
||||
cy.get('[data-cy=CoursesYearFilter]')
|
||||
cy.get('[data-cy=CourseTabs]')
|
||||
cy.cs('CoursesYearFilter')
|
||||
cy.cs('CourseTabs')
|
||||
})
|
||||
|
||||
it('time range selection works', () => {
|
||||
@@ -482,8 +520,8 @@ describe('Studyprogramme overview', () => {
|
||||
cy.get('td').eq(2).contains('75')
|
||||
})
|
||||
})
|
||||
cy.get('[data-cy=fromYear]').click().contains('2018').click()
|
||||
cy.get('[data-cy=toYear').click().contains('2019').click()
|
||||
cy.cs('fromYear').click().contains('2018').click()
|
||||
cy.cs('toYear').click().contains('2019').click()
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
@@ -497,9 +535,9 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
|
||||
it("'Calendar year/Academic year' toggle works", () => {
|
||||
cy.get('[data-cy=calendarAcademicYearToggle]').click()
|
||||
cy.get('[data-cy=fromYear]').contains('2017-2018')
|
||||
cy.get('[data-cy=toYear').contains('2023-2024')
|
||||
cy.cs('calendarAcademicYearToggle').click()
|
||||
cy.cs('fromYear').contains('2017-2018')
|
||||
cy.cs('toYear').contains('2023-2024')
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
@@ -512,14 +550,14 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
|
||||
it('contains correct courses in alphabetical order', () => {
|
||||
cy.get('[data-cy=CoursesSortableTable]').within(() => {
|
||||
cy.cs('CoursesSortableTable').within(() => {
|
||||
cy.get('tr').eq(1).contains('Advanced calculus')
|
||||
cy.get('tr').eq(-1).contains('Äidinkielen opinnot')
|
||||
})
|
||||
})
|
||||
|
||||
it('different sorting options work', () => {
|
||||
cy.get('[data-cy=CoursesSortableTable]').within(() => {
|
||||
cy.cs('CoursesSortableTable').within(() => {
|
||||
// Course code
|
||||
cy.get('th').eq(0).click()
|
||||
cy.get('tr')
|
||||
@@ -598,7 +636,7 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy=creditsStudentsToggle]').click()
|
||||
cy.cs('creditsStudentsToggle').click()
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] thead tr')
|
||||
.eq(0)
|
||||
@@ -647,16 +685,16 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Degree courses works for basic user', () => {
|
||||
describe('Degree courses tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Degree courses').click()
|
||||
cy.cs('degree-courses-tab').click()
|
||||
})
|
||||
|
||||
it('content loads', () => {
|
||||
cy.contains('h3', 'Select curriculum to edit:')
|
||||
cy.get('[data-cy=curriculum-picker]').contains('2023 - 2026')
|
||||
cy.cs('curriculum-picker').contains('2023–2026')
|
||||
cy.contains('Change visibility of degree courses and select criteria for academic years')
|
||||
cy.contains('form', 'First year (12 months) last set: 0')
|
||||
cy.get('table').within(() => {
|
||||
@@ -668,60 +706,80 @@ describe('Studyprogramme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/* Tag-tests */
|
||||
describe('Tags view works for basic user', () => {
|
||||
describe('Tags tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
cy.get('.attached').contains('Tags').click()
|
||||
cy.cs('tags-tab').click()
|
||||
})
|
||||
|
||||
it('can create and delete tags for population', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2022')
|
||||
cy.contains('Create a new tag').click()
|
||||
cy.contains(name)
|
||||
cy.contains('2022')
|
||||
deleteTag(name)
|
||||
it('info box', () => {
|
||||
cy.cs('create-new-tag-info-box-button').click()
|
||||
cy.cs('create-new-tag-info-box-content').contains('Here you can create')
|
||||
})
|
||||
|
||||
it('can create personal tags', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2022')
|
||||
describe('Adding tags to populations and removing them works', () => {
|
||||
beforeEach(() => {
|
||||
cy.contains('This study programme does not have any tags yet')
|
||||
cy.cs('tag-name-text-field').type(tagName)
|
||||
})
|
||||
|
||||
cy.get('.ui > label').click()
|
||||
cy.contains('Create a new tag').click()
|
||||
cy.contains(name)
|
||||
deleteTag(name)
|
||||
afterEach(() => {
|
||||
deleteTag(tagName)
|
||||
cy.contains('This study programme does not have any tags yet')
|
||||
})
|
||||
|
||||
it('can create and delete tags without start year', () => {
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.contains('No associated start year')
|
||||
})
|
||||
|
||||
it.skip('can create and delete tags with start year', () => {
|
||||
// TODO: This test fails in the pipeline, but works locally. Investigate.
|
||||
selectYear('2022')
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.contains('Associated start year 2022')
|
||||
})
|
||||
|
||||
it.skip('population link works', () => {
|
||||
// TODO: This test fails in the pipeline, but works locally. Investigate.
|
||||
selectYear('2022')
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.cs('population-link-button').click()
|
||||
cy.contains(`Tagged with: ${tagName}`)
|
||||
cy.go('back')
|
||||
})
|
||||
|
||||
it('can create personal tags', () => {
|
||||
cy.cs('personal-tag-toggle').click()
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.cs(`${tagName}-visibility-icon`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Adding tags to students and removing them works', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
const studentInput = '477806,478275; 478953 479239\n 480080'
|
||||
const studentNumbers = studentInput.match(/[^\s,;]+/g)
|
||||
|
||||
it('can add tags to students', () => {
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2022')
|
||||
cy.contains('Create a new tag').click()
|
||||
cy.contains(name)
|
||||
it.skip('can add tags to students', () => {
|
||||
// TODO: This test fails in the pipeline, but works locally. Investigate.
|
||||
cy.cs('tag-name-text-field').type(tagName)
|
||||
selectYear('2022')
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
|
||||
cy.contains('Add a tag to students').click()
|
||||
cy.get('.form > .field > .dropdown').click().get('.ui > input.search').type(name).click()
|
||||
|
||||
cy.get('.form > .field > .dropdown > .visible').contains(name).click()
|
||||
|
||||
cy.get('textarea').type(studentInput)
|
||||
cy.contains('Add tags').click()
|
||||
|
||||
cy.contains('Successfully added tags to students.')
|
||||
|
||||
cy.contains('td', name).get('i.level.up.alternate.icon').click()
|
||||
cy.cs('add-students-button').click()
|
||||
cy.cs('add-students-text-field').type(studentInput)
|
||||
cy.cs('add-students-confirm-button').click()
|
||||
cy.contains('Students added to tag')
|
||||
|
||||
cy.cs('population-link-button').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains(`Tagged with: ${name}`)
|
||||
cy.contains(`Tagged with: ${tagName}`)
|
||||
cy.contains('Students (5)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
@@ -732,17 +790,24 @@ describe('Studyprogramme overview', () => {
|
||||
cy.contains(studentNumber)
|
||||
}
|
||||
|
||||
cy.go('back')
|
||||
deleteTag(name)
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags')
|
||||
cy.contains(tagName)
|
||||
}
|
||||
|
||||
cy.get('a').contains('Matemaattisten tieteiden kandiohjelma').invoke('removeAttr', 'target').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?tab=4')
|
||||
|
||||
deleteTag(tagName)
|
||||
})
|
||||
|
||||
it('deleting a tag from tag view also removes it from students', () => {
|
||||
cy.contains(name).should('not.exist')
|
||||
cy.contains(tagName).should('not.exist')
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.contains('Students').click()
|
||||
cy.get('.prompt').type(studentNumber)
|
||||
cy.contains('a', studentNumber).click()
|
||||
cy.contains(name).should('not.exist')
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags').should('not.exist')
|
||||
cy.contains(tagName).should('not.exist')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -754,34 +819,45 @@ describe('Studyprogramme overview', () => {
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
})
|
||||
|
||||
it.skip('year selector', () => {
|
||||
// TODO: Implement this test
|
||||
// Things to test:
|
||||
// - Clicking the button works
|
||||
// - Selecting a study track works
|
||||
// - The button is disabled if only one year is selected
|
||||
// - The slider is not visible if the user does not have correct programme rights
|
||||
})
|
||||
|
||||
it('can access programme and correct tabs are visible', () => {
|
||||
cy.contains('Basic information')
|
||||
cy.contains('Studytracks and class statistics')
|
||||
cy.contains('Study tracks and class statistics')
|
||||
|
||||
cy.contains('Update statistics').should('not.exist')
|
||||
cy.contains('Degree courses').should('not.exist')
|
||||
})
|
||||
|
||||
it('can access basic information', () => {
|
||||
cy.contains('Basic information').click()
|
||||
cy.cs('basic-information-tab').click()
|
||||
|
||||
cy.get('[data-cy=Section-StudentsOfTheStudyprogramme]')
|
||||
cy.get('[data-cy=Section-CreditsProducedByTheStudyprogramme]')
|
||||
cy.get('[data-cy=Section-GraduatedAndThesisWritersOfTheProgramme]')
|
||||
cy.get('[data-cy=Section-ProgrammesBeforeOrAfter]')
|
||||
cy.get('[data-cy=Section-AverageGraduationTimes]')
|
||||
cy.cs('students-of-the-study-programme-section')
|
||||
cy.cs('credits-produced-by-the-study-programme-section')
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
cy.cs('programmes-before-or-after-section')
|
||||
})
|
||||
|
||||
it('can access studytracks', () => {
|
||||
cy.get('.attached').contains('Studytracks and class statistics').click()
|
||||
it('can access study tracks', () => {
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
|
||||
cy.get('[data-cy=Section-StudytrackOverview]')
|
||||
cy.get('[data-cy=Section-StudytrackProgress]')
|
||||
cy.get('[data-cy=Section-AverageGraduationTimesStudytracks]')
|
||||
cy.cs('study-track-overview-section')
|
||||
cy.cs('progress-of-students-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
})
|
||||
|
||||
it("doesn't see other tabs", () => {
|
||||
cy.get('div.ui.tabular.menu a').should('have.length', 2)
|
||||
cy.cs('degree-courses-tab').should('not.exist')
|
||||
cy.cs('tags-tab').should('not.exist')
|
||||
cy.cs('update-statistics-tab').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
+52
-116
@@ -1,159 +1,95 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const levels = ['Bachelors', 'BachelorMasters', 'Masters', 'Doctoral']
|
||||
const progressLevels = ['bachelors', 'bachelor-masters', 'masters', 'doctoral']
|
||||
|
||||
const checkProgressGraphs = () => {
|
||||
levels.forEach(level => cy.get(`[data-cy=Graph-Faculty${level}Progress]`))
|
||||
const checkProgressBarCharts = () => {
|
||||
progressLevels.forEach(level => cy.cs(`faculty-${level}-progress-bar-chart-section`))
|
||||
}
|
||||
|
||||
const checkProgressTables = () => {
|
||||
levels.forEach(level => cy.get(`[data-cy=Table-Faculty${level}Progress]`))
|
||||
progressLevels.forEach(level => cy.cs(`${level}-faculty-progress-table`))
|
||||
}
|
||||
|
||||
const checkAverageGraduationTimesGraphs = breakdown => {
|
||||
const levels = ['bachelor', 'bcMsCombo', 'master', 'doctor']
|
||||
levels.forEach(level => {
|
||||
cy.get(`[data-cy=Section-${level}] .graduations-chart-container .faculty-${breakdown ? 'breakdown-' : ''}graph`)
|
||||
const graduationTimesLevels = ['bachelor', 'master', 'doctor'] // ? Bachelor + master seems to be missing in test data
|
||||
|
||||
const checkAverageGraduationTimesBreakdownBarCharts = () => {
|
||||
graduationTimesLevels.forEach(level => {
|
||||
cy.cs(`${level}-breakdown-bar-chart`)
|
||||
})
|
||||
}
|
||||
|
||||
const checkProgressStatsFacultyLevelBreakdown = () => {
|
||||
cy.get('[data-cy=Table-FacultyBachelorsProgress]').within(() => {
|
||||
cy.contains('2021 - 2022').click()
|
||||
cy.contains('29.5%').trigger('mouseover', { force: true })
|
||||
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
|
||||
cy.contains('H50 - H50')
|
||||
cy.contains('0 Credits: 0')
|
||||
cy.contains('1 ≤ Credits < 45: 13')
|
||||
cy.contains('45 ≤ Credits < 90: 10')
|
||||
cy.contains('90 ≤ Credits < 135: 16')
|
||||
cy.contains('135 ≤ Credits < 180: 3')
|
||||
cy.contains('180 ≤ Credits: 2')
|
||||
})
|
||||
}
|
||||
|
||||
const checkAverageGraduationTimesFacultyLevelBreakdown = breakdown => {
|
||||
cy.get('[data-cy=Section-bachelor] .graduations-chart-container').within(() => {
|
||||
cy.contains('.graduations-message', "Click a bar to view that year's faculty level breakdown")
|
||||
cy.contains(breakdown ? '51' : '141 graduated').click()
|
||||
cy.contains('.graduations-message', "Click a bar to view that year's faculty level breakdown").should('not.exist')
|
||||
cy.contains(`.programmes-${breakdown ? 'breakdown-' : ''}graph`, 'Year 2021 by graduation year')
|
||||
const checkAverageGraduationTimesMedianBarCharts = () => {
|
||||
graduationTimesLevels.forEach(level => {
|
||||
cy.cs(`${level}-median-bar-chart`)
|
||||
})
|
||||
}
|
||||
|
||||
describe('University view', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/university')
|
||||
cy.contains('University-level view')
|
||||
cy.contains('University')
|
||||
})
|
||||
|
||||
describe('Progress stats section', () => {
|
||||
it('has all the correct progress graphs', () => {
|
||||
checkProgressGraphs()
|
||||
describe('Faculty progress tab', () => {
|
||||
it('contains all the correct progress bar charts', () => {
|
||||
checkProgressBarCharts()
|
||||
})
|
||||
|
||||
it('has all the correct progress tables', () => {
|
||||
it('contains all the correct progress tables', () => {
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it("'Graduated included/excluded' toggle works", () => {
|
||||
cy.get('[data-cy=GraduatedToggle]').click()
|
||||
checkProgressGraphs()
|
||||
it("'Graduated included / Graduated excluded' toggle works", () => {
|
||||
cy.cs('graduated-toggle').click()
|
||||
checkProgressBarCharts()
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it("'All studyrights/Special studyrights excluded' toggle works", () => {
|
||||
cy.get('[data-cy=StudentToggle]').click()
|
||||
checkProgressGraphs()
|
||||
it("'All study rights / Special study rights excluded' toggle works", () => {
|
||||
cy.cs('study-right-toggle').click()
|
||||
checkProgressBarCharts()
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it('Years in the tables can be clicked to show faculty level breakdown', () => {
|
||||
// Filtering out special study rights to make the data match with the Evaluation overview page
|
||||
cy.get('[data-cy=StudentToggle]').click()
|
||||
checkProgressStatsFacultyLevelBreakdown()
|
||||
it('years in the tables can be clicked to show faculty level breakdown', () => {
|
||||
cy.cs('study-right-toggle').click()
|
||||
cy.cs('bachelors-faculty-progress-table-show-button3').click()
|
||||
cy.contains('29.5%').trigger('mouseover', { force: true })
|
||||
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
|
||||
cy.contains('H50')
|
||||
cy.contains('0 Credits: 0')
|
||||
cy.contains('1 ≤ Credits < 45: 13')
|
||||
cy.contains('45 ≤ Credits < 90: 10')
|
||||
cy.contains('90 ≤ Credits < 135: 16')
|
||||
cy.contains('135 ≤ Credits < 180: 3')
|
||||
cy.contains('180 ≤ Credits: 2')
|
||||
})
|
||||
|
||||
it('info boxes contain correct information', () => {
|
||||
cy.cs('faculty-progress-info-box-button').click()
|
||||
cy.cs('faculty-progress-info-box-content').contains('Kuvaa tiedekuntaan kuuluvien')
|
||||
cy.cs('faculty-bachelor-masters-progress-info-box-button').click()
|
||||
cy.cs('faculty-bachelor-masters-progress-info-box-content').contains('The starting year is the')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Average graduation times section', () => {
|
||||
it('graphs exist', () => {
|
||||
checkAverageGraduationTimesGraphs(true)
|
||||
describe('Faculty graduations tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('faculty-graduations-tab').click()
|
||||
})
|
||||
|
||||
it('Faculty level breakdown can be seen by clicking a bar in the breakdown graph', () => {
|
||||
checkAverageGraduationTimesFacultyLevelBreakdown(true)
|
||||
it('has all the correct median time bar charts', () => {
|
||||
checkAverageGraduationTimesBreakdownBarCharts()
|
||||
})
|
||||
|
||||
it("'Breakdown/Median times' toggle works", () => {
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
checkAverageGraduationTimesGraphs(false)
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
checkAverageGraduationTimesMedianBarCharts()
|
||||
})
|
||||
|
||||
it('Faculty level breakdown can be seen by clicking a bar in the median times graph', () => {
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
checkAverageGraduationTimesFacultyLevelBreakdown(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// It's better to have these tests defined here than in the 'Evaluation overview' tests as they use the same data as the 'University view' tests
|
||||
describe("'Evaluation overview' university page", () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/evaluationoverview/university')
|
||||
cy.contains('University-level view')
|
||||
})
|
||||
|
||||
it('page opens', () => {
|
||||
cy.contains("This view is a combined version of Oodikone's Faculty Evaluation Overview")
|
||||
})
|
||||
|
||||
it('has links to faculty evaluation overview pages', () => {
|
||||
cy.get('.facultyLinkBox span').should('have.length', 12)
|
||||
cy.contains('.facultyLinkBox span a', 'H50 Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.url().should('include', '/evaluationoverview/faculty/H50')
|
||||
})
|
||||
|
||||
describe('Progress stats section', () => {
|
||||
it('has all the correct progress graphs', () => {
|
||||
checkProgressGraphs()
|
||||
})
|
||||
|
||||
it('has all the correct progress tables', () => {
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it("'Graduated included/excluded' toggle works", () => {
|
||||
cy.get('[data-cy=GraduatedToggle]').click()
|
||||
checkProgressGraphs()
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it("'All studyrights/Special studyrights excluded' toggle doesn't exist", () => {
|
||||
cy.get('.toggle-container [data-cy=StudentToggle]').should('not.exist')
|
||||
})
|
||||
|
||||
it('Years in the tables can be clicked to show faculty level breakdown', () => {
|
||||
checkProgressStatsFacultyLevelBreakdown()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Average graduation times section', () => {
|
||||
it('graphs exist', () => {
|
||||
checkAverageGraduationTimesGraphs(true)
|
||||
})
|
||||
|
||||
it('Faculty level breakdown can be seen by clicking a bar in the breakdown graph', () => {
|
||||
checkAverageGraduationTimesFacultyLevelBreakdown(true)
|
||||
})
|
||||
|
||||
it("'Breakdown/Median times' toggle works", () => {
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
checkAverageGraduationTimesGraphs(false)
|
||||
})
|
||||
|
||||
it('Faculty level breakdown can be seen by clicking a bar in the median times graph', () => {
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
checkAverageGraduationTimesFacultyLevelBreakdown(false)
|
||||
it('info boxes contain correct information', () => {
|
||||
cy.cs('average-graduation-times-info-box-button').click()
|
||||
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset valmistumisajat')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+50
-41
@@ -1,47 +1,53 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const visibleLinks = {
|
||||
norights: ['University', 'Faculties', 'Special populations', 'Give feedback', 'Logout'],
|
||||
onlycoursestatistics: ['University', 'Courses', 'Special populations', 'Give feedback', 'Logout'],
|
||||
norights: ['University', 'Faculties', 'Special populations', 'Feedback'],
|
||||
onlycoursestatistics: ['University', 'Courses', 'Special populations', 'Feedback'],
|
||||
}
|
||||
|
||||
visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Programmes', 'Students']
|
||||
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Users', 'Updater']
|
||||
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
|
||||
|
||||
const containsLinks = links => {
|
||||
cy.cs('nav-bar').within(() => {
|
||||
for (const link of links) {
|
||||
cy.contains(link)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const userButtonWorks = (username, mocking = false) => {
|
||||
cy.cs('nav-bar-user-button').click()
|
||||
cy.contains(mocking ? `Mocking as ${username}` : `Logged in as ${username}`)
|
||||
cy.contains('Language')
|
||||
cy.contains('suomi')
|
||||
cy.contains('English')
|
||||
cy.contains('svenska')
|
||||
cy.contains(mocking ? 'Stop mocking' : 'Log out')
|
||||
}
|
||||
|
||||
describe('Users tests', () => {
|
||||
describe('Using user with just grp-oodikone-user, no other rights', () => {
|
||||
describe('Using as user with just grp-oodikone-user, no other rights', () => {
|
||||
it('shows correct tabs', () => {
|
||||
cy.init('', 'norights')
|
||||
cy.get('[data-cy=navBar]').children().should('have.length', 7)
|
||||
cy.get('[data-cy=navBar]').within(() => {
|
||||
for (const link of visibleLinks.norights) {
|
||||
cy.contains(link)
|
||||
}
|
||||
})
|
||||
containsLinks(visibleLinks.norights)
|
||||
userButtonWorks('norights')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Using as coursestatistics user', () => {
|
||||
it('shows correct tabs', () => {
|
||||
cy.init('', 'onlycoursestatistics')
|
||||
cy.get('[data-cy=navBar]').children().should('have.length', 7)
|
||||
cy.get('[data-cy=navBar]').within(() => {
|
||||
for (const link of visibleLinks.onlycoursestatistics) {
|
||||
cy.contains(link)
|
||||
}
|
||||
})
|
||||
containsLinks(visibleLinks.onlycoursestatistics)
|
||||
userButtonWorks('onlycoursestatistics')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Using as basic user', () => {
|
||||
it('shows correct tabs', () => {
|
||||
cy.init('')
|
||||
cy.get('[data-cy=navBar]').children().should('have.length', 10)
|
||||
cy.get('[data-cy=navBar]').within(() => {
|
||||
for (const link of visibleLinks.basic) {
|
||||
cy.contains(link)
|
||||
}
|
||||
})
|
||||
containsLinks(visibleLinks.basic)
|
||||
userButtonWorks('basic')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,27 +57,30 @@ describe('Users tests', () => {
|
||||
})
|
||||
|
||||
it('should see more stuff than others', () => {
|
||||
cy.get('[data-cy=navBar]').children().should('have.length', 13)
|
||||
cy.get('[data-cy=navBar]').within(() => {
|
||||
for (const link of visibleLinks.admin) {
|
||||
cy.contains(link)
|
||||
}
|
||||
})
|
||||
containsLinks(visibleLinks.admin)
|
||||
userButtonWorks('admin')
|
||||
})
|
||||
|
||||
it("mocking normal user shows only the mocked user's programmes", () => {
|
||||
cy.contains('mocking').should('not.exist')
|
||||
cy.cs('user-edit-button-basic').click()
|
||||
cy.get('i.spy').click()
|
||||
cy.contains('mocking as basic')
|
||||
cy.contains('Programmes').click().siblings().contains('Class statistics').click()
|
||||
cy.contains('label', 'Study programme')
|
||||
.siblings()
|
||||
.within(() => {
|
||||
cy.get("div[role='option']").should('have.length', 2)
|
||||
cy.get("div[role='option']").eq(0).contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.get("div[role='option']").eq(1).contains('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
})
|
||||
describe('can mock as other users', { retries: 3 }, () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('user-page-button-basic').click()
|
||||
cy.cs('mock-button').click()
|
||||
})
|
||||
|
||||
it('user button shows mocked user', () => {
|
||||
userButtonWorks('basic', true)
|
||||
})
|
||||
|
||||
it("only the mocked user's programmes are visible", () => {
|
||||
cy.visit('/populations')
|
||||
cy.contains('label', 'Study programme')
|
||||
.siblings()
|
||||
.within(() => {
|
||||
cy.get("div[role='option']").should('have.length', 2)
|
||||
cy.get("div[role='option']").eq(0).contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.get("div[role='option']").eq(1).contains('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,8 +15,21 @@
|
||||
/**
|
||||
* @type {Cypress.PluginConfig}
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
module.exports = (on, config) => {
|
||||
// `on` is used to hook into various events Cypress emits
|
||||
// `config` is the resolved Cypress config
|
||||
on('before:browser:launch', (browser = {}, launchOptions) => {
|
||||
// By manually setting the browser width and height in headless
|
||||
// mode, screenshots and videos will have a better quality
|
||||
// https://www.cypress.io/blog/generate-high-resolution-videos-and-screenshots
|
||||
const width = 2560
|
||||
const height = 1440
|
||||
|
||||
if (browser.name === 'chrome' && browser.isHeadless) {
|
||||
launchOptions.args.push(`--window-size=${width},${height}`)
|
||||
launchOptions.args.push('--force-device-scale-factor=1')
|
||||
}
|
||||
return launchOptions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ Cypress.Commands.add('selectFromDropdown', { prevSubject: true }, (s, index) =>
|
||||
})
|
||||
|
||||
Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
|
||||
cy.get(`[data-cy=Table-${tableName}] tbody`).within(() => {
|
||||
cy.get(`[data-cy=${tableName}-data-table] tbody`).within(() => {
|
||||
correctStats.forEach((values, trIndex) => {
|
||||
cy.get('tr')
|
||||
.filter(':visible')
|
||||
|
||||
@@ -17,12 +17,8 @@
|
||||
require('@cypress/code-coverage/support')
|
||||
require('./commands')
|
||||
|
||||
// This error is benign and seems to be only present in Cypress
|
||||
const resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/
|
||||
|
||||
Cypress.on('uncaught:exception', err => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
Cypress.on('uncaught:exception', error => {
|
||||
/* returning false here prevents Cypress from failing the test */
|
||||
if (resizeObserverLoopErrRe.test(err.message)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
+4
-18
@@ -46,7 +46,7 @@ services:
|
||||
<<: *defaults
|
||||
command: >
|
||||
sh -c 'redis-server --appendonly yes && redis-cli flushall'
|
||||
image: redis:6.2.14
|
||||
image: redis:7.2.6
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
@@ -83,7 +83,7 @@ services:
|
||||
|
||||
jami-db:
|
||||
<<: *defaults
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
environment:
|
||||
- PGDATA=/data
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
@@ -118,18 +118,15 @@ services:
|
||||
- redis
|
||||
- sis-db
|
||||
- sis-importer-db
|
||||
- sis-updater-nats
|
||||
environment:
|
||||
CI: 'true'
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
SIS_IMPORTER_PASSWORD: postgres
|
||||
SIS_IMPORTER_USER: postgres
|
||||
SIS_PASSWORD: postgres
|
||||
SIS_NATS_TOKEN: dev
|
||||
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
|
||||
profiles:
|
||||
- updater
|
||||
|
||||
@@ -141,28 +138,17 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-importer-db
|
||||
- sis-updater-nats
|
||||
environment:
|
||||
CI: 'true'
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SECRET_TOKEN: dev
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
SIS_IMPORTER_PASSWORD: postgres
|
||||
SIS_IMPORTER_USER: postgres
|
||||
SIS_NATS_TOKEN: dev
|
||||
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
|
||||
SCHEDULE_IMMEDIATE: '${SCHEDULE_IMMEDIATE}'
|
||||
EXIT_AFTER_IMMEDIATES: '${EXIT_AFTER_IMMEDIATES}'
|
||||
ports:
|
||||
- 8082:8082
|
||||
profiles:
|
||||
- updater
|
||||
|
||||
sis-updater-nats:
|
||||
<<: *defaults
|
||||
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_channels 1000 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
|
||||
container_name: sis-updater-nats
|
||||
image: nats-streaming:0.25.6
|
||||
profiles:
|
||||
- updater
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
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}
|
||||
|
||||
|
||||
+7
-18
@@ -64,6 +64,7 @@ services:
|
||||
environment:
|
||||
REACT_APP_SERVICE_PROVIDER: fd
|
||||
REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED: 'false'
|
||||
REACT_APP_SIS_URL: 'https://sis-qa.funidata.fi'
|
||||
volumes:
|
||||
- ./services/frontend/src/:/opt/app-root/src/src
|
||||
- ./services/shared/:/opt/app-root/src/src/shared
|
||||
@@ -75,7 +76,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
ports:
|
||||
- 12349:5432
|
||||
volumes:
|
||||
@@ -85,7 +86,7 @@ services:
|
||||
<<: *defaults
|
||||
command: 'redis-server --appendonly yes'
|
||||
container_name: redis
|
||||
image: redis:6.2.14
|
||||
image: redis:6.2.16
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
@@ -98,19 +99,13 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
volumes:
|
||||
- sis-db-data:/data
|
||||
shm_size: 2gb
|
||||
ports:
|
||||
- 12345:5432
|
||||
|
||||
sis-updater-nats:
|
||||
<<: *defaults
|
||||
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_channels 1000 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
|
||||
container_name: sis-updater-nats
|
||||
image: nats-streaming:0.25.6
|
||||
|
||||
sis-updater-scheduler:
|
||||
<<: *defaults
|
||||
build:
|
||||
@@ -121,17 +116,14 @@ services:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-updater-nats
|
||||
environment:
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
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
|
||||
SIS_NATS_TOKEN: dev
|
||||
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
|
||||
SERVICE_PROVIDER: fd
|
||||
ports:
|
||||
- 8082:8082
|
||||
@@ -149,20 +141,17 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-db
|
||||
- sis-updater-nats
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
environment:
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
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
|
||||
SIS_NATS_TOKEN: dev
|
||||
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
|
||||
ROOT_ORG_ID: ${ROOT_ORG_ID}
|
||||
SERVICE_PROVIDER: fd
|
||||
volumes:
|
||||
@@ -175,7 +164,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
volumes:
|
||||
- user-db-data:/data
|
||||
ports:
|
||||
|
||||
+4
-18
@@ -76,7 +76,7 @@ services:
|
||||
<<: *defaults
|
||||
command: 'redis-server --appendonly yes'
|
||||
container_name: redis
|
||||
image: redis:6.2.14
|
||||
image: redis:7.2.6
|
||||
ports:
|
||||
- '6379:6379'
|
||||
volumes:
|
||||
@@ -103,14 +103,6 @@ services:
|
||||
ports:
|
||||
- 12346:5432
|
||||
|
||||
sis-updater-nats:
|
||||
<<: *defaults
|
||||
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_channels 1000 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
|
||||
container_name: sis-updater-nats
|
||||
image: nats-streaming:0.25.6
|
||||
profiles:
|
||||
- updater
|
||||
|
||||
sis-updater-scheduler:
|
||||
<<: *defaults
|
||||
build:
|
||||
@@ -120,16 +112,13 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-importer-db
|
||||
- sis-updater-nats
|
||||
environment:
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SECRET_TOKEN: dev
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
SIS_IMPORTER_PASSWORD: postgres
|
||||
SIS_IMPORTER_USER: postgres
|
||||
SIS_NATS_TOKEN: dev
|
||||
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
|
||||
ports:
|
||||
- 8082:8082
|
||||
profiles:
|
||||
@@ -147,17 +136,14 @@ services:
|
||||
- redis
|
||||
- sis-db
|
||||
- sis-importer-db
|
||||
- sis-updater-nats
|
||||
environment:
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
SIS_IMPORTER_PASSWORD: postgres
|
||||
SIS_IMPORTER_USER: postgres
|
||||
SIS_PASSWORD: postgres
|
||||
SIS_NATS_TOKEN: dev
|
||||
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
|
||||
profiles:
|
||||
- updater
|
||||
volumes:
|
||||
@@ -189,7 +175,7 @@ services:
|
||||
|
||||
jami-db:
|
||||
<<: *defaults
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
environment:
|
||||
- PGDATA=/data
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
|
||||
@@ -8,24 +8,22 @@ Roles are defined [here](../services/backend/src/config/roles.ts). Criteria for
|
||||
- courseStatistics
|
||||
- facultyStatistics
|
||||
- fullSisuAccess
|
||||
- katselmusViewer
|
||||
- openUniSearch
|
||||
- studyGuidanceGroups
|
||||
- teachers
|
||||
|
||||
The roles `openUniSearch`, `teachers` and `studyGuidanceGroups` simply enable certain views (_Open uni student population_, _Teachers_ and _Guidance groups_ respectively).
|
||||
The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply enable certain views (_Faculties_, _Open uni student population_, and _Guidance groups_ respectively).
|
||||
|
||||
### courseStatistics
|
||||
|
||||
- Can see statistics of every course but year statistics of ≤ 5 students are hidden
|
||||
- If the user has [full rights](#programmes) to **any** programme, they can also see exact statistics even if there are ≤ 5 students (the same behavior as with `admin` or `fullSisuAccess` roles)
|
||||
|
||||
### facultyStatistics
|
||||
### teachers
|
||||
|
||||
- Can see statistics of every course and year
|
||||
|
||||
### katselemusViewer
|
||||
|
||||
- Enables the _Evaluation overview_ view
|
||||
- By default, the role only allows seeing the statistics of course providers (study programmes) they have full rights to (see [Programmes](#programmes))
|
||||
- This role always needs to be given and removed manually
|
||||
- If the user is a member of the `hy-dekaanit` or `hy-varadekaanit-opetus` IAM group (or is an admin), they can see leaderboards, information about individual teachers, and statistics of all course providers. They don't need to be given the `teachers` role manually.
|
||||
|
||||
### Other roles
|
||||
|
||||
@@ -46,7 +44,6 @@ The roles `openUniSearch`, `teachers` and `studyGuidanceGroups` simply enable ce
|
||||
| Completed courses | ✅ | ✅ | ✅ |
|
||||
| Close to graduation | | ✅ | ✅ |
|
||||
| Open uni population | | ✅ | |
|
||||
| Evaluation overview | | ✅ | |
|
||||
| Courses | | ✅ | ✅ |
|
||||
|
||||
\* = anyone who can log in to Oodikone, check the [IAM groups](#iam-groups) section for more information
|
||||
@@ -66,8 +63,6 @@ A user must be a member of either the `grp-oodikone-basic-users` or `grp-oodikon
|
||||
| grp-oodikone-basic-users | courseStatistics |
|
||||
| grp-oodikone-users | facultyStatistics |
|
||||
| grp-toska | admin |
|
||||
| grp-katselmus-\* | katselmusViewer |
|
||||
| hy-one | teachers |
|
||||
| hy-ypa-opa-dojo | openUniSearch |
|
||||
|
||||
`grp-oodikone-basic-users`: basically the whole staff (hy-\*-allstaff)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,127 @@
|
||||
# How to update the database schema
|
||||
|
||||
These instructions explain how to update the schemas for the following databases used by Oodikone:
|
||||
|
||||
- `sis-db`
|
||||
- `user-db`
|
||||
- `kone-db`
|
||||
|
||||
The steps to **add, remove, or modify columns** are mostly the same for each database, but the file paths and container names differ. Always **double-check** that you have the correct function name, column name, data type, and other relevant details in your migration file.
|
||||
|
||||
> [!NOTE]
|
||||
> If you need to change the `sis-importer-db` schema, see the instructions in the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips).
|
||||
|
||||
## General guidelines
|
||||
|
||||
### File names
|
||||
|
||||
- Name new migration files using the format `YYYYMMDD_XX_description.js`.
|
||||
- The `XX` part is a migration number (starting from `00` each day).
|
||||
- Example: `20250122_00_add_column_to_table.js`.
|
||||
|
||||
### Model vs. column names
|
||||
|
||||
- Use **camelCase** in your model definitions.
|
||||
- Use **snake_case** in the actual database columns.
|
||||
- If a model has `underscored: true`, Sequelize automatically converts camelCase to snake_case in all queries. Otherwise, specify the column name explicitly.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
|
||||
|
||||
### Verifying the migration locally
|
||||
|
||||
- Migrations are run automatically whenever the server restarts.
|
||||
- After creating a migration, ensure the relevant service is running:
|
||||
- For `sis-db`: `npm run both:real` or `npm run updater:real`
|
||||
- For `user-db`/`kone-db`: `npm run both:real` or `npm run oodikone:real`
|
||||
- **Connect to the database container** and check:
|
||||
|
||||
```bash
|
||||
docker exec -it <container-name> psql -U postgres <database-name>-real
|
||||
```
|
||||
|
||||
Replace `<container-name>` and `<database-name>` with the actual values (e.g., `sis-db`, `user-db`, or `kone-db`).
|
||||
|
||||
- In the psql prompt, verify your migration:
|
||||
|
||||
```sql
|
||||
SELECT * FROM migrations;
|
||||
```
|
||||
|
||||
Also confirm that the new (or updated) column exists in the appropriate table and has the correct type.
|
||||
|
||||
### Reverting a migration locally
|
||||
|
||||
If you need to **revert a migration locally**, follow these steps:
|
||||
|
||||
1. Locate the `migrator.up()` function in the code:
|
||||
- For `user-db` and `kone-db`: [`services/backend/src/database/connection.ts`](../services/backend/src/database/connection.ts)
|
||||
- For `sis-db`: [`updater/sis-updater-worker/src/db/connection.js`](../updater/sis-updater-worker/src/db/connection.js)
|
||||
1. Change `migrator.up()` to `migrator.down()` and save the file. The latest migration will be reverted automatically when the server reloads.
|
||||
1. **Important:** After the migration is reverted, change `migrator.down()` back to `migrator.up()` to ensure future migrations run correctly.
|
||||
1. For more instructions, see the [umzug documentation](https://github.com/sequelize/umzug?tab=readme-ov-file#usage).
|
||||
|
||||
### Deploying changes to production
|
||||
|
||||
- **Push changes** to the `master` branch and wait for the workflow to finish.
|
||||
- For **`sis-db`** migrations (in the updater):
|
||||
- You **do not** need to create a new release. Once tests pass, changes to the updater go directly to production.
|
||||
- For **`user-db`** and **`kone-db`** migrations (in the backend):
|
||||
- A new release must be made before the changes go to production.
|
||||
- After deployment, check logs (for example, in OpenShift) to confirm the migration ran successfully.
|
||||
- Optionally, use the `db-cli` tool to inspect the production database. See `how_to_db_cli.md` (in GitLab) for instructions.
|
||||
|
||||
## `sis-db`
|
||||
|
||||
> [!NOTE]
|
||||
> These instructions assume that needed data is already in the `sis-importer-db`. If not, you must add the data there first. See the [sis-importer repo](https://github.com/UniversityOfHelsinkiCS/sis-importer/blob/master/README.md#tricks--tips) for more details.
|
||||
|
||||
1. **Update the updater model:**
|
||||
|
||||
- Go to `updater/sis-updater-worker/src/db/models/` and choose the model you want to change.
|
||||
- Add or modify the column (with its **type** and any other necessary fields).
|
||||
- Check the correct data type in the importer.
|
||||
- Use camelCase in the model, snake_case in the database.
|
||||
|
||||
1. **Update the backend model:**
|
||||
|
||||
- Go to `services/backend/src/models/`.
|
||||
- The models are in different formats (JavaScript in the updater, TypeScript in the backend), but follow existing examples.
|
||||
|
||||
1. **Create a migration file:**
|
||||
|
||||
- Go to `updater/sis-updater-worker/src/db/migrations/`.
|
||||
- Name your file using the format described above (e.g. `20250122_00_add_column_to_table.js`).
|
||||
- **Remember**: The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
|
||||
|
||||
1. **Run and verify the migration:**
|
||||
|
||||
- Ensure that the updater is running (`npm run both:real` or `npm run updater:real`).
|
||||
- Use the steps in [General guidelines](#general-guidelines) to verify.
|
||||
|
||||
1. **Push to master and deploy:**
|
||||
- Your migration will go to production automatically after tests pass.
|
||||
- Check logs in production (OpenShift) or use `db-cli` to confirm success.
|
||||
|
||||
## `user-db` and `kone-db`
|
||||
|
||||
1. **Update the model:**
|
||||
|
||||
- Go to `services/backend/src/models/kone` (for `kone-db`) or `services/backend/src/models/user.ts` (the only model for user-db)
|
||||
- Add or modify the column, type, and any other needed fields. Follow existing models for structure.
|
||||
|
||||
1. **Create a migration file:**
|
||||
|
||||
- Go to `services/backend/src/database/`.
|
||||
- Use `migrations_kone` (for `kone-db`) or `migrations_user` (for `user-db`).
|
||||
- Name the file using the same `YYYYMMDD_XX_description.js` format.
|
||||
- **Remember**: The column name **in the database** (snake_case) must be used in the migration file, not the camelCase name from your model.
|
||||
|
||||
1. **Run and verify the migration:**
|
||||
|
||||
- Make sure Oodikone is running (`npm run both:real` or `npm run oodikone:real`).
|
||||
- Follow the same verification steps as in [General Guidelines](#general-guidelines).
|
||||
|
||||
1. **Push to master and release:**
|
||||
- Wait for the CI workflow to pass.
|
||||
- **Create a new release**; then check the production logs (in OpenShift) to confirm the migrationor use `db-cli`.
|
||||
@@ -42,6 +42,10 @@ A collection of useful tools, documentation and other resources.
|
||||
|
||||
## User interface
|
||||
|
||||
- [Material UI documentation](https://mui.com/material-ui/getting-started/)
|
||||
- [Material Icons](https://mui.com/material-ui/material-icons/)
|
||||
- [MUI X Date pickers](https://mui.com/x/react-date-pickers/)
|
||||
- [Material React Table documentation](https://www.material-react-table.com/docs)
|
||||
- [Semantic UI React documentation](https://react.semantic-ui.com)
|
||||
|
||||
<!--
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import pluginJs from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import pluginCypress from 'eslint-plugin-cypress/flat'
|
||||
import pluginImport from 'eslint-plugin-import'
|
||||
import pluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import pluginReact from 'eslint-plugin-react'
|
||||
import pluginReactHooks from 'eslint-plugin-react-hooks'
|
||||
import tsEslint from 'typescript-eslint'
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
{
|
||||
files: ['**/*.{js,ts,jsx,tsx}'],
|
||||
},
|
||||
{
|
||||
ignores: ['eslint.config.mjs', '**/build/', '**/dist/', '**/node_modules/'],
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: [
|
||||
'services/frontend/tsconfig.json',
|
||||
'services/frontend/tsconfig.node.json',
|
||||
'services/backend/tsconfig.json',
|
||||
'services/shared/tsconfig.json',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
...tsEslint.configs.recommendedTypeChecked,
|
||||
...tsEslint.configs.stylisticTypeChecked,
|
||||
pluginCypress.configs.recommended,
|
||||
pluginReact.configs.flat.recommended,
|
||||
pluginReact.configs.flat['jsx-runtime'],
|
||||
pluginImport.flatConfigs.recommended,
|
||||
pluginImport.flatConfigs.typescript,
|
||||
pluginPrettierRecommended, // This must be the last plugin so it can override other configs
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': pluginReactHooks,
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
camelcase: 'off', // TODO: Enable this rule (at least for frontend)
|
||||
'class-methods-use-this': 'error',
|
||||
'consistent-return': 'error',
|
||||
'id-denylist': ['error', 'c', 'd', 'e', 'err', 't'],
|
||||
'import/no-commonjs': 'error',
|
||||
'import/no-default-export': 'error',
|
||||
'import/no-extraneous-dependencies': ['error', { devDependencies: false }],
|
||||
'import/no-unused-modules': ['error', { missingExports: true, unusedExports: true }],
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
|
||||
},
|
||||
],
|
||||
'no-alert': 'error',
|
||||
'no-async-promise-executor': 'error',
|
||||
'no-await-in-loop': 'error',
|
||||
'no-console': 'error',
|
||||
'no-implied-eval': 'error',
|
||||
'no-param-reassign': ['error', { props: false }],
|
||||
'no-promise-executor-return': 'error',
|
||||
'no-return-assign': 'error',
|
||||
'object-shorthand': ['error', 'always'],
|
||||
'prefer-const': 'error',
|
||||
'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
|
||||
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
|
||||
'react/display-name': 'off', // TODO: Delete this override
|
||||
'react/function-component-definition': [
|
||||
'error',
|
||||
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
|
||||
],
|
||||
'react/jsx-boolean-value': ['error', 'never'],
|
||||
'react/jsx-filename-extension': ['error', { allow: 'as-needed', extensions: ['.jsx', '.tsx'] }],
|
||||
'react/jsx-sort-props': ['error', { reservedFirst: false }],
|
||||
'react/no-array-index-key': 'error',
|
||||
'react/no-this-in-sfc': 'error',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/no-unknown-property': 'off', // TODO: Delete this override
|
||||
'react/prefer-stateless-function': 'error',
|
||||
'react/prop-types': 'off',
|
||||
...pluginReactHooks.configs.recommended.rules,
|
||||
'react-hooks/exhaustive-deps': 'warn', // TODO: Delete this override
|
||||
'@typescript-eslint/array-type': 'off',
|
||||
'@typescript-eslint/consistent-indexed-object-style': 'off',
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-misused-promises': 'off', // Most of these errors come from Express route handlers that are handled correctly by express-async-errors
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', caughtErrors: 'none', ignoreRestSiblings: true, varsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/restrict-template-expressions': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
rules: {
|
||||
...tsEslint.configs.disableTypeChecked.rules,
|
||||
'dot-notation': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['services/backend/src/rapodiff/**/*'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['services/**/shared/**/*'],
|
||||
rules: {
|
||||
'import/no-unused-modules': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['cypress/**/*.js', 'services/backend/**/*.js', 'updater/**/*.js'],
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'import/no-commonjs': 'off',
|
||||
'import/no-unused-modules': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['services/backend/**/*.{js,ts}', 'updater/**/*.js'],
|
||||
rules: {
|
||||
// TODO: Most of these overrides should probably be removed
|
||||
'consistent-return': 'off',
|
||||
'import/no-default-export': 'off',
|
||||
'import/no-unused-modules': 'off',
|
||||
'no-await-in-loop': 'off',
|
||||
'no-param-reassign': 'off',
|
||||
'no-promise-executor-return': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
'import/internal-regex': '^@/shared/',
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: [
|
||||
'services/frontend/tsconfig.json',
|
||||
'services/frontend/tsconfig.node.json',
|
||||
'services/backend/tsconfig.json',
|
||||
'services/shared/tsconfig.json',
|
||||
],
|
||||
},
|
||||
},
|
||||
react: {
|
||||
version: '18.3',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
Generated
+1722
-1331
File diff suppressed because it is too large
Load Diff
+20
-22
@@ -7,7 +7,7 @@
|
||||
"cypress:run": "cypress run --browser chrome",
|
||||
"instrument": "./scripts/instrument.sh",
|
||||
"eslint": "eslint",
|
||||
"lint:all": "eslint --ext .js,.jsx,.ts,.tsx services/ updater/ --report-unused-disable-directives",
|
||||
"lint:all": "eslint services/ updater/",
|
||||
"stylelint": "stylelint",
|
||||
"actionlint": "node-actionlint",
|
||||
"prettier": "prettier",
|
||||
@@ -38,29 +38,27 @@
|
||||
},
|
||||
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
|
||||
"devDependencies": {
|
||||
"@cypress/code-coverage": "^3.12.39",
|
||||
"@cypress/code-coverage": "^3.13.12",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"cypress": "^13.2.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-custom-alias": "^1.3.2",
|
||||
"eslint-plugin-cypress": "^3.0.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"husky": "^9.0.0",
|
||||
"lint-staged": "^15.0.0",
|
||||
"cypress": "^14.1.0",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"eslint-import-resolver-typescript": "^3.8.3",
|
||||
"eslint-plugin-cypress": "^4.2.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.4.3",
|
||||
"node-actionlint": "^1.2.2",
|
||||
"nyc": "^17.0.0",
|
||||
"prettier": "3.3.3",
|
||||
"stylelint": "^16.2.1",
|
||||
"stylelint-config-standard": "^36.0.0"
|
||||
"nyc": "^17.1.0",
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "^16.15.0",
|
||||
"stylelint-config-standard": "^37.0.0",
|
||||
"typescript-eslint": "^8.26.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
+7
-6
@@ -5,18 +5,19 @@
|
||||
{
|
||||
"groupName": "all non-major dependencies",
|
||||
"groupSlug": "all-minor-patch",
|
||||
"matchPackagePatterns": ["*"],
|
||||
"matchUpdateTypes": ["minor", "patch"]
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"matchCurrentVersion": "!/^0/",
|
||||
"matchPackageNames": ["*"]
|
||||
},
|
||||
{
|
||||
"matchDatasources": ["docker"],
|
||||
"matchPackageNames": ["node"],
|
||||
"versionCompatibility": "^(?<version>[^-]+)(?<compatibility>-.*)?$",
|
||||
"versioning": "node"
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
"rebaseWhen": "never",
|
||||
"vulnerabilityAlerts": {
|
||||
"labels": ["security"]
|
||||
}
|
||||
},
|
||||
"addLabels": ["dependencies"],
|
||||
"rangeStrategy": "bump"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM registry.access.redhat.com/ubi9/nodejs-20-minimal AS build
|
||||
FROM registry.access.redhat.com/ubi9/nodejs-22-minimal AS build
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
@@ -28,7 +28,7 @@ RUN npm run build
|
||||
|
||||
|
||||
|
||||
FROM registry.access.redhat.com/ubi9/nodejs-20-minimal
|
||||
FROM registry.access.redhat.com/ubi9/nodejs-22-minimal
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
require('./src/util/sentry') // Sentry must be the first import
|
||||
require('./src/app')
|
||||
const logger = require('./src/util/logger')
|
||||
|
||||
|
||||
Generated
+1170
-466
File diff suppressed because it is too large
Load Diff
@@ -5,46 +5,44 @@
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/node": "^5.30.0",
|
||||
"@sentry/tracing": "^6.18.2",
|
||||
"axios": "^0.28.1",
|
||||
"bullmq": "^5.0.0",
|
||||
"compression": "^1.7.4",
|
||||
"@sentry/node": "^8.55.0",
|
||||
"@sentry/profiling-node": "^8.55.0",
|
||||
"axios": "^1.8.1",
|
||||
"bullmq": "^5.41.8",
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^3.0.0",
|
||||
"express": "^4.19.2",
|
||||
"cron": "^4.1.0",
|
||||
"express": "^4.21.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.0.0",
|
||||
"lru-cache": "^11.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.37",
|
||||
"morgan": "^1.10.0",
|
||||
"oodikone-shared": "file:../oodikone-shared",
|
||||
"papaparse": "^5.4.1",
|
||||
"pg": "^8.11.5",
|
||||
"papaparse": "^5.5.2",
|
||||
"pg": "^8.13.3",
|
||||
"redis": "^4.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sequelize": "^6.37.3",
|
||||
"sequelize": "^6.37.6",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"umzug": "^3.8.1",
|
||||
"umzug": "^3.8.2",
|
||||
"unfuck-utf8-headers-middleware": "^1.0.1",
|
||||
"winston": "^3.8.2",
|
||||
"winston": "^3.17.0",
|
||||
"winston-gelf-transporter": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.14.2",
|
||||
"@types/validator": "^13.12.0",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.3"
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/validator": "^13.12.2",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"overrides": {
|
||||
"winston-gelf-transporter": {
|
||||
"gelf-pro": "^1.3.4"
|
||||
"gelf-pro": "^1.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,6 @@ const isDev = process.env.NODE_ENV === 'development'
|
||||
const isStaging = process.env.REACT_APP_STAGING === 'true'
|
||||
const isProduction = !isStaging && process.env.NODE_ENV === 'production'
|
||||
|
||||
// Sentry
|
||||
const sentryRelease = process.env.SENTRY_RELEASE || ''
|
||||
const sentryEnvironment = process.env.SENTRY_ENVIRONMENT || ''
|
||||
const sentryDSN = process.env.SENTRY_DSN || ''
|
||||
const runningInCI = process.env.CI === 'true'
|
||||
|
||||
// IAM group
|
||||
@@ -60,6 +56,7 @@ let DB_MAX_CRON_CONNECTIONS = DB_MAX_CONNECTIONS - 5
|
||||
if (DB_MAX_CRON_CONNECTIONS < 1) {
|
||||
DB_MAX_CRON_CONNECTIONS = 1
|
||||
}
|
||||
const CRON_SCHEDULE = process.env.CRON_SCHEDULE || '0 23 * * *' // Default to 23:00 daily
|
||||
|
||||
const redis = process.env.REDIS
|
||||
const DB_SCHEMA_KONE = process.env.DB_SCHEMA_KONE || 'public'
|
||||
@@ -74,6 +71,7 @@ module.exports = {
|
||||
DB_URL_KONE,
|
||||
DB_MAX_CONNECTIONS,
|
||||
DB_MAX_CRON_CONNECTIONS,
|
||||
CRON_SCHEDULE,
|
||||
redis,
|
||||
SECRET_TOKEN,
|
||||
DB_SCHEMA_KONE,
|
||||
@@ -84,9 +82,6 @@ module.exports = {
|
||||
SIS_DB_URL,
|
||||
SIS_UPDATER_URL,
|
||||
runningInCI,
|
||||
sentryRelease,
|
||||
sentryEnvironment,
|
||||
sentryDSN,
|
||||
isProduction,
|
||||
pateToken,
|
||||
jamiUrl,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Linter was not having any of this so neeeded to add resolveJsonModule:true to tsconfig
|
||||
// Linter was not having any of this so needed to add resolveJsonModule:true to tsconfig
|
||||
// export const { facultyCodes, ignoredFacultyCodes, magicFacultyCode } = require('../environment/organizationConfig.json')
|
||||
|
||||
export { facultyCodes, ignoredFacultyCodes, magicFacultyCode } from '../environment/organizationConfig.json'
|
||||
|
||||
@@ -3,7 +3,6 @@ export const roles = [
|
||||
'courseStatistics',
|
||||
'facultyStatistics',
|
||||
'fullSisuAccess',
|
||||
'katselmusViewer',
|
||||
'openUniSearch',
|
||||
'studyGuidanceGroups',
|
||||
'teachers',
|
||||
|
||||
@@ -118,7 +118,7 @@ const sequelizeKone = new Sequelize(conf.DB_URL_KONE!, {
|
||||
],
|
||||
})
|
||||
|
||||
sequelizeKone.query(`SET SESSION search_path to ${conf.DB_SCHEMA_KONE}`)
|
||||
void sequelizeKone.query(`SET SESSION search_path to ${conf.DB_SCHEMA_KONE}`)
|
||||
|
||||
const sequelizeUser = new Sequelize(conf.DB_URL_USER!, {
|
||||
logging: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CronJob } from 'cron'
|
||||
|
||||
import { isProduction, runningInCI, languageCenterViewEnabled } from './config'
|
||||
import { isProduction, runningInCI, languageCenterViewEnabled, CRON_SCHEDULE } from './config'
|
||||
import { getDegreeProgrammesOfFaculty } from './services/faculty/faculty'
|
||||
import { getFaculties } from './services/faculty/facultyHelpers'
|
||||
import { updateFacultyOverview, updateFacultyProgressOverview } from './services/faculty/facultyUpdates'
|
||||
@@ -8,8 +8,8 @@ import { computeLanguageCenterData, LANGUAGE_CENTER_REDIS_KEY } from './services
|
||||
import { findStudentsCloseToGraduation, CLOSE_TO_GRADUATION_REDIS_KEY } from './services/populations/closeToGraduation'
|
||||
import { redisClient } from './services/redis'
|
||||
import { getCurrentSemester } from './services/semesters'
|
||||
import { combinedStudyprogrammes, isRelevantProgramme } from './services/studyProgramme/studyProgrammeHelpers'
|
||||
import { updateBasicView, updateStudytrackView } from './services/studyProgramme/studyProgrammeUpdates'
|
||||
import { combinedStudyProgrammes, isRelevantProgramme } from './services/studyProgramme/studyProgrammeHelpers'
|
||||
import { updateBasicView, updateStudyTrackView } from './services/studyProgramme/studyProgrammeUpdates'
|
||||
import { findAndSaveTeachers } from './services/teachers/top'
|
||||
import { deleteOutdatedUsers } from './services/userService'
|
||||
import logger from './util/logger'
|
||||
@@ -25,7 +25,10 @@ const schedule = (cronTime: string, onTick: () => void) => {
|
||||
export const refreshCloseToGraduating = async () => {
|
||||
logger.info('Refreshing students close to graduating')
|
||||
const updatedData = await findStudentsCloseToGraduation()
|
||||
await redisClient.set(CLOSE_TO_GRADUATION_REDIS_KEY, JSON.stringify(updatedData))
|
||||
await redisClient.set(
|
||||
CLOSE_TO_GRADUATION_REDIS_KEY,
|
||||
JSON.stringify({ ...updatedData, lastUpdated: new Date().toISOString() })
|
||||
)
|
||||
logger.info('Students close to graduating updated!')
|
||||
}
|
||||
|
||||
@@ -61,10 +64,10 @@ const refreshProgrammesAndFaculties = async () => {
|
||||
|
||||
export const refreshProgramme = async (code: string) => {
|
||||
await updateBasicView(code, '')
|
||||
await updateStudytrackView(code, '')
|
||||
const combinedProgramme = combinedStudyprogrammes[code] || ''
|
||||
await updateStudyTrackView(code, '')
|
||||
const combinedProgramme = combinedStudyProgrammes[code] || ''
|
||||
await updateBasicView(code, combinedProgramme)
|
||||
await updateStudytrackView(code, combinedProgramme)
|
||||
await updateStudyTrackView(code, combinedProgramme)
|
||||
}
|
||||
|
||||
export const refreshProgrammes = async () => {
|
||||
@@ -102,8 +105,8 @@ const dailyJobs = async () => {
|
||||
|
||||
export const startCron = () => {
|
||||
if (isProduction && !runningInCI) {
|
||||
logger.info('Cronjob for refreshing stats started: runs daily at 23:00')
|
||||
schedule('0 23 * * *', async () => {
|
||||
logger.info(`Cronjob for refreshing stats started: runs at "${CRON_SCHEDULE}"`)
|
||||
schedule(CRON_SCHEDULE, async () => {
|
||||
logger.info('Running daily jobs from cron')
|
||||
await dailyJobs()
|
||||
})
|
||||
|
||||
@@ -40,15 +40,15 @@ const accessLogger = morgan((tokens, req: Request, res: Response): undefined =>
|
||||
|
||||
const message = [
|
||||
`${user.mockedBy ? '(mocking) ' : ''}${user.name}`,
|
||||
`${(tokens['response-time'](req, res) || '0').split('.')[0]} ms`.padEnd(8, ' '),
|
||||
`${(tokens['response-time'](req, res) ?? '0').split('.')[0]} ms`.padEnd(8, ' '),
|
||||
tokens.status(req, res),
|
||||
tokens.method(req, res),
|
||||
decodeURIComponent(tokens.url(req, res) as string),
|
||||
decodeURIComponent(tokens.url(req, res)!),
|
||||
].join(' ')
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(user.programmeRights)
|
||||
const iamBasedRights = user.programmeRights.filter(right => right.isIamBased)
|
||||
const usingIamRights = iamBasedRights.some(right => (tokens.url(req, res) as string).includes(right.code))
|
||||
const usingIamRights = iamBasedRights.some(right => tokens.url(req, res)!.includes(right.code))
|
||||
const onlyIamRights = !user.isAdmin && fullStudyProgrammeRights.length === 0
|
||||
|
||||
logger.info(message, {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { Role } from '../types'
|
||||
import { Role } from '../shared/types'
|
||||
|
||||
export const roles = (requiredRoles: Role[]) => async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { roles } = req.user
|
||||
if (requiredRoles.some(role => roles.includes(role)) || roles.includes('admin')) {
|
||||
return next()
|
||||
export const roles =
|
||||
(requiredRoles: Role[], requiredIamGroups?: string[]) => (req: Request, res: Response, next: NextFunction) => {
|
||||
const { roles, iamGroups } = req.user
|
||||
if (
|
||||
requiredRoles.some(role => roles.includes(role)) ||
|
||||
requiredIamGroups?.some(group => iamGroups.includes(group)) ||
|
||||
roles.includes('admin')
|
||||
) {
|
||||
return next()
|
||||
}
|
||||
|
||||
res.status(403).json({ error: 'Missing required roles or IAM groups' })
|
||||
}
|
||||
|
||||
res.status(403).json({ error: 'Missing required roles' })
|
||||
}
|
||||
|
||||
@@ -25,42 +25,28 @@ const getUser = async (
|
||||
iamAccess: IamAccess
|
||||
) => {
|
||||
if (showAsUser && specialGroup?.superAdmin) {
|
||||
return await getMockedUser({ userToMock: showAsUser as string, mockedBy: username as string })
|
||||
return await getMockedUser({ userToMock: showAsUser, mockedBy: username })
|
||||
}
|
||||
return await getUserToska({
|
||||
username: username as string,
|
||||
name: name as string,
|
||||
email: email as string,
|
||||
username,
|
||||
name,
|
||||
email,
|
||||
iamGroups,
|
||||
specialGroup,
|
||||
sisId: sisId as string,
|
||||
sisId,
|
||||
iamAccess,
|
||||
})
|
||||
}
|
||||
|
||||
type Headers = {
|
||||
host?: string
|
||||
'user-agent'?: string
|
||||
accept?: string
|
||||
'accept-language'?: string
|
||||
'accept-encoding'?: string
|
||||
referer?: string
|
||||
displayname?: string
|
||||
hygroupcn?: string
|
||||
hypersonsisuid?: string
|
||||
mail?: string
|
||||
remote_user?: string
|
||||
'shib-session-id'?: string
|
||||
shib_logout_url?: string
|
||||
uid?: string
|
||||
'x-show-as-user'?: string
|
||||
connection?: string
|
||||
cookie?: string
|
||||
'sec-fetch-dest'?: string
|
||||
'sec-fetch-mode'?: string
|
||||
'sec-fetch-site'?: string
|
||||
'if-none-match'?: string
|
||||
priority?: string
|
||||
}
|
||||
|
||||
const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunction) => {
|
||||
@@ -87,8 +73,8 @@ const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunct
|
||||
)
|
||||
}
|
||||
|
||||
const iamGroups = parseIamGroups(hygroupcn as string)
|
||||
const { iamAccess = {}, specialGroup = {} } = await getOrganizationAccess(sisId as string, iamGroups)
|
||||
const iamGroups = parseIamGroups(hygroupcn!)
|
||||
const { iamAccess = {}, specialGroup = {} } = await getOrganizationAccess(sisId!, iamGroups)
|
||||
const iamRights = Object.keys(iamAccess)
|
||||
|
||||
if (!hasRequiredIamGroup(iamGroups, iamRights)) {
|
||||
@@ -107,7 +93,7 @@ const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunct
|
||||
Sentry.setUser({ username: user.mockedBy ?? username! })
|
||||
|
||||
req.user = user
|
||||
req.logoutUrl = logoutUrl as string
|
||||
req.logoutUrl = logoutUrl!
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
@@ -50,11 +49,10 @@ export class SISStudyRight extends Model<InferAttributes<SISStudyRight>> {
|
||||
@Column(DataType.BOOLEAN)
|
||||
cancelled!: boolean
|
||||
|
||||
@ForeignKey(() => Student)
|
||||
@Column(DataType.STRING)
|
||||
studentNumber!: string
|
||||
|
||||
@BelongsTo(() => Student)
|
||||
@BelongsTo(() => Student, { foreignKey: 'studentNumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
|
||||
@ForeignKey(() => StudyrightExtent)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
@@ -12,7 +11,8 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { DegreeProgrammeType, Name, Phase, StudyTrack } from '../types'
|
||||
import { Name } from '../shared/types'
|
||||
import { DegreeProgrammeType, Phase, StudyTrack } from '../types'
|
||||
import { SISStudyRight } from './SISStudyRight'
|
||||
|
||||
@Table({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../types'
|
||||
import { Name } from '../shared/types'
|
||||
import { CourseProvider } from './courseProvider'
|
||||
import { CourseType } from './courseType'
|
||||
import { Credit } from './credit'
|
||||
@@ -57,15 +56,6 @@ export class Course extends Model<InferAttributes<Course>> {
|
||||
@Column(DataType.JSONB)
|
||||
name!: Name
|
||||
|
||||
@Column(DataType.DATE)
|
||||
latest_instance_date!: Date
|
||||
|
||||
@Column(DataType.DATE)
|
||||
startdate!: Date
|
||||
|
||||
@Column(DataType.DATE)
|
||||
enddate!: Date
|
||||
|
||||
@Column(DataType.DATE)
|
||||
max_attainment_date!: Date
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, ForeignKey, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
import { Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Course } from './course'
|
||||
import { Organization } from './organization'
|
||||
@@ -9,18 +8,8 @@ import { Organization } from './organization'
|
||||
underscored: true,
|
||||
modelName: 'course_provider',
|
||||
tableName: 'course_providers',
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['coursecode', 'organizationcode'],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class CourseProvider extends Model<InferAttributes<CourseProvider>> {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
composite!: string
|
||||
|
||||
@ForeignKey(() => Course)
|
||||
@Column(DataType.STRING)
|
||||
coursecode!: string
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../types'
|
||||
import { Name } from '../shared/types'
|
||||
import { Course } from './course'
|
||||
|
||||
@Table({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
Column,
|
||||
CreatedAt,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
Model,
|
||||
PrimaryKey,
|
||||
Table,
|
||||
Unique,
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
import { Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Credit } from './credit'
|
||||
import { Teacher } from './teacher'
|
||||
@@ -21,11 +10,6 @@ import { Teacher } from './teacher'
|
||||
tableName: 'credit_teachers',
|
||||
})
|
||||
export class CreditTeacher extends Model<InferAttributes<CreditTeacher>> {
|
||||
@PrimaryKey
|
||||
@Unique
|
||||
@Column(DataType.STRING)
|
||||
composite!: string
|
||||
|
||||
@ForeignKey(() => Credit)
|
||||
@Column(DataType.STRING)
|
||||
credit_id!: string
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { CreditTypeCode, Name } from '../types'
|
||||
import { Name } from '../shared/types'
|
||||
import { CreditTypeCode } from '../types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../types'
|
||||
import { Name } from '../shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { CreditTypeCode } from '../types'
|
||||
import { Course } from './course'
|
||||
import { CourseProvider } from './courseProvider'
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsToMany,
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../types'
|
||||
import { Name } from '../shared/types'
|
||||
import { Course, CourseProvider, ProgrammeModule, SISStudyRight } from '.'
|
||||
|
||||
@Table({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
@@ -13,7 +12,8 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { DegreeProgrammeType, Name } from '../types'
|
||||
import { Name } from '../shared/types'
|
||||
import { DegreeProgrammeType } from '../types'
|
||||
import { Organization } from './organization'
|
||||
import { ProgrammeModuleChild } from './programmeModuleChild'
|
||||
|
||||
@@ -70,6 +70,9 @@ export class ProgrammeModule extends Model<InferAttributes<ProgrammeModule>> {
|
||||
@Column(DataType.STRING)
|
||||
degreeProgrammeType!: DegreeProgrammeType | null
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
minimumCredits!: number | null
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, ForeignKey, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../types'
|
||||
import { Name } from '../shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { GenderCode } from '../types'
|
||||
import { Credit, Enrollment, SISStudyRight, Studyplan } from '.'
|
||||
|
||||
@@ -27,7 +27,7 @@ export class Student extends Model<InferAttributes<Student>> {
|
||||
@HasMany(() => Enrollment, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
enrollments!: Enrollment[]
|
||||
|
||||
@HasMany(() => SISStudyRight)
|
||||
@HasMany(() => SISStudyRight, { foreignKey: 'studentNumber', sourceKey: 'studentnumber' })
|
||||
studyRights!: SISStudyRight[]
|
||||
|
||||
@HasMany(() => Studyplan, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
@@ -57,23 +57,8 @@ export class Student extends Model<InferAttributes<Student>> {
|
||||
@Column(DataType.STRING)
|
||||
phone_number!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
country_fi!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
country_sv!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
country_en!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
home_country_fi!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
home_country_sv!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
home_country_en!: string
|
||||
@Column(DataType.JSONB)
|
||||
citizenships!: Name[]
|
||||
|
||||
@Column(DataType.STRING)
|
||||
gender_code!: GenderCode
|
||||
@@ -81,6 +66,12 @@ export class Student extends Model<InferAttributes<Student>> {
|
||||
@Column(DataType.STRING)
|
||||
sis_person_id!: string
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
hasPersonalIdentityCode!: boolean
|
||||
|
||||
@Column(DataType.STRING)
|
||||
preferredLanguage!: string
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
@@ -44,6 +43,9 @@ export class Studyplan extends Model<InferAttributes<Studyplan>> {
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
included_courses!: string[]
|
||||
|
||||
@Column({ type: DataType.ARRAY(DataType.TEXT), field: 'included_modules' })
|
||||
includedModules!: string[]
|
||||
|
||||
@Column(DataType.STRING)
|
||||
sisu_id!: string
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { BelongsToMany, Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Default, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
|
||||
|
||||
import { Language, Role } from '../types'
|
||||
import { Language } from '../shared/language'
|
||||
import { Role } from '../shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const { orderBy } = require('lodash')
|
||||
|
||||
// dbConnections must be imported to avoid Sequelize errors even if it's not used
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { dbConnections } = require('../database/connection')
|
||||
const { computeCreditsProduced } = require('../services/providerCredits')
|
||||
const { parseCsv, printProgressBar } = require('./helpers')
|
||||
@@ -68,7 +69,7 @@ const diff = (rapoData, okData, code) => {
|
||||
}
|
||||
}
|
||||
|
||||
const process = async data => {
|
||||
const processProgrammes = async data => {
|
||||
let programmeCounter = 0
|
||||
const rapoProgrammeData = formatData(data.slice(1))
|
||||
const allProgrammeCodes = [...new Set(Object.keys(rapoProgrammeData))]
|
||||
@@ -87,7 +88,7 @@ const process = async data => {
|
||||
|
||||
const programmeCreditsDiff = async fileName => {
|
||||
console.log('Running studyprogramme credits diff')
|
||||
await parseCsv(fileName, process)
|
||||
await parseCsv(fileName, processProgrammes)
|
||||
console.log('Diff completed.')
|
||||
}
|
||||
|
||||
|
||||
@@ -36,14 +36,8 @@ import university from './routes/university'
|
||||
import updater from './routes/updater'
|
||||
import usersToska from './routes/users'
|
||||
import usersFd from './routes/usersFd'
|
||||
import initializeSentry from './util/sentry'
|
||||
|
||||
const routes = (app: Express, url: string) => {
|
||||
initializeSentry(app)
|
||||
|
||||
app.use(Sentry.Handlers.requestHandler())
|
||||
app.use(Sentry.Handlers.tracingHandler())
|
||||
|
||||
app.use(cors({ credentials: true, origin: frontUrl }))
|
||||
app.use(express.json())
|
||||
app.use(compression())
|
||||
@@ -71,7 +65,7 @@ const routes = (app: Express, url: string) => {
|
||||
app.use(`${url}/faculties`, faculties)
|
||||
app.use(`${url}/university`, university)
|
||||
app.use(`${url}/updater`, auth.roles(['admin']), updater)
|
||||
app.use(`${url}/teachers`, auth.roles(['teachers']), teachers)
|
||||
app.use(`${url}/teachers`, teachers)
|
||||
if (serviceProvider === 'toska') {
|
||||
app.use(`${url}/users`, usersToska)
|
||||
} else {
|
||||
@@ -84,11 +78,10 @@ const routes = (app: Express, url: string) => {
|
||||
app.use(`${url}/close-to-graduation`, auth.roles(['fullSisuAccess', 'studyGuidanceGroups']), closeToGraduation)
|
||||
app.use(`${url}/study-programme-pins`, studyProgrammePins)
|
||||
app.use(`${url}/curriculum-periods`, curriculumPeriods)
|
||||
app.get('*', async (_, res) => {
|
||||
const results = { error: 'unknown endpoint' }
|
||||
res.status(404).json(results)
|
||||
app.get('*', (_, res) => {
|
||||
res.status(404).json({ error: 'unknown endpoint' })
|
||||
})
|
||||
app.use(Sentry.Handlers.errorHandler())
|
||||
Sentry.setupExpressErrorHandler(app)
|
||||
app.use(errorHandler)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,39 +2,48 @@ import axios from 'axios'
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { isDev } from '../config'
|
||||
import { Release } from '../shared/types'
|
||||
|
||||
const router = Router()
|
||||
|
||||
type ChangeLogData = {
|
||||
description: string
|
||||
time: Date
|
||||
title: string
|
||||
}
|
||||
|
||||
const changelog: { data?: ChangeLogData[] } = {}
|
||||
const changelog: { data?: Release[] } = {}
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
if (changelog.data) {
|
||||
return res.status(200).send(changelog.data)
|
||||
}
|
||||
if (isDev) {
|
||||
const fakeChangeLogData: ChangeLogData[] = [
|
||||
const fakeRelease: Release[] = [
|
||||
{
|
||||
description: "### Fake release\nLet's not spam the GitHub API in development!",
|
||||
title: 'Fake title for fake release',
|
||||
time: new Date(),
|
||||
description: '**Feature 1**\n- Added a fancy new feature \n\n**Feature 2**\n- Fixed a bug\n- Fixed another bug',
|
||||
title: 'Release 3',
|
||||
time: new Date().toISOString(),
|
||||
version: '0.0.3',
|
||||
},
|
||||
{
|
||||
description: "Let's not spam the GitHub API in development!",
|
||||
title: 'Release 2',
|
||||
time: new Date().toISOString(),
|
||||
version: '0.0.2',
|
||||
},
|
||||
{
|
||||
description: 'This release should not be visible on the frontpage',
|
||||
title: 'Release 1',
|
||||
time: new Date().toISOString(),
|
||||
version: '0.0.1',
|
||||
},
|
||||
]
|
||||
return res.status(200).json(fakeChangeLogData)
|
||||
return res.status(200).json(fakeRelease)
|
||||
}
|
||||
const response = await axios.get('https://api.github.com/repos/UniversityOfHelsinkiCS/oodikone/releases')
|
||||
const newChangelogData = response.data.map((release: Record<string, any>) => ({
|
||||
const releasesFromAPI: Release[] = response.data.map((release: Record<string, any>) => ({
|
||||
description: release.body,
|
||||
time: release.created_at,
|
||||
time: release.published_at,
|
||||
title: release.name,
|
||||
version: release.tag_name,
|
||||
}))
|
||||
changelog.data = newChangelogData
|
||||
res.status(200).send(newChangelogData)
|
||||
changelog.data = releasesFromAPI
|
||||
res.status(200).json(releasesFromAPI)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import crypto from 'crypto'
|
||||
import { Request, Response, Router } from 'express'
|
||||
import moment from 'moment'
|
||||
|
||||
import { getCourseYearlyStats } from '../services/courses'
|
||||
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
|
||||
import { CourseWithSubsId } from '../types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
|
||||
|
||||
const router = Router()
|
||||
@@ -22,24 +22,45 @@ router.get('/v2/coursesmulti', async (req: GetCoursesRequest, res: Response) =>
|
||||
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
|
||||
}
|
||||
|
||||
let results: { courses: CourseWithSubsId[] } = { courses: [] }
|
||||
results = await getCoursesByNameAndOrCode(name, code)
|
||||
const courses = await getCoursesByNameAndOrCode(name, code)
|
||||
|
||||
if (combineSubstitutions === 'false') {
|
||||
const courseCodes = results.courses.map(course => course.code)
|
||||
const courseCodes = courses.map(course => course.code)
|
||||
const substitutions = [
|
||||
...new Set(
|
||||
results.courses
|
||||
.flatMap(course => course.substitutions)
|
||||
.filter(code => code !== null && !courseCodes.includes(code))
|
||||
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
|
||||
),
|
||||
]
|
||||
const substitutionDetails = await getCoursesByCodes(substitutions)
|
||||
for (const substitution of substitutionDetails) {
|
||||
results.courses.push(substitution.dataValues)
|
||||
courses.push(substitution.toJSON())
|
||||
}
|
||||
}
|
||||
res.json(results)
|
||||
|
||||
const mergedCourses = {}
|
||||
|
||||
for (const course of courses) {
|
||||
const groupId = combineSubstitutions === 'true' ? course.subsId! : course.code
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) {
|
||||
continue
|
||||
}
|
||||
if (!mergedCourses[groupId]) {
|
||||
mergedCourses[groupId] = {
|
||||
...course,
|
||||
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
|
||||
}
|
||||
} else {
|
||||
const mergedCourse = mergedCourses[groupId]
|
||||
if (moment(mergedCourse.max_attainment_date).isBefore(course.max_attainment_date)) {
|
||||
mergedCourse.max_attainment_date = course.max_attainment_date
|
||||
}
|
||||
if (moment(mergedCourse.min_attainment_date).isAfter(course.min_attainment_date)) {
|
||||
mergedCourse.min_attainment_date = course.min_attainment_date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ courses: Object.values(mergedCourses) })
|
||||
})
|
||||
|
||||
interface GetCourseYearlyStatsRequest extends Request {
|
||||
@@ -54,7 +75,7 @@ router.get('/v3/courseyearlystats', async (req: GetCourseYearlyStatsRequest, res
|
||||
const { roles, programmeRights } = req.user
|
||||
|
||||
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
|
||||
const userHasCorrectRole = userHasFullAccessToStudentData || roles.includes('courseStatistics')
|
||||
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
|
||||
|
||||
@@ -19,7 +19,7 @@ interface GetSearchRequest extends Request {
|
||||
}
|
||||
|
||||
router.get('/', async (req: GetSearchRequest, res: Response) => {
|
||||
const courseCodes = JSON.parse(req.query?.courselist as string) || []
|
||||
const courseCodes = JSON.parse(req.query?.courselist) || []
|
||||
const startDate = req.query?.startdate !== 'null' ? new Date(req.query.startdate) : new Date(2017, 7, 1, 0, 0, 0)
|
||||
const endDate = req.query?.enddate !== 'null' ? new Date(req.query.enddate) : new Date()
|
||||
if (req.query?.enddate === 'null') {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import * as auth from '../middleware/auth'
|
||||
import { getDegreeProgrammesOfFaculty } from '../services/faculty/faculty'
|
||||
import { getDegreeProgrammesOfFaculty, getFacultyCodeById } from '../services/faculty/faculty'
|
||||
import { combineFacultyBasics } from '../services/faculty/facultyBasics'
|
||||
import { getFacultyCredits } from '../services/faculty/facultyCredits'
|
||||
import { countGraduationTimes } from '../services/faculty/facultyGraduationTimes'
|
||||
import { getSortedFaculties } from '../services/faculty/facultyHelpers'
|
||||
import { getFacultiesForFacultyList } from '../services/faculty/facultyHelpers'
|
||||
import {
|
||||
getBasicStats,
|
||||
setBasicStats,
|
||||
@@ -22,7 +22,7 @@ import { combineFacultyStudentProgress } from '../services/faculty/facultyStuden
|
||||
import { combineFacultyStudents } from '../services/faculty/facultyStudents'
|
||||
import { combineFacultyThesisWriters } from '../services/faculty/facultyThesisWriters'
|
||||
import { updateFacultyOverview, updateFacultyProgressOverview } from '../services/faculty/facultyUpdates'
|
||||
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../types'
|
||||
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
// Faculty uses a lot of tools designed for Study programme.
|
||||
@@ -31,7 +31,7 @@ import logger from '../util/logger'
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const faculties = await getSortedFaculties()
|
||||
const faculties = await getFacultiesForFacultyList()
|
||||
res.json(faculties)
|
||||
})
|
||||
|
||||
@@ -43,34 +43,31 @@ interface GetStatsRequest extends Request {
|
||||
}
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/:id/basicstats',
|
||||
auth.roles(['facultyStatistics', 'katselmusViewer']),
|
||||
async (req: GetStatsRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
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 programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
|
||||
if (!programmes.length) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
// 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)
|
||||
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()
|
||||
}
|
||||
)
|
||||
|
||||
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 programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
|
||||
if (!programmes.length) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
interface GetCreditStatsRequest extends Request {
|
||||
query: {
|
||||
@@ -78,44 +75,38 @@ interface GetCreditStatsRequest extends Request {
|
||||
}
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/:id/creditstats',
|
||||
auth.roles(['facultyStatistics', 'katselmusViewer']),
|
||||
async (req: GetCreditStatsRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
const { year_type: yearType } = req.query
|
||||
const stats = await getFacultyCredits(code, yearType === 'ACADEMIC_YEAR')
|
||||
return res.json(stats)
|
||||
router.get('/:id/creditstats', auth.roles(['facultyStatistics']), async (req: GetCreditStatsRequest, res: Response) => {
|
||||
const facultyId = req.params.id
|
||||
const code = await getFacultyCodeById(facultyId)
|
||||
const { year_type: yearType } = req.query
|
||||
const stats = await getFacultyCredits(code, yearType === 'ACADEMIC_YEAR')
|
||||
return res.json(stats)
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/:id/thesisstats',
|
||||
auth.roles(['facultyStatistics', 'katselmusViewer']),
|
||||
async (req: GetStatsRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
|
||||
if (!programmes.length) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
let updatedStats: any = await combineFacultyThesisWriters(code, programmes, yearType, specialGroups)
|
||||
if (updatedStats) {
|
||||
updatedStats = await setThesisWritersStats(updatedStats, yearType, programmeFilter, specialGroups)
|
||||
}
|
||||
return res.json(updatedStats)
|
||||
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)
|
||||
}
|
||||
)
|
||||
|
||||
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
|
||||
if (!programmes.length) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -125,9 +116,10 @@ interface GetGraduationStatsRequest extends Request {
|
||||
|
||||
router.get(
|
||||
'/:id/graduationtimes',
|
||||
auth.roles(['facultyStatistics', 'katselmusViewer']),
|
||||
auth.roles(['facultyStatistics']),
|
||||
async (req: GetGraduationStatsRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
const facultyId = req.params.id
|
||||
const code = await getFacultyCodeById(facultyId)
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
@@ -160,9 +152,10 @@ interface GetProgressStatsRequest extends Request {
|
||||
|
||||
router.get(
|
||||
'/:id/progressstats',
|
||||
auth.roles(['facultyStatistics', 'katselmusViewer']),
|
||||
auth.roles(['facultyStatistics']),
|
||||
async (req: GetProgressStatsRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
const facultyId = req.params.id
|
||||
const code = await getFacultyCodeById(facultyId)
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
@@ -190,9 +183,10 @@ interface GetStudentStatsRequest extends GetProgressStatsRequest {}
|
||||
|
||||
router.get(
|
||||
'/:id/studentstats',
|
||||
auth.roles(['facultyStatistics', 'katselmusViewer']),
|
||||
auth.roles(['facultyStatistics']),
|
||||
async (req: GetStudentStatsRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
const facultyId = req.params.id
|
||||
const code = await getFacultyCodeById(facultyId)
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
@@ -225,9 +219,10 @@ interface GetUpdateBasicViewRequest extends Request {
|
||||
|
||||
router.get(
|
||||
'/:id/update_basicview',
|
||||
auth.roles(['facultyStatistics', 'katselmusViewer']),
|
||||
auth.roles(['facultyStatistics']),
|
||||
async (req: GetUpdateBasicViewRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
const facultyId = req.params.id
|
||||
const code = await getFacultyCodeById(facultyId)
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
@@ -243,23 +238,20 @@ router.get(
|
||||
}
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/:id/update_progressview',
|
||||
auth.roles(['facultyStatistics', 'katselmusViewer']),
|
||||
async (req: Request, res: Response) => {
|
||||
const code = req.params.id
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
try {
|
||||
const result = await updateFacultyProgressOverview(code)
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
const message = `Failed to update progress tab stats for faculty ${code}`
|
||||
logger.error(message, { error })
|
||||
return res.status(500).json({ error: message })
|
||||
}
|
||||
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)
|
||||
} catch (error) {
|
||||
const message = `Failed to update progress tab stats for faculty ${code}`
|
||||
logger.error(message, { error })
|
||||
return res.status(500).json({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Request, Router } from 'express'
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { sendFeedbackToToska } from '../services/mailService'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
@@ -12,7 +12,7 @@ interface EmailRequest extends Request {
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/email', async (req: EmailRequest) => {
|
||||
router.post('/email', async (req: EmailRequest, res: Response) => {
|
||||
const { content } = req.body
|
||||
const { user } = req
|
||||
|
||||
@@ -26,6 +26,7 @@ router.post('/email', async (req: EmailRequest) => {
|
||||
})
|
||||
|
||||
logger.info(`${user.userId} succesfully sent feedback to Toska`)
|
||||
return res.status(200).end()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ApplicationError } from '../util/customErrors'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
const { user, logoutUrl } = req
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -6,20 +6,23 @@ import { difference, intersection, uniq } from 'lodash'
|
||||
import { rootOrgId } from '../config'
|
||||
import { SISStudyRight } from '../models'
|
||||
import { maxYearsToCreatePopulationFrom, getCourseProvidersForCourses } from '../services/courses'
|
||||
import { encrypt, decrypt } from '../services/encrypt'
|
||||
import { encrypt, type EncrypterData } from '../services/encrypt'
|
||||
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from '../services/faculty/faculty'
|
||||
import { bottlenecksOf } from '../services/populations/bottlenecksOf'
|
||||
import { Bottlenecks, bottlenecksOf } from '../services/populations/bottlenecksOf'
|
||||
import { optimizedStatisticsOf } from '../services/populations/optimizedStatisticsOf'
|
||||
import { populationStudentsMerger, populationCourseStatsMerger } from '../services/statMerger'
|
||||
import { findByTag, findByCourseAndSemesters } from '../services/students'
|
||||
import { mapToProviders } from '../shared/util/mapToProviders'
|
||||
import { findByCourseAndSemesters } from '../services/students'
|
||||
import { mapToProviders } from '../shared/util'
|
||||
import { GenderCode, ParsedCourse, Unarray, Unification, UnifyStatus } from '../types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, safeJSONParse } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const filterPersonalTags = (population: Record<string, any>, userId: string) => {
|
||||
type populationPersonalTagFilter = {
|
||||
[key: string]: any
|
||||
students: any[]
|
||||
}
|
||||
|
||||
const filterPersonalTags = (population: populationPersonalTagFilter, userId: string) => {
|
||||
return {
|
||||
...population,
|
||||
students: population.students.map(student => {
|
||||
@@ -29,267 +32,165 @@ const filterPersonalTags = (population: Record<string, any>, userId: string) =>
|
||||
}
|
||||
}
|
||||
|
||||
type EncryptedStudent = EncrypterData
|
||||
const isEncryptedStudent = (student?: string | EncryptedStudent) => {
|
||||
return (student as EncryptedStudent)?.encryptedData !== undefined
|
||||
}
|
||||
|
||||
type EncryptedStudent = { iv: string; encryptedData: string }
|
||||
|
||||
interface PostPopulationStatisticsRequest extends Request {
|
||||
body: {
|
||||
year: string
|
||||
semesters: string[]
|
||||
studyRights: string | string[]
|
||||
months: number
|
||||
selectedStudents: string[] | EncryptedStudent[]
|
||||
selectedStudentsByYear?: { [year: string]: string[] }
|
||||
studentStatuses?: string[]
|
||||
years?: string[]
|
||||
}
|
||||
export type PopulationstatisticsCoursesResBody = Bottlenecks | { error: string }
|
||||
export type PopulationstatisticsCoursesReqBody = {
|
||||
// NOTE: Encrypted students have their iv in selectedStudents
|
||||
selectedStudents: string[] | EncryptedStudent[]
|
||||
selectedStudentsByYear?: { [year: string]: string[] }
|
||||
courses?: string[]
|
||||
}
|
||||
|
||||
// POST instead of GET because of too long params and "sensitive" data
|
||||
router.post('/v2/populationstatistics/courses', async (req: PostPopulationStatisticsRequest, res: Response) => {
|
||||
if (!req.body.year || !req.body.semesters || !req.body.studyRights) {
|
||||
Sentry.captureException(new Error('The body should have a year, semester and study rights defined'))
|
||||
return res.status(400).json({ error: 'The body should have a year, semester and study rights defined' })
|
||||
}
|
||||
// NOTE: POST instead of GET because of too long params and "sensitive" data
|
||||
router.post<never, PopulationstatisticsCoursesResBody, PopulationstatisticsCoursesReqBody>(
|
||||
'/v4/populationstatistics/courses',
|
||||
async (req, res) => {
|
||||
const { roles, studentsUserCanAccess } = req.user
|
||||
const { selectedStudents, courses: selectedCourses = [], selectedStudentsByYear = {} } = req.body
|
||||
|
||||
if (!Array.isArray(req.body.studyRights)) {
|
||||
req.body.studyRights = [req.body.studyRights]
|
||||
}
|
||||
const hasFullAccess = hasFullAccessToStudentData(roles)
|
||||
|
||||
if (req.body.months == null) {
|
||||
req.body.months = 12
|
||||
}
|
||||
const isEncrypted = selectedStudents.some(isEncryptedStudent)
|
||||
const hasAccessToStudents =
|
||||
isEncrypted || (selectedStudents as string[]).every(student => studentsUserCanAccess.includes(student))
|
||||
|
||||
const encrypted = isEncryptedStudent(req.body.selectedStudents[0])
|
||||
|
||||
if (
|
||||
!encrypted &&
|
||||
!hasFullAccessToStudentData(req.user.roles) &&
|
||||
req.body.selectedStudents.some(student => !req.user.studentsUserCanAccess.includes(student))
|
||||
) {
|
||||
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
|
||||
}
|
||||
|
||||
if (!req.body.semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
|
||||
return res.status(400).json({ error: 'Semester should be either SPRING OR FALL' })
|
||||
}
|
||||
|
||||
if (
|
||||
req.body.studentStatuses &&
|
||||
!req.body.studentStatuses.every(
|
||||
status => status === 'EXCHANGE' || status === 'NONDEGREE' || status === 'TRANSFERRED'
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
|
||||
}
|
||||
|
||||
if (req.body.years) {
|
||||
const upperYearBound = new Date().getFullYear() + 1
|
||||
const multicoursestatPromises = Promise.all(
|
||||
req.body.years.map(year => {
|
||||
if (req.body.selectedStudentsByYear) {
|
||||
req.body.selectedStudents = encrypted
|
||||
? req.body.selectedStudents.filter(isEncryptedStudent).map(decrypt)
|
||||
: req.body.selectedStudentsByYear[year]
|
||||
}
|
||||
const newMonths = (upperYearBound - Number(year)) * 12
|
||||
const query = { ...req.body, year, months: newMonths, selectedStudents: req.body.selectedStudents as string[] }
|
||||
const coursestatistics = bottlenecksOf(query, null, encrypted)
|
||||
return coursestatistics
|
||||
})
|
||||
)
|
||||
const multicoursestats = await multicoursestatPromises
|
||||
const result = populationCourseStatsMerger(multicoursestats)
|
||||
if (result.error) {
|
||||
return res.status(400).json(result)
|
||||
if (!hasAccessToStudents && !hasFullAccess) {
|
||||
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
|
||||
}
|
||||
|
||||
if (isEncrypted && !selectedStudents.every(isEncryptedStudent)) {
|
||||
Sentry.captureException(new Error('Trying to request unencrypted student data as encrypted'))
|
||||
return res.status(403).json({ error: 'Trying to request unauthorized student data' })
|
||||
}
|
||||
|
||||
const requiredFields = [selectedStudents, selectedCourses]
|
||||
if (requiredFields.some(field => !field)) {
|
||||
Sentry.captureException(new Error('The request body should countain: selected students and courses'))
|
||||
return res.status(400).json({ error: 'The request body should countain: selected students and courses' })
|
||||
}
|
||||
|
||||
const result = await bottlenecksOf(
|
||||
selectedStudents as string[],
|
||||
selectedStudentsByYear,
|
||||
selectedCourses,
|
||||
isEncrypted
|
||||
)
|
||||
|
||||
return res.json(result)
|
||||
}
|
||||
|
||||
if (encrypted) {
|
||||
req.body.selectedStudents = req.body.selectedStudents.map(decrypt)
|
||||
}
|
||||
|
||||
const query = { ...req.body, selectedStudents: req.body.selectedStudents as string[] }
|
||||
try {
|
||||
const result = await bottlenecksOf(query, null, encrypted)
|
||||
return res.json(result)
|
||||
} catch (error: any) {
|
||||
Sentry.captureException(new Error(error.message))
|
||||
return res.status(400).end()
|
||||
}
|
||||
})
|
||||
|
||||
interface PostPopulationStatisticsByTagRequest extends Request {
|
||||
body: {
|
||||
tag: string
|
||||
}
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/v2/populationstatistics/coursesbytag',
|
||||
async (req: PostPopulationStatisticsByTagRequest, res: Response) => {
|
||||
const { roles, studentsUserCanAccess } = req.user
|
||||
const { tag } = req.body
|
||||
if (!tag) {
|
||||
return res.status(400).json({ error: 'The body should have a tag defined' })
|
||||
}
|
||||
|
||||
const studentNumbers = await findByTag(tag)
|
||||
const studentNumberList = hasFullAccessToStudentData(roles)
|
||||
? studentNumbers
|
||||
: intersection(studentNumbers, studentsUserCanAccess)
|
||||
|
||||
try {
|
||||
const result = await bottlenecksOf(
|
||||
{
|
||||
year: '1900',
|
||||
studyRights: [],
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
months: 10000,
|
||||
tag,
|
||||
},
|
||||
studentNumberList
|
||||
)
|
||||
return res.json(result)
|
||||
} catch (error: any) {
|
||||
Sentry.captureException(new Error(error.message))
|
||||
return res.status(400).end()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface PostPopulationStatisticsByStudentNumberListRequest extends Request {
|
||||
body: {
|
||||
studentnumberlist: string[]
|
||||
year: string
|
||||
studyRights: string[]
|
||||
semesters: string[]
|
||||
months: number
|
||||
}
|
||||
export type PopulationstatisticsResBody = { students: any } | { error: string }
|
||||
export type PopulationstatisticsReqBody = never
|
||||
export type PopulationstatisticsQuery = {
|
||||
semesters: string[]
|
||||
studentStatuses?: string[]
|
||||
// NOTE: This param is a JSON -object
|
||||
studyRights: string
|
||||
year: string
|
||||
months?: string
|
||||
years?: string[]
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/v2/populationstatistics/coursesbystudentnumberlist',
|
||||
async (req: PostPopulationStatisticsByStudentNumberListRequest, res: Response) => {
|
||||
const { roles, studentsUserCanAccess } = req.user
|
||||
const {
|
||||
body: { studentnumberlist, ...query },
|
||||
} = req
|
||||
router.get<never, PopulationstatisticsResBody, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
|
||||
'/v3/populationstatistics',
|
||||
async (req, res) => {
|
||||
const { roles: userRoles, programmeRights: userProgrammeRights } = req.user
|
||||
const { year, semesters, studyRights: requestedStudyRightsJSON, studentStatuses } = req.query
|
||||
|
||||
if (!studentnumberlist) {
|
||||
throw new ApplicationError('The body should have a studentnumberlist defined', 400)
|
||||
const months = req.query.months ?? '12'
|
||||
|
||||
// NOTE: `year` isn't needed anymore if `years` is defined
|
||||
const requiredFields = [year, semesters, requestedStudyRightsJSON]
|
||||
if (requiredFields.some(field => !field)) {
|
||||
return res.status(400).json({ error: 'The query should have a year, semester and studyRights defined' })
|
||||
}
|
||||
|
||||
const studentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentnumberlist
|
||||
: intersection(studentnumberlist, studentsUserCanAccess)
|
||||
|
||||
try {
|
||||
const result = await bottlenecksOf(
|
||||
{
|
||||
year: query?.year ?? 1900,
|
||||
studyRights: query?.studyRights ?? [],
|
||||
semesters: query?.semesters ?? ['FALL', 'SPRING'],
|
||||
months: query?.months ?? 10000,
|
||||
},
|
||||
studentNumbers
|
||||
)
|
||||
return res.json(result)
|
||||
} catch (error: any) {
|
||||
Sentry.captureException(new Error(error.message))
|
||||
return res.status(400).end()
|
||||
if (!semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
|
||||
return res.status(400).json({ error: 'Semester should be either SPRING OR FALL' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface GetPopulationStatisticsRequest extends Request {
|
||||
query: {
|
||||
year: string
|
||||
semesters: string[]
|
||||
studyRights: string
|
||||
months: string
|
||||
years?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsRequest, res: Response) => {
|
||||
const { year, semesters, studyRights: studyRightsJSON } = req.query
|
||||
const { roles, programmeRights, id: userId } = req.user
|
||||
if (!year || !semesters || !studyRightsJSON) {
|
||||
res.status(400).json({ error: 'The query should have a year, semester and studyRights defined' })
|
||||
return
|
||||
}
|
||||
|
||||
const studyRights: { programme: string; combinedProgramme: string } = JSON.parse(studyRightsJSON)
|
||||
|
||||
const fullProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeRightsCodes = programmeRights.map(({ code }) => code)
|
||||
|
||||
try {
|
||||
if (
|
||||
!hasFullAccessToStudentData(roles) &&
|
||||
!programmeRightsCodes.includes(studyRights.programme) &&
|
||||
!programmeRightsCodes.includes(studyRights.combinedProgramme)
|
||||
) {
|
||||
return res.status(403).json([])
|
||||
const hasCorrectStatus = (studentStatuses: string[]) =>
|
||||
studentStatuses.every(status => ['EXCHANGE', 'NONDEGREE', 'TRANSFERRED'].includes(status))
|
||||
if (studentStatuses && !hasCorrectStatus(studentStatuses)) {
|
||||
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
|
||||
}
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: 'The query had invalid studyRights' })
|
||||
}
|
||||
|
||||
if (req.query.months === null) {
|
||||
req.query.months = '12'
|
||||
}
|
||||
const requestedStudyRights: { programme: string; combinedProgramme: string } | null =
|
||||
safeJSONParse(requestedStudyRightsJSON)
|
||||
if (requestedStudyRights === null) {
|
||||
return res.status(400).json({ error: 'Invalid studyrights value!' })
|
||||
}
|
||||
|
||||
if (req.query.years) {
|
||||
const upperYearBound = new Date().getFullYear() + 1
|
||||
const multipopulationstudentPromises = Promise.all(
|
||||
req.query.years.map(year => {
|
||||
const newMonths = (upperYearBound - Number(year)) * 12
|
||||
const populationStudents = optimizedStatisticsOf({
|
||||
...req.query,
|
||||
year: Number(year),
|
||||
studyRights: { programme: studyRights.programme },
|
||||
months: newMonths,
|
||||
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
|
||||
const userProgrammeRightsCodes = userProgrammeRights.map(({ code }) => code)
|
||||
|
||||
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
|
||||
const hasAccessToProgramme = userProgrammeRightsCodes.includes(requestedStudyRights.programme)
|
||||
const hasAccessToCombinedProgramme = userProgrammeRightsCodes.includes(requestedStudyRights.combinedProgramme)
|
||||
|
||||
if (!hasFullAccessToStudents && !hasAccessToProgramme && !hasAccessToCombinedProgramme) {
|
||||
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
|
||||
}
|
||||
|
||||
let result: { students: any[]; courses: any[] } | { error: string }
|
||||
if (req.query.years) {
|
||||
const upperYearBound = new Date().getFullYear() + 1
|
||||
const multiYearStudents = Promise.all(
|
||||
req.query.years.map(year => {
|
||||
const yearAsNumber = +year
|
||||
const monthsForCurrentYear = String((upperYearBound - yearAsNumber) * 12)
|
||||
return optimizedStatisticsOf({
|
||||
...req.query,
|
||||
studyRights: requestedStudyRights.programme,
|
||||
year,
|
||||
months: monthsForCurrentYear,
|
||||
})
|
||||
})
|
||||
return populationStudents
|
||||
)
|
||||
|
||||
const populationStudentsMerger = (multiYearStudents: any) => {
|
||||
const samples = { students: [], courses: [] as any[] }
|
||||
const uniqueCourseCodes = new Set<string>()
|
||||
|
||||
for (const year of multiYearStudents) {
|
||||
samples.students = samples.students.concat(year.students)
|
||||
for (const course of year.courses) {
|
||||
if (!uniqueCourseCodes.has(course.code)) {
|
||||
uniqueCourseCodes.add(course.code)
|
||||
samples.courses.push(course)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return samples
|
||||
}
|
||||
|
||||
result = populationStudentsMerger(await multiYearStudents)
|
||||
} else {
|
||||
result = await optimizedStatisticsOf({
|
||||
...req.query,
|
||||
studyRights: requestedStudyRights.programme,
|
||||
year,
|
||||
months,
|
||||
})
|
||||
)
|
||||
const multipopulationstudents = await multipopulationstudentPromises
|
||||
|
||||
const result = populationStudentsMerger(multipopulationstudents)
|
||||
|
||||
res.json(filterPersonalTags(result, userId))
|
||||
} else {
|
||||
const result: any = await optimizedStatisticsOf({
|
||||
...req.query,
|
||||
year: Number(req.query.year),
|
||||
studyRights: { programme: studyRights.programme },
|
||||
months: Number(req.query.months),
|
||||
})
|
||||
|
||||
if ('error' in result && result.error) {
|
||||
Sentry.captureException(new Error(result.error))
|
||||
return res.status(400).end()
|
||||
}
|
||||
|
||||
// Obfuscate if user has only limited study programme rights
|
||||
// Obfuscate if user has only limited study programme rights and there are any students
|
||||
if (
|
||||
!hasFullAccessToStudentData(roles) &&
|
||||
!fullProgrammeRights.includes(studyRights.programme) &&
|
||||
!fullProgrammeRights.includes(studyRights.combinedProgramme)
|
||||
'students' in result &&
|
||||
!hasFullAccessToStudentData(userRoles) &&
|
||||
!userFullProgrammeRights.includes(requestedStudyRights.programme) &&
|
||||
!userFullProgrammeRights.includes(requestedStudyRights.combinedProgramme)
|
||||
) {
|
||||
if (!('students' in result)) {
|
||||
return
|
||||
}
|
||||
result.students = result.students.map(student => {
|
||||
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
|
||||
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0, 1)) // correct year for age distribution calculation but the date is always January 1st
|
||||
// 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: '',
|
||||
@@ -309,9 +210,10 @@ router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsReques
|
||||
})
|
||||
}
|
||||
|
||||
res.json(filterPersonalTags(result, userId))
|
||||
const { id: userId } = req.user
|
||||
return res.json(filterPersonalTags(result, userId))
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
interface GetPopulationStatisticsByCourseRequest extends Request {
|
||||
query: {
|
||||
@@ -350,10 +252,9 @@ router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisti
|
||||
{
|
||||
// Useless, because studentnumbers are already filtered above by from & to.
|
||||
// We should probably refactor this to avoid more confusement.
|
||||
year: 1900,
|
||||
studyRights: [],
|
||||
year: '1900',
|
||||
studyRights: undefined,
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
months: 10000,
|
||||
},
|
||||
studentNumbers
|
||||
)
|
||||
@@ -397,11 +298,6 @@ router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisti
|
||||
obfuscated: true,
|
||||
})
|
||||
|
||||
if ('error' in result && result.error) {
|
||||
Sentry.captureException(new Error(result.error))
|
||||
return res.status(400).end()
|
||||
}
|
||||
|
||||
if ('students' in result) {
|
||||
result.students = result.students.map(student =>
|
||||
studentsUserCanAccess.has(student.studentNumber) ? student : obfuscateStudent(student)
|
||||
@@ -427,24 +323,17 @@ router.post('/v3/populationstatisticsbystudentnumbers', async (req: PostByStuden
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentnumberlist
|
||||
: intersection(studentnumberlist, studentsUserCanAccess)
|
||||
const studyProgrammeCode =
|
||||
tags?.studyProgramme && tags?.studyProgramme.includes('+')
|
||||
? tags?.studyProgramme.split('+')[0]
|
||||
: tags?.studyProgramme
|
||||
|
||||
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
|
||||
|
||||
const result = await optimizedStatisticsOf(
|
||||
{
|
||||
year: tags?.year ? Number(tags.year) : 1900,
|
||||
studyRights: studyProgrammeCode ? [studyProgrammeCode] : [],
|
||||
year: tags?.year ?? '1900',
|
||||
studyRights: studyProgrammeCode,
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
months: 10000,
|
||||
},
|
||||
filteredStudentNumbers
|
||||
)
|
||||
if ('error' in result && result.error) {
|
||||
Sentry.captureException(new Error(result.error))
|
||||
return res.status(500).json({ error: result.error })
|
||||
}
|
||||
|
||||
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
|
||||
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
|
||||
|
||||
@@ -6,14 +6,6 @@ import { ApplicationError } from '../util/customErrors'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const filterStudentTags = (student: Awaited<ReturnType<typeof withStudentNumber>>, userId: string) => {
|
||||
if (!student) return null
|
||||
return {
|
||||
...student,
|
||||
tags: (student.tags ?? []).filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId),
|
||||
}
|
||||
}
|
||||
|
||||
interface GetStudentsRequest extends Request {
|
||||
query: {
|
||||
searchTerm: string
|
||||
@@ -59,11 +51,15 @@ router.get('/:studentNumber', async (req: GetStudentRequest, res: Response) => {
|
||||
} = req
|
||||
|
||||
if (!hasFullAccessToStudentData(roles) && !studentsUserCanAccess.includes(studentNumber)) {
|
||||
throw new ApplicationError('Error finding student', 400)
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: `User does not have permission to view data for student number ${studentNumber}.` })
|
||||
}
|
||||
const student = await withStudentNumber(studentNumber)
|
||||
const filteredTags = filterStudentTags(student, id)
|
||||
res.json(filteredTags)
|
||||
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
|
||||
|
||||
@@ -5,21 +5,20 @@ import {
|
||||
setBasicStats,
|
||||
getGraduationStats,
|
||||
setGraduationStats,
|
||||
getStudytrackStats,
|
||||
setStudytrackStats,
|
||||
getStudyTrackStats,
|
||||
setStudyTrackStats,
|
||||
} from '../services/analyticsService'
|
||||
import { getCreditsProduced } from '../services/providerCredits'
|
||||
import { getProgrammeName } from '../services/studyProgramme'
|
||||
import { getBasicStatsForStudytrack } from '../services/studyProgramme/studyProgrammeBasics'
|
||||
import {
|
||||
getStudyProgrammeCoursesForStudyTrack,
|
||||
getStudyProgrammeStatsForColorizedCoursesTable,
|
||||
} from '../services/studyProgramme/studyProgrammeCourses'
|
||||
import { getGraduationStatsForStudytrack } from '../services/studyProgramme/studyProgrammeGraduations'
|
||||
import { updateBasicView, updateStudytrackView } from '../services/studyProgramme/studyProgrammeUpdates'
|
||||
import { getStudyRightsInProgramme } from '../services/studyProgramme/studyRightFinders'
|
||||
import { getGraduationStatsForStudyTrack } from '../services/studyProgramme/studyProgrammeGraduations'
|
||||
import { updateBasicView, updateStudyTrackView } from '../services/studyProgramme/studyProgrammeUpdates'
|
||||
import { getStudyRightsInProgramme, getStudyTracksForProgramme } from '../services/studyProgramme/studyRightFinders'
|
||||
import { getStudyTrackStatsForStudyProgramme } from '../services/studyProgramme/studyTrackStats'
|
||||
import { Graduated, SpecialGroups, YearType } from '../types'
|
||||
import { Graduated, SpecialGroups, YearType } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
import { logInfoForGrafana } from '../util/logInfoForGrafana'
|
||||
|
||||
@@ -57,7 +56,7 @@ router.get('/:id/basicstats', async (req: GetStatsRequest, res: Response) => {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
logInfoForGrafana(code, combinedProgramme)
|
||||
void logInfoForGrafana(code, combinedProgramme)
|
||||
const data = await getBasicStats(code, combinedProgramme, yearType, specialGroups)
|
||||
if (data) {
|
||||
return res.json(data)
|
||||
@@ -89,7 +88,7 @@ router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) =
|
||||
return res.json(data)
|
||||
}
|
||||
|
||||
const updatedStats = await getGraduationStatsForStudytrack({
|
||||
const updatedStats = await getGraduationStatsForStudyTrack({
|
||||
studyProgramme: code,
|
||||
combinedProgramme,
|
||||
settings: {
|
||||
@@ -117,7 +116,7 @@ router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response)
|
||||
const combinedProgramme = req.query?.combined_programme
|
||||
const date = new Date()
|
||||
date.setHours(23, 59, 59, 999)
|
||||
logInfoForGrafana(code, combinedProgramme)
|
||||
void logInfoForGrafana(code, combinedProgramme)
|
||||
try {
|
||||
const data = await getStudyProgrammeCoursesForStudyTrack(date.getTime(), code, showByYear, combinedProgramme)
|
||||
return res.json(data)
|
||||
@@ -141,8 +140,8 @@ router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: R
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
logInfoForGrafana(code, combinedProgramme)
|
||||
const data = await getStudytrackStats(code, combinedProgramme, graduated, specialGroups)
|
||||
void logInfoForGrafana(code, combinedProgramme)
|
||||
const data = await getStudyTrackStats(code, combinedProgramme, graduated, specialGroups)
|
||||
if (data) {
|
||||
return res.json(data)
|
||||
}
|
||||
@@ -158,7 +157,7 @@ router.get('/:id/studytrackstats', async (req: GetStudyTrackStatsRequest, res: R
|
||||
studyRightsOfProgramme,
|
||||
})
|
||||
if (updated) {
|
||||
await setStudytrackStats(updated, graduated, specialGroups)
|
||||
await setStudyTrackStats(updated, graduated, specialGroups)
|
||||
}
|
||||
return res.json(updated)
|
||||
})
|
||||
@@ -173,6 +172,19 @@ router.get('/:id/colorizedtablecoursestats', async (req: Request, res: Response)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id/studytracks', async (req: Request, res: Response) => {
|
||||
const code = req.params.id
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
try {
|
||||
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}` })
|
||||
}
|
||||
})
|
||||
|
||||
interface GetUpdateViewRequest extends Request {
|
||||
query: {
|
||||
combined_programme: string
|
||||
@@ -202,7 +214,7 @@ router.get('/:id/update_studytrackview', async (req: GetUpdateViewRequest, res:
|
||||
return res.status(400).json({ error: 'Missing code' })
|
||||
}
|
||||
try {
|
||||
const result = await updateStudytrackView(code, combinedProgramme)
|
||||
const result = await updateStudyTrackView(code, combinedProgramme)
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
const message = `Failed to update study track stats for programme ${code}${combinedProgramme ? `+${combinedProgramme}` : ''}`
|
||||
@@ -211,73 +223,4 @@ router.get('/:id/update_studytrackview', async (req: GetUpdateViewRequest, res:
|
||||
}
|
||||
})
|
||||
|
||||
interface GetEvaluationStatsRequest extends Request {
|
||||
query: {
|
||||
graduated: Graduated
|
||||
year_type: YearType
|
||||
special_groups: SpecialGroups
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/:id/evaluationstats', async (req: GetEvaluationStatsRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
const { graduated, year_type: yearType, special_groups: specialGroups } = req.query
|
||||
if (!code) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
// Statistics for Tilannekuvalomake view
|
||||
const combinedProgramme = ''
|
||||
let gradData = await getGraduationStats(code, combinedProgramme, yearType, specialGroups)
|
||||
if (!gradData) {
|
||||
const updatedStats = await getGraduationStatsForStudytrack({
|
||||
studyProgramme: code,
|
||||
combinedProgramme,
|
||||
settings: {
|
||||
isAcademicYear: yearType === 'ACADEMIC_YEAR',
|
||||
includeAllSpecials: specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
})
|
||||
if (updatedStats) {
|
||||
await setGraduationStats(updatedStats, yearType, specialGroups)
|
||||
gradData = updatedStats
|
||||
}
|
||||
}
|
||||
|
||||
let progressData = await getStudytrackStats(code, combinedProgramme, graduated, specialGroups)
|
||||
if (!progressData) {
|
||||
const studyRightsOfProgramme = await getStudyRightsInProgramme(code, false, true)
|
||||
const updated = await getStudyTrackStatsForStudyProgramme({
|
||||
studyProgramme: code,
|
||||
combinedProgramme,
|
||||
settings: {
|
||||
graduated: graduated === 'GRADUATED_INCLUDED',
|
||||
specialGroups: specialGroups === 'SPECIAL_INCLUDED',
|
||||
},
|
||||
studyRightsOfProgramme,
|
||||
})
|
||||
if (updated) {
|
||||
await setStudytrackStats(updated, graduated, specialGroups)
|
||||
progressData = updated
|
||||
}
|
||||
}
|
||||
|
||||
const programmeName = await getProgrammeName(code)
|
||||
|
||||
delete gradData.tableStats
|
||||
delete gradData.graphStats
|
||||
delete gradData.titles
|
||||
const data = {
|
||||
id: code,
|
||||
programmeName,
|
||||
status: gradData?.status,
|
||||
lastUpdated: gradData.lastUpdated,
|
||||
graduations: gradData,
|
||||
creditCounts: progressData?.creditCounts,
|
||||
creditCountsCombo: progressData?.creditCountsCombo,
|
||||
years: progressData?.years,
|
||||
}
|
||||
|
||||
return res.json(data)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -19,7 +19,7 @@ router.get('/', async (req: StudyProgrammeCriteriaRequest, res: Response) => {
|
||||
if (studyProgramme !== '' && !studyProgramme) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
const studyProgrammeCriteria = await getCriteria(studyProgramme as string)
|
||||
const studyProgrammeCriteria = await getCriteria(studyProgramme)
|
||||
return res.json(studyProgrammeCriteria)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { difference } from 'lodash'
|
||||
|
||||
import { Tag, TagStudent } from '../models/kone'
|
||||
import { filterStudentnumbersByAccessrights } from '../services/students'
|
||||
import { filterStudentNumbersByAccessRights } from '../services/students'
|
||||
import {
|
||||
findTagsByStudyTrack,
|
||||
createNewTag,
|
||||
@@ -11,20 +10,18 @@ import {
|
||||
createMultipleStudentTags,
|
||||
findTagsFromStudyTrackById,
|
||||
deleteMultipleStudentTags,
|
||||
TagFromFrontend,
|
||||
StudentTagFromFrontend,
|
||||
} from '../services/tags'
|
||||
import { Role } from '../types'
|
||||
import { NewTag, Role, StudentTag, Tag } from '../shared/types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const filterRelevantTags = (tags: Tag[], userId: string) => {
|
||||
return tags.filter(tag => !tag.personal_user_id || tag.personal_user_id === userId)
|
||||
return tags.filter(tag => !tag.personalUserId || tag.personalUserId === userId)
|
||||
}
|
||||
|
||||
const filterRelevantStudentTags = (studentTags: TagStudent[], userId: string) => {
|
||||
return studentTags.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId)
|
||||
const filterRelevantStudentTags = (studentTags: StudentTag[], userId: string) => {
|
||||
return studentTags.filter(({ tag }) => !tag?.personalUserId || tag.personalUserId === userId)
|
||||
}
|
||||
|
||||
const userIsUnauthorized = (programmeRights: string[], programmeCodes: string[], roles: Role[]) => {
|
||||
@@ -41,15 +38,15 @@ const getStudyTrackCode = (studyTrack: string, combinedProgramme: string) => {
|
||||
|
||||
interface GetTagsByStudyTrackRequest extends Request {
|
||||
params: {
|
||||
studytrack: string
|
||||
studyTrack: string
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/tags/:studytrack', async (req: GetTagsByStudyTrackRequest, res: Response) => {
|
||||
const { studytrack } = req.params
|
||||
router.get('/tags/:studyTrack', async (req: GetTagsByStudyTrackRequest, res: Response) => {
|
||||
const { studyTrack } = req.params
|
||||
const { roles, id, programmeRights } = req.user
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeCodes = getProgrammeCodes(studytrack)
|
||||
const programmeCodes = getProgrammeCodes(studyTrack)
|
||||
|
||||
// Respond with null and 200 instead of 403 if the user isn't authorized to view the tags
|
||||
// This is to avoid unnecessary noise in Sentry
|
||||
@@ -57,150 +54,148 @@ router.get('/tags/:studytrack', async (req: GetTagsByStudyTrackRequest, res: Res
|
||||
return res.json(null)
|
||||
}
|
||||
|
||||
const tags = await findTagsByStudyTrack(studytrack)
|
||||
const tags = await findTagsByStudyTrack(studyTrack)
|
||||
res.status(200).json(filterRelevantTags(tags, id))
|
||||
})
|
||||
|
||||
interface PostTagRequest extends Request {
|
||||
body: {
|
||||
tag: TagFromFrontend
|
||||
}
|
||||
body: { tag: NewTag }
|
||||
}
|
||||
|
||||
router.post('/tags', async (req: PostTagRequest, res: Response) => {
|
||||
const {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
tag: { studytrack, tagname, year, personal_user_id },
|
||||
tag: { name, personalUserId, studyTrack, year },
|
||||
} = req.body
|
||||
const { roles, id, programmeRights } = req.user
|
||||
|
||||
if (!tagname || !studytrack || !year) {
|
||||
if (!name || !studyTrack) {
|
||||
return res.status(400).json({ error: 'Missing required fields' })
|
||||
}
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeCodes = getProgrammeCodes(studytrack)
|
||||
const programmeCodes = getProgrammeCodes(studyTrack)
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
await createNewTag({ studytrack, tagname, year, personal_user_id })
|
||||
const tags = await findTagsByStudyTrack(studytrack)
|
||||
await createNewTag({ name, personalUserId, studyTrack, year })
|
||||
const tags = await findTagsByStudyTrack(studyTrack)
|
||||
res.status(200).json(filterRelevantTags(tags, id))
|
||||
})
|
||||
|
||||
interface DeleteTagRequest extends Request {
|
||||
body: {
|
||||
tag: {
|
||||
studytrack: string
|
||||
tag_id: string
|
||||
personal_user_id: string
|
||||
}
|
||||
}
|
||||
body: { tag: Tag }
|
||||
}
|
||||
|
||||
router.delete('/tags', async (req: DeleteTagRequest, res: Response) => {
|
||||
const { tag } = req.body
|
||||
const { roles, id, programmeRights } = req.user
|
||||
const {
|
||||
tag: { id, personalUserId, studyTrack },
|
||||
} = req.body
|
||||
const { roles, id: userId, programmeRights } = req.user
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeCodes = getProgrammeCodes(tag.studytrack)
|
||||
const programmeCodes = getProgrammeCodes(studyTrack)
|
||||
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles) && !(tag.personal_user_id === id)) {
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles) && !(personalUserId === userId)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
await deleteTag(tag.tag_id)
|
||||
const tags = await findTagsByStudyTrack(tag.studytrack)
|
||||
await deleteTag(id)
|
||||
const tags = await findTagsByStudyTrack(studyTrack)
|
||||
res.status(200).json(filterRelevantTags(tags, id))
|
||||
})
|
||||
|
||||
interface GetStudentTagsByStudyTrackRequest extends Request {
|
||||
params: {
|
||||
studytrack: string
|
||||
studyTrack: string
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/studenttags/:studytrack', async (req: GetStudentTagsByStudyTrackRequest, res: Response) => {
|
||||
const { studytrack } = req.params
|
||||
router.get('/studenttags/:studyTrack', async (req: GetStudentTagsByStudyTrackRequest, res: Response) => {
|
||||
const { studyTrack } = req.params
|
||||
const { roles, id, programmeRights } = req.user
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeCodes = getProgrammeCodes(studytrack)
|
||||
const programmeCodes = getProgrammeCodes(studyTrack)
|
||||
const lacksProgrammeAccess = programmeCodes.every(code => !fullStudyProgrammeRights.includes(code))
|
||||
if (lacksProgrammeAccess && !roles?.includes('admin')) {
|
||||
return res.json(null)
|
||||
}
|
||||
|
||||
const tags = await getStudentTagsByStudyTrack(studytrack)
|
||||
res.status(200).json(filterRelevantStudentTags(tags, id))
|
||||
const tags = await getStudentTagsByStudyTrack(studyTrack)
|
||||
const relevantTags = filterRelevantStudentTags(tags, id)
|
||||
res.status(200).json(relevantTags)
|
||||
})
|
||||
|
||||
interface PostStudentTagsRequest extends Request {
|
||||
body: {
|
||||
tags: StudentTagFromFrontend[]
|
||||
studytrack: string
|
||||
combinedProgramme: string
|
||||
studentTags: StudentTag[]
|
||||
studyTrack: string
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/studenttags', async (req: PostStudentTagsRequest, res: Response) => {
|
||||
const { tags, studytrack, combinedProgramme } = req.body
|
||||
const { combinedProgramme, studentTags, studyTrack } = req.body
|
||||
const { roles, programmeRights } = req.user
|
||||
|
||||
if (!studentTags || !studyTrack) {
|
||||
return res.status(400).json({ error: 'Missing required fields' })
|
||||
}
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) {
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studyTrack, combinedProgramme], roles)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
const studyTrackCode = getStudyTrackCode(studytrack, combinedProgramme)
|
||||
const studyTrackCode = getStudyTrackCode(studyTrack, combinedProgramme)
|
||||
const existingTags = await findTagsByStudyTrack(studyTrackCode)
|
||||
const existingTagids = existingTags.map(tag => tag.tag_id)
|
||||
const tagIds = [...new Set(tags.map(tag => tag.tag_id))]
|
||||
if (!tagIds.find(tag => existingTagids.includes(tag))) {
|
||||
const existingTagIds = existingTags.map(tag => tag.id)
|
||||
const tagIds = [...new Set(studentTags.map(studentTag => studentTag.tagId))]
|
||||
if (!tagIds.find(tag => existingTagIds.includes(tag))) {
|
||||
return res.status(400).json({ error: 'The tag does not exist' })
|
||||
}
|
||||
|
||||
const studentNumbers = tags.map(tag => tag.studentnumber)
|
||||
const studentFromProgrammes = combinedProgramme ? [studytrack, combinedProgramme] : [studytrack]
|
||||
const students = await filterStudentnumbersByAccessrights(studentNumbers, studentFromProgrammes)
|
||||
const studentNumbers = studentTags.map(studentTag => studentTag.studentNumber)
|
||||
const studentFromProgrammes = combinedProgramme ? [studyTrack, combinedProgramme] : [studyTrack]
|
||||
const students = await filterStudentNumbersByAccessRights(studentNumbers, studentFromProgrammes)
|
||||
const missingStudents = difference(studentNumbers, students)
|
||||
if (missingStudents.length !== 0) {
|
||||
const errorMessage = `Could not find the following students from the programme: ${missingStudents.join(', ')}`
|
||||
return res.status(400).json({ error: errorMessage })
|
||||
}
|
||||
|
||||
await createMultipleStudentTags(tags)
|
||||
await createMultipleStudentTags(studentTags)
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
interface DeleteStudentTagsRequest extends Request {
|
||||
body: {
|
||||
tagId: string
|
||||
studentnumbers: string[]
|
||||
studytrack: string
|
||||
combinedProgramme: string
|
||||
tagId: string
|
||||
studentNumbers: string[]
|
||||
studyTrack: string
|
||||
}
|
||||
}
|
||||
|
||||
router.delete('/studenttags', async (req: DeleteStudentTagsRequest, res: Response) => {
|
||||
const { tagId, studentnumbers, studytrack, combinedProgramme } = req.body
|
||||
const { combinedProgramme, tagId, studentNumbers, studyTrack } = req.body
|
||||
const { roles, programmeRights } = req.user
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) {
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studyTrack, combinedProgramme], roles)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
const studyTrackCode = getStudyTrackCode(studytrack, combinedProgramme)
|
||||
const studyTrackCode = getStudyTrackCode(studyTrack, combinedProgramme)
|
||||
const tags = await findTagsFromStudyTrackById(studyTrackCode, [tagId])
|
||||
if (tags.length === 0) {
|
||||
return res.status(403).json({ error: 'The tag does not exist' })
|
||||
}
|
||||
|
||||
await deleteMultipleStudentTags(tagId, studentnumbers)
|
||||
await deleteMultipleStudentTags(tagId, studentNumbers)
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { serviceProvider } from '../config'
|
||||
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 { mapToProviders } from '../shared/util/mapToProviders'
|
||||
import { mapToProviders } from '../shared/util'
|
||||
import { getFullStudyProgrammeRights, splitByEmptySpace } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const iamGroupsGivingFullAccess = ['hy-dekaanit', 'hy-varadekaanit-opetus']
|
||||
|
||||
const fullAccessAuth = () =>
|
||||
serviceProvider === 'toska' ? auth.roles([], iamGroupsGivingFullAccess) : auth.roles(['teachers'])
|
||||
|
||||
interface GetTeachersRequest extends Request {
|
||||
query: {
|
||||
searchTerm: string
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/', async (req: GetTeachersRequest, res: Response) => {
|
||||
router.get('/', fullAccessAuth(), async (req: GetTeachersRequest, res: Response) => {
|
||||
const { searchTerm } = req.query
|
||||
if (!searchTerm) {
|
||||
return res.status(400).json({ error: 'Search term missing' })
|
||||
@@ -38,14 +45,19 @@ interface GetTopTeachersRequest extends Request {
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/top', async (req: GetTopTeachersRequest, res: Response) => {
|
||||
router.get('/top', fullAccessAuth(), async (req: GetTopTeachersRequest, res: Response) => {
|
||||
const { yearcode, category = CategoryID.ALL } = req.query
|
||||
if (!yearcode) {
|
||||
return res.status(422).send('Missing required yearcode query param')
|
||||
}
|
||||
|
||||
const result = await getTeacherStats(category, Number(yearcode))
|
||||
res.json(result)
|
||||
if (result) {
|
||||
return res.json(result)
|
||||
}
|
||||
await findAndSaveTeachers(Number(yearcode), Number(yearcode))
|
||||
const updatedStats = await getTeacherStats(category, Number(yearcode))
|
||||
res.json(updatedStats)
|
||||
})
|
||||
|
||||
interface PostTopTeachersRequest extends Request {
|
||||
@@ -55,7 +67,7 @@ interface PostTopTeachersRequest extends Request {
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/top', async (req: PostTopTeachersRequest, res: Response) => {
|
||||
router.post('/top', auth.roles(['admin']), async (req: PostTopTeachersRequest, res: Response) => {
|
||||
const { startyearcode, endyearcode } = req.body
|
||||
const endYear = endyearcode ? Number(endyearcode) : undefined
|
||||
const startYear = startyearcode ? Number(startyearcode) : undefined
|
||||
@@ -63,7 +75,7 @@ router.post('/top', async (req: PostTopTeachersRequest, res: Response) => {
|
||||
await findAndSaveTeachers(endYear, startYear)
|
||||
})
|
||||
|
||||
router.get('/top/categories', async (_req: Request, res: Response) => {
|
||||
router.get('/top/categories', fullAccessAuth(), async (_req: Request, res: Response) => {
|
||||
const result = await getCategoriesAndYears()
|
||||
res.json(result)
|
||||
})
|
||||
@@ -77,7 +89,7 @@ interface GetTeacherStatsRequest extends Request {
|
||||
}
|
||||
|
||||
router.get('/stats', async (req: GetTeacherStatsRequest, res: Response) => {
|
||||
const { roles, programmeRights } = req.user
|
||||
const { roles, programmeRights, iamGroups } = req.user
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
const { providers, semesterStart, semesterEnd } = req.query
|
||||
@@ -86,7 +98,13 @@ router.get('/stats', async (req: GetTeacherStatsRequest, res: Response) => {
|
||||
}
|
||||
|
||||
const providerRights = mapToProviders(fullStudyProgrammeRights)
|
||||
if (!(providers.every(provider => providerRights.includes(provider)) || roles.includes('admin'))) {
|
||||
if (
|
||||
!(
|
||||
(roles.includes('teachers') && providers.every(provider => providerRights.includes(provider))) ||
|
||||
roles.includes('admin') ||
|
||||
iamGroups.some(group => iamGroupsGivingFullAccess.includes(group))
|
||||
)
|
||||
) {
|
||||
return res.status(403).send('You do not have permission to see this data')
|
||||
}
|
||||
|
||||
@@ -114,7 +132,7 @@ interface GetTeacherByIdRequest extends Request {
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/:id', async (req: GetTeacherByIdRequest, res: Response) => {
|
||||
router.get('/:id', fullAccessAuth(), async (req: GetTeacherByIdRequest, res: Response) => {
|
||||
const { id } = req.params
|
||||
const result = await getTeacherStatistics(id)
|
||||
if (!result) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// const router = require('express').Router()
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
} from '../services/faculty/facultyService'
|
||||
import { combineFacultyStudentProgress, FacultyProgressData } from '../services/faculty/facultyStudentProgress'
|
||||
import { getMedian } from '../services/studyProgramme/studyProgrammeHelpers'
|
||||
import { Graduated } from '../types'
|
||||
import { Graduated, NameWithCode } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
@@ -24,7 +23,7 @@ const router = Router()
|
||||
const degreeNames = ['bachelor', 'bachelorMaster', 'master', 'doctor'] as const
|
||||
|
||||
const getProgrammeNames = (faculties: Organization[]) => {
|
||||
return faculties.reduce<Record<string, { code: string; en?: string; fi?: string; sv?: string }>>((obj, faculty) => {
|
||||
return faculties.reduce<Record<string, NameWithCode>>((obj, faculty) => {
|
||||
const { name, code } = faculty.dataValues
|
||||
obj[faculty.code] = { code, ...name }
|
||||
return obj
|
||||
@@ -33,7 +32,7 @@ const getProgrammeNames = (faculties: Organization[]) => {
|
||||
|
||||
interface GetProgressStatsRequest extends Request {
|
||||
query: {
|
||||
specialsIncluded: 'true' | undefined
|
||||
specialsIncluded: 'true' | 'false'
|
||||
graduated: Graduated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ router.get('/refresh_redis_cache', async (req: Request, res: Response) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/refresh-teacher-leaderboard', async (req: Request, res: Response) => {
|
||||
router.post('/refresh-teacher-leaderboard', (req: Request, res: Response) => {
|
||||
logger.info(
|
||||
`${req.user.username} requested refresh of teacher leaderboard for the current and previous academic year`
|
||||
)
|
||||
@@ -90,17 +90,17 @@ router.post('/refresh-teacher-leaderboard', async (req: Request, res: Response)
|
||||
|
||||
router.post('/refresh_study_programmes_v2', async (req: Request, res: Response) => {
|
||||
logger.info(`${req.user.username} requested refresh of study programmes`)
|
||||
refreshProgrammes()
|
||||
await refreshProgrammes()
|
||||
res.status(200).json('Added job for refreshing study programme overviews')
|
||||
})
|
||||
|
||||
router.post('/refresh_faculties_v2', async (req: Request, res: Response) => {
|
||||
logger.info(`${req.user.username} requested refresh of faculties`)
|
||||
refreshFaculties()
|
||||
await refreshFaculties()
|
||||
res.status(200).json('Added job for refreshing faculties')
|
||||
})
|
||||
|
||||
router.post('/refresh_language_center_data', async (req: Request, res: Response) => {
|
||||
router.post('/refresh_language_center_data', (req: Request, res: Response) => {
|
||||
logger.info(`${req.user.username} requested refresh of language center data`)
|
||||
if (!languageCenterViewEnabled)
|
||||
res.status(418).json({ error: 'The language center functionality is not activated in your environment.' })
|
||||
@@ -108,15 +108,15 @@ router.post('/refresh_language_center_data', async (req: Request, res: Response)
|
||||
res.status(200).json('Added job for refreshing language center data')
|
||||
})
|
||||
|
||||
router.post('/refresh-close-to-graduation', async (req: Request, res: Response) => {
|
||||
router.post('/refresh-close-to-graduation', (req: Request, res: Response) => {
|
||||
logger.info(`${req.user.username} requested refresh of close to graduation data`)
|
||||
jobMaker.closeToGraduation()
|
||||
res.status(200).json('Added job for refreshing close to graduation data')
|
||||
})
|
||||
|
||||
router.get('/abort', async (_req: Request, res: Response) => {
|
||||
await abortUpdate()
|
||||
res.status(200).json()
|
||||
const response = await abortUpdate()
|
||||
res.status(200).json(response)
|
||||
})
|
||||
|
||||
router.get('/jobs', async (_req: Request, res: Response) => {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { roles } from '../config/roles'
|
||||
import * as auth from '../middleware/auth'
|
||||
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
|
||||
import * as userService from '../services/userService'
|
||||
import { LANGUAGE_CODES } from '../shared/language'
|
||||
import { Language } from '../types'
|
||||
import { LANGUAGE_CODES, Language } from '../shared/language'
|
||||
import { Role } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
@@ -15,7 +15,7 @@ router.get('/', auth.roles(['admin']), async (_req: Request, res: Response) => {
|
||||
res.json(results)
|
||||
})
|
||||
|
||||
router.get('/access_groups', auth.roles(['admin']), async (_req: Request, res: Response) => {
|
||||
router.get('/roles', auth.roles(['admin']), (_req: Request, res: Response) => {
|
||||
res.json(roles)
|
||||
})
|
||||
|
||||
@@ -35,17 +35,17 @@ router.get('/:uid', auth.roles(['admin']), async (req: UidRequest, res: Response
|
||||
}
|
||||
})
|
||||
|
||||
interface ModifyAccessRequest extends Request {
|
||||
interface ModifyRolesRequest extends Request {
|
||||
body: {
|
||||
username: string
|
||||
accessgroups: Record<string, boolean>
|
||||
roles: Record<Role, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/modifyaccess', auth.roles(['admin']), async (req: ModifyAccessRequest, res: Response) => {
|
||||
const { username, accessgroups } = req.body
|
||||
router.post('/modify-roles', auth.roles(['admin']), async (req: ModifyRolesRequest, res: Response) => {
|
||||
const { username, roles } = req.body
|
||||
try {
|
||||
await userService.modifyAccess(username, accessgroups)
|
||||
await userService.modifyAccess(username, roles)
|
||||
res.status(204).end()
|
||||
} catch (error) {
|
||||
res.status(400).json(error)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { facultyCodes, ignoredFacultyCodes } from '../config/organizationConstants'
|
||||
import { Graduated, SpecialGroups, YearType } from '../types'
|
||||
import { Graduated, SpecialGroups, YearType } from '../shared/types'
|
||||
import { redisClient } from './redis'
|
||||
import { isRelevantProgramme } from './studyProgramme/studyProgrammeHelpers'
|
||||
|
||||
@@ -17,21 +17,25 @@ const createRedisKeyForCreditStats = (id: string, yearType: YearType, specialGro
|
||||
const createRedisKeyForGraduationStats = (id: string, yearType: YearType, specialGroups: SpecialGroups) => {
|
||||
return `GRADUATION_STATS_${id}_${yearType}_${specialGroups}`
|
||||
}
|
||||
const createRedisKeyForStudytrackStats = (id: string, graduated: Graduated, specialGroups: SpecialGroups) => {
|
||||
const createRedisKeyForStudyTrackStats = (id: string, graduated: Graduated, specialGroups: SpecialGroups) => {
|
||||
return `STUDYTRACK_STATS_${id}_${graduated}_${specialGroups}`
|
||||
}
|
||||
|
||||
const shouldSaveToRedis = (id: unknown) => {
|
||||
if (typeof id !== 'string') return false
|
||||
if (isRelevantProgramme(id)) return true
|
||||
const splitId = id.split('-')
|
||||
return splitId.length === 2 && isRelevantProgramme(splitId[0]) && isRelevantProgramme(splitId[1])
|
||||
}
|
||||
|
||||
export const getBasicStats = async (
|
||||
id: string,
|
||||
combinedProgramme: string | null,
|
||||
yearType: YearType,
|
||||
specialGroups: SpecialGroups
|
||||
) => {
|
||||
if (!isRelevantProgramme(id)) {
|
||||
return null
|
||||
}
|
||||
const searchkey = combinedProgramme ? `${id}-${combinedProgramme}` : id
|
||||
const redisKey = createRedisKeyForBasicStats(searchkey, yearType, specialGroups)
|
||||
const searchKey = combinedProgramme ? `${id}-${combinedProgramme}` : id
|
||||
const redisKey = createRedisKeyForBasicStats(searchKey, yearType, specialGroups)
|
||||
const dataFromRedis = await redisClient.get(redisKey)
|
||||
if (!dataFromRedis) {
|
||||
return null
|
||||
@@ -47,7 +51,7 @@ export const setBasicStats = async (data, yearType: YearType, specialGroups: Spe
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
if (!isRelevantProgramme(id)) {
|
||||
if (!shouldSaveToRedis(id)) {
|
||||
return dataToRedis
|
||||
}
|
||||
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
|
||||
@@ -98,11 +102,8 @@ export const getGraduationStats = async (
|
||||
yearType: YearType,
|
||||
specialGroups: SpecialGroups
|
||||
) => {
|
||||
if (!isRelevantProgramme(id)) {
|
||||
return null
|
||||
}
|
||||
const searchkey = combinedProgramme ? `${id}-${combinedProgramme}` : id
|
||||
const redisKey = createRedisKeyForGraduationStats(searchkey, yearType, specialGroups)
|
||||
const searchKey = combinedProgramme ? `${id}-${combinedProgramme}` : id
|
||||
const redisKey = createRedisKeyForGraduationStats(searchKey, yearType, specialGroups)
|
||||
const dataFromRedis = await redisClient.get(redisKey)
|
||||
if (!dataFromRedis) {
|
||||
return null
|
||||
@@ -118,7 +119,7 @@ export const setGraduationStats = async (data, yearType: YearType, specialGroups
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
if (!isRelevantProgramme(id)) {
|
||||
if (!shouldSaveToRedis(id)) {
|
||||
return dataToRedis
|
||||
}
|
||||
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
|
||||
@@ -128,17 +129,14 @@ export const setGraduationStats = async (data, yearType: YearType, specialGroups
|
||||
return dataToRedis
|
||||
}
|
||||
|
||||
export const getStudytrackStats = async (
|
||||
export const getStudyTrackStats = async (
|
||||
id: string,
|
||||
combinedProgramme: string | null,
|
||||
graduated: Graduated,
|
||||
specialGroups: SpecialGroups
|
||||
) => {
|
||||
if (!isRelevantProgramme(id)) {
|
||||
return null
|
||||
}
|
||||
const searchkey = combinedProgramme ? `${id}-${combinedProgramme}` : id
|
||||
const redisKey = createRedisKeyForStudytrackStats(searchkey, graduated, specialGroups)
|
||||
const searchKey = combinedProgramme ? `${id}-${combinedProgramme}` : id
|
||||
const redisKey = createRedisKeyForStudyTrackStats(searchKey, graduated, specialGroups)
|
||||
const dataFromRedis = await redisClient.get(redisKey)
|
||||
if (!dataFromRedis) {
|
||||
return null
|
||||
@@ -146,15 +144,15 @@ export const getStudytrackStats = async (
|
||||
return JSON.parse(dataFromRedis)
|
||||
}
|
||||
|
||||
export const setStudytrackStats = async (data, graduated: Graduated, specialGroups: SpecialGroups) => {
|
||||
export const setStudyTrackStats = async (data, graduated: Graduated, specialGroups: SpecialGroups) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForStudytrackStats(id, graduated, specialGroups)
|
||||
const redisKey = createRedisKeyForStudyTrackStats(id, graduated, specialGroups)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
}
|
||||
if (!isRelevantProgramme(id)) {
|
||||
if (!shouldSaveToRedis(id)) {
|
||||
return dataToRedis
|
||||
}
|
||||
const setOperationStatus = await redisClient.set(redisKey, JSON.stringify(dataToRedis))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { col, fn, Op, where } from 'sequelize'
|
||||
|
||||
import { Course, Credit, Enrollment, Student } from '../models/index'
|
||||
import { Course, Credit, Enrollment, Student, Studyplan } from '../models/index'
|
||||
import { CreditTypeCode, EnrollmentState } from '../types'
|
||||
|
||||
type Courses = Array<Pick<Course, 'code' | 'name' | 'substitutions'>>
|
||||
@@ -15,7 +15,6 @@ interface StudentCredit {
|
||||
interface StudentEnrollment {
|
||||
date: Date
|
||||
courseCode: string
|
||||
state: EnrollmentState
|
||||
substitution: string | null
|
||||
}
|
||||
|
||||
@@ -24,9 +23,10 @@ interface StudentInfo {
|
||||
lastname: string
|
||||
email: string
|
||||
sis_person_id: string
|
||||
secondary_email: string | null
|
||||
coursesInStudyPlan: string[]
|
||||
secondaryEmail: string | null
|
||||
credits: StudentCredit[]
|
||||
enrollments: StudentEnrollment[]
|
||||
enrollments: Record<string, StudentEnrollment>
|
||||
}
|
||||
|
||||
type StudentCredits = Record<
|
||||
@@ -37,16 +37,18 @@ type StudentCredits = Record<
|
||||
}
|
||||
>
|
||||
|
||||
interface FormattedStudent {
|
||||
interface FormattedStudent extends StudentInfo {
|
||||
studentNumber: string
|
||||
sis_person_id: string
|
||||
credits: StudentCredit[]
|
||||
enrollments: Record<string, any>
|
||||
allEnrollments: StudentEnrollment[]
|
||||
firstnames: string
|
||||
lastname: string
|
||||
email: string
|
||||
secondaryEmail: string | null
|
||||
}
|
||||
|
||||
interface StudentWithStudyplanNested
|
||||
extends Pick<Student, 'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'> {
|
||||
studyplans: { included_courses: string[] }[]
|
||||
}
|
||||
|
||||
interface StudentWithCourses extends Omit<StudentWithStudyplanNested, 'studyplans'> {
|
||||
coursesInStudyPlan: string[]
|
||||
}
|
||||
|
||||
const getCourses = async (courseCodes: string[]) => {
|
||||
@@ -84,7 +86,7 @@ const getCredits = async (
|
||||
? null
|
||||
: courses.find(course => course.substitutions.includes(credit.course_code))?.code
|
||||
return {
|
||||
courseCode: originalCode || credit.course_code,
|
||||
courseCode: originalCode ?? credit.course_code,
|
||||
substitution: originalCode ? credit.course_code : null,
|
||||
studentNumber: credit.student_studentnumber,
|
||||
creditType: credit.credittypecode,
|
||||
@@ -95,9 +97,9 @@ const getCredits = async (
|
||||
}
|
||||
|
||||
const getEnrollments = async (courses: Courses, fullCourseCodes: string[], studentNumbers: string[]) => {
|
||||
const enrollments: Array<Pick<Enrollment, 'course_code' | 'enrollment_date_time' | 'studentnumber' | 'state'>> =
|
||||
const enrollments: Array<Pick<Enrollment, 'course_code' | 'enrollment_date_time' | 'studentnumber'>> =
|
||||
await Enrollment.findAll({
|
||||
attributes: ['course_code', 'enrollment_date_time', 'studentnumber', 'state'],
|
||||
attributes: ['course_code', 'enrollment_date_time', 'studentnumber'],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: fullCourseCodes,
|
||||
@@ -113,30 +115,37 @@ const getEnrollments = async (courses: Courses, fullCourseCodes: string[], stude
|
||||
const formattedEnrollments = enrollments.map(enrollment => {
|
||||
const originalCode = courses.find(course => course.substitutions.includes(enrollment.course_code))?.code
|
||||
return {
|
||||
courseCode: originalCode || enrollment.course_code,
|
||||
courseCode: originalCode ?? enrollment.course_code,
|
||||
substitution: originalCode ? enrollment.course_code : null,
|
||||
studentNumber: enrollment.studentnumber,
|
||||
date: enrollment.enrollment_date_time,
|
||||
state: enrollment.state,
|
||||
}
|
||||
})
|
||||
return formattedEnrollments
|
||||
}
|
||||
|
||||
const getStudents = async (studentNumbers: string[]) => {
|
||||
const students: Array<
|
||||
Pick<Student, 'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'>
|
||||
> = (
|
||||
await Student.findAll({
|
||||
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
const students = await Student.findAll({
|
||||
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
})
|
||||
).map(student => student.toJSON())
|
||||
return students
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Studyplan,
|
||||
as: 'studyplans',
|
||||
attributes: ['included_courses'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return students.map(student => {
|
||||
const { studyplans, ...rest } = student.toJSON() as StudentWithStudyplanNested
|
||||
const coursesInStudyPlan = studyplans.flatMap(studyplan => studyplan.included_courses)
|
||||
return { ...rest, coursesInStudyPlan } as StudentWithCourses
|
||||
})
|
||||
}
|
||||
|
||||
export const getCompletedCourses = async (studentNumbers: string[], courseCodes: string[]) => {
|
||||
@@ -161,7 +170,8 @@ export const getCompletedCourses = async (studentNumbers: string[], courseCodes:
|
||||
lastname: student.lastname,
|
||||
email: student.email,
|
||||
sis_person_id: student.sis_person_id,
|
||||
secondary_email: student.secondary_email,
|
||||
secondaryEmail: student.secondary_email,
|
||||
coursesInStudyPlan: student.coursesInStudyPlan,
|
||||
credits: [],
|
||||
enrollments: [],
|
||||
}
|
||||
@@ -201,7 +211,6 @@ export const getCompletedCourses = async (studentNumbers: string[], courseCodes:
|
||||
studentCredits[enrollment.studentNumber].enrollments.push({
|
||||
date: enrollment.date,
|
||||
courseCode: enrollment.courseCode,
|
||||
state: enrollment.state,
|
||||
substitution: enrollment.substitution,
|
||||
})
|
||||
})
|
||||
@@ -217,7 +226,8 @@ export const getCompletedCourses = async (studentNumbers: string[], courseCodes:
|
||||
firstnames: student.firstnames,
|
||||
lastname: student.lastname,
|
||||
email: student.email,
|
||||
secondaryEmail: student.secondary_email,
|
||||
secondaryEmail: student.secondaryEmail,
|
||||
coursesInStudyPlan: student.coursesInStudyPlan,
|
||||
})
|
||||
return acc
|
||||
}, [] as FormattedStudent[])
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { orderBy } from 'lodash'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Course } from '../../models'
|
||||
@@ -52,7 +53,17 @@ const getRawCourses = async (name: string, code: string) => {
|
||||
const getCourses = async (name: string, code: string) => {
|
||||
const rawCourses = await getRawCourses(name, code)
|
||||
const courses: CourseWithSubsId[] = rawCourses
|
||||
.map(course => ({ ...course.dataValues }))
|
||||
.map(course => ({
|
||||
...course.toJSON(),
|
||||
substitutions: orderBy(course.substitutions, [
|
||||
substitution => {
|
||||
if (/^A/.exec(substitution)) return 4 // open university codes come last
|
||||
if (/^\d/.exec(substitution)) return 2 // old numeric codes come second
|
||||
if (/^[A-Za-z]/.exec(substitution)) return 1 // new letter based codes come first
|
||||
return 3 // unknown, comes before open uni?
|
||||
},
|
||||
]),
|
||||
}))
|
||||
.sort((a, b) => getSortRank(b.code) - getSortRank(a.code))
|
||||
return courses
|
||||
}
|
||||
@@ -87,7 +98,7 @@ export const getCoursesByNameAndOrCode = async (name: string, code: string) => {
|
||||
}
|
||||
})
|
||||
|
||||
return { courses }
|
||||
return courses
|
||||
}
|
||||
|
||||
export const getCoursesByCodes = (codes: string[]) => {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { EnrollmentState, Name } from '../../types'
|
||||
import { lengthOf, percentageOf } from '../../util'
|
||||
import { Name } from '../../shared/types'
|
||||
import { EnrollmentState } from '../../types'
|
||||
|
||||
const lengthOf = (obj: object) => Object.keys(obj).length
|
||||
const percentageOf = (num: number, denom: number) => {
|
||||
if (denom === 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.round(((100 * num) / denom) * 100) / 100
|
||||
}
|
||||
|
||||
const fall: string[] = []
|
||||
const spring: string[] = []
|
||||
@@ -21,26 +29,16 @@ const initializePassingSemesters = () => {
|
||||
return passingSemesters
|
||||
}
|
||||
|
||||
const getEnrolledStudents = (enrollments: DynamicEnrollments) => {
|
||||
if (enrollments) {
|
||||
return Object.keys(enrollments)
|
||||
.filter(key => key !== EnrollmentState.ENROLLED && key !== 'semesters')
|
||||
.reduce((acc, key) => [...acc, ...[...enrollments[key]].map(studentNumber => studentNumber)], [] as string[])
|
||||
const getFilteredEnrolledNoGrade = (enrollments: DynamicEnrollments, allStudents: string[]) => {
|
||||
const enrolledStudents = [...(enrollments[EnrollmentState.ENROLLED] ?? [])]
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const student of enrolledStudents) {
|
||||
if (allStudents.includes(student)) {
|
||||
continue
|
||||
}
|
||||
result[student] = true
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const getFilteredEnrolledNoGrade = (
|
||||
enrollments: DynamicEnrollments,
|
||||
enrolledStudents: string[],
|
||||
allStudents: string[]
|
||||
) => {
|
||||
if (enrollments[EnrollmentState.ENROLLED]) {
|
||||
return [...enrollments[EnrollmentState.ENROLLED]]
|
||||
.filter(student => !enrolledStudents.includes(student) && !allStudents.includes(student))
|
||||
.reduce((acc, student) => ({ ...acc, [student]: true }), {} as Record<string, boolean>)
|
||||
}
|
||||
return {} as Record<string, boolean>
|
||||
return result
|
||||
}
|
||||
|
||||
type DynamicEnrollments = {
|
||||
@@ -57,8 +55,6 @@ type Stats = {
|
||||
students: number
|
||||
passed: number
|
||||
failed: number
|
||||
failedMany: number
|
||||
retryPassed: number
|
||||
attempts: number
|
||||
improvedPassedGrade: number
|
||||
percentage: number | undefined
|
||||
@@ -76,8 +72,6 @@ type Students = {
|
||||
all: Record<string, boolean>
|
||||
passed: Record<string, boolean>
|
||||
failed: Record<string, boolean>
|
||||
retryPassed: Record<string, boolean>
|
||||
failedMany: Record<string, boolean>
|
||||
improvedPassedGrade: Record<string, boolean>
|
||||
markedToSemester: Record<string, boolean>
|
||||
enrolledNoGrade: Record<string, boolean>
|
||||
@@ -108,15 +102,13 @@ export type CourseStatistics = {
|
||||
}
|
||||
|
||||
export class CourseStatsCounter {
|
||||
private studentsInTotal: number
|
||||
private course: Course
|
||||
private students: Students
|
||||
private stats: Stats
|
||||
private enrollments: DynamicEnrollments
|
||||
private grades: Grades
|
||||
|
||||
constructor(code: string, name: Name, studentsInTotal: number) {
|
||||
this.studentsInTotal = studentsInTotal
|
||||
constructor(code: string, name: Name) {
|
||||
this.course = {
|
||||
code,
|
||||
name,
|
||||
@@ -126,8 +118,6 @@ export class CourseStatsCounter {
|
||||
all: {},
|
||||
passed: {},
|
||||
failed: {},
|
||||
retryPassed: {},
|
||||
failedMany: {},
|
||||
improvedPassedGrade: {},
|
||||
markedToSemester: {},
|
||||
enrolledNoGrade: {},
|
||||
@@ -136,8 +126,6 @@ export class CourseStatsCounter {
|
||||
students: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
failedMany: 0,
|
||||
retryPassed: 0,
|
||||
attempts: 0,
|
||||
improvedPassedGrade: 0,
|
||||
percentage: undefined,
|
||||
@@ -216,13 +204,11 @@ export class CourseStatsCounter {
|
||||
|
||||
private removeFromFailed(studentNumber: string) {
|
||||
delete this.students.failed[studentNumber]
|
||||
delete this.students.failedMany[studentNumber]
|
||||
}
|
||||
|
||||
private markPassingGrade(studentNumber: string) {
|
||||
this.students.passed[studentNumber] = true
|
||||
if (this.failedBefore(studentNumber)) {
|
||||
this.students.retryPassed[studentNumber] = true
|
||||
this.removeFromFailed(studentNumber)
|
||||
}
|
||||
}
|
||||
@@ -235,12 +221,8 @@ export class CourseStatsCounter {
|
||||
|
||||
private markFailedGrade(studentNumber: string) {
|
||||
if (this.passedBefore(studentNumber)) {
|
||||
this.students.retryPassed[studentNumber] = true
|
||||
this.removeFromFailed(studentNumber)
|
||||
} else {
|
||||
if (this.failedBefore(studentNumber)) {
|
||||
this.students.failedMany[studentNumber] = true
|
||||
}
|
||||
this.students.failed[studentNumber] = true
|
||||
}
|
||||
}
|
||||
@@ -256,10 +238,9 @@ export class CourseStatsCounter {
|
||||
|
||||
private getPassingSemestersCumulative() {
|
||||
const { passingSemesters } = this.stats
|
||||
const attemptStats: Record<string, number> = {
|
||||
BEFORE: passingSemesters.BEFORE,
|
||||
}
|
||||
const attemptStats: Record<string, number> = {}
|
||||
|
||||
attemptStats.BEFORE = passingSemesters.BEFORE
|
||||
attemptStats['0-FALL'] = passingSemesters.BEFORE + passingSemesters['0-FALL']
|
||||
attemptStats['0-SPRING'] = attemptStats['0-FALL'] + passingSemesters['0-SPRING']
|
||||
|
||||
@@ -273,29 +254,26 @@ export class CourseStatsCounter {
|
||||
return attemptStats
|
||||
}
|
||||
|
||||
public getFinalStats() {
|
||||
const { stats, students } = this
|
||||
const enrolledStudents = getEnrolledStudents(this.enrollments)
|
||||
const allStudents = Object.keys(students.all).map(student => student)
|
||||
const filteredEnrolledNoGrade = getFilteredEnrolledNoGrade(this.enrollments, enrolledStudents, allStudents)
|
||||
public getFinalStats(populationCount: number): CourseStatistics {
|
||||
const { stats, students, course, grades } = this
|
||||
const allStudents = Object.keys(students.all)
|
||||
const filteredEnrolledNoGrade = getFilteredEnrolledNoGrade(this.enrollments, allStudents)
|
||||
|
||||
students.all = { ...students.all, ...filteredEnrolledNoGrade }
|
||||
students.enrolledNoGrade = filteredEnrolledNoGrade
|
||||
stats.students = lengthOf(students.all)
|
||||
stats.passed = lengthOf(students.passed)
|
||||
stats.failed = lengthOf(students.failed)
|
||||
stats.failedMany = lengthOf(students.failedMany)
|
||||
stats.retryPassed = lengthOf(students.retryPassed)
|
||||
stats.improvedPassedGrade = lengthOf(students.improvedPassedGrade)
|
||||
stats.percentage = percentageOf(stats.passed, stats.students)
|
||||
stats.passedOfPopulation = percentageOf(stats.passed, this.studentsInTotal)
|
||||
stats.triedOfPopulation = percentageOf(stats.students, this.studentsInTotal)
|
||||
stats.passedOfPopulation = percentageOf(stats.passed, populationCount)
|
||||
stats.triedOfPopulation = percentageOf(stats.students, populationCount)
|
||||
stats.perStudent = percentageOf(stats.attempts, stats.passed + stats.failed) / 100
|
||||
stats.passingSemestersCumulative = this.getPassingSemestersCumulative()
|
||||
stats.totalStudents = stats.students
|
||||
stats.totalEnrolledNoGrade = lengthOf(filteredEnrolledNoGrade)
|
||||
stats.percentageWithEnrollments = percentageOf(stats.passed, stats.totalStudents)
|
||||
|
||||
return { stats, students, course: this.course, grades: this.grades } as CourseStatistics
|
||||
return { stats, students, course, grades }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { EnrollmentState, Name } from '../../types'
|
||||
import { max, min } from 'lodash'
|
||||
|
||||
import { Name } from '../../shared/types'
|
||||
import { getSemesterNameByCode } from '../semesters'
|
||||
import { FormattedProgramme, OrganizationDetails } from './helpers'
|
||||
|
||||
type Programme = {
|
||||
@@ -34,13 +37,8 @@ type GroupAttempts = {
|
||||
}
|
||||
|
||||
type GroupStudents = {
|
||||
categories: {
|
||||
passedFirst: string[]
|
||||
passedEventually: string[]
|
||||
neverPassed: string[]
|
||||
}
|
||||
grades: { [studentNumber: string]: { grade: string; passed: boolean } }
|
||||
studentnumbers: string[]
|
||||
studentNumbers: string[]
|
||||
}
|
||||
|
||||
type Group = {
|
||||
@@ -49,14 +47,13 @@ type Group = {
|
||||
coursecode: string
|
||||
attempts: GroupAttempts
|
||||
students: GroupStudents
|
||||
enrollments: { studentNumber: string; state: string; enrollmentDateTime: Date }[]
|
||||
allEnrollments: { studentNumber: string; state: string; enrollmentDateTime: Date }[]
|
||||
yearcode: number
|
||||
enrollments: { studentNumber: string; enrollmentDateTime: Date }[]
|
||||
allEnrollments: { studentNumber: string; enrollmentDateTime: Date }[]
|
||||
yearCode: number
|
||||
}
|
||||
|
||||
type Student = {
|
||||
earliestAttainment: Date
|
||||
category: 'passedFirst' | 'passedEventually' | 'neverPassed'
|
||||
code: number
|
||||
}
|
||||
|
||||
@@ -117,17 +114,12 @@ export class CourseYearlyStatsCounter {
|
||||
},
|
||||
},
|
||||
students: {
|
||||
categories: {
|
||||
passedFirst: [],
|
||||
passedEventually: [],
|
||||
neverPassed: [],
|
||||
},
|
||||
grades: {},
|
||||
studentnumbers: [],
|
||||
studentNumbers: [],
|
||||
},
|
||||
enrollments: [],
|
||||
allEnrollments: [],
|
||||
yearcode: yearCode,
|
||||
yearCode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +139,6 @@ export class CourseYearlyStatsCounter {
|
||||
if (!this.facultyStats[yearCode]) {
|
||||
this.initFacultyYear(yearCode)
|
||||
}
|
||||
|
||||
if (
|
||||
facultyCode &&
|
||||
!this.facultyStats[yearCode].faculties[facultyCode] &&
|
||||
@@ -163,7 +154,6 @@ export class CourseYearlyStatsCounter {
|
||||
this.programmes[code].passed[yearCode] = []
|
||||
this.programmes[code].credits[yearCode] = 0
|
||||
}
|
||||
|
||||
if (facultyCode && !this.facultyStats[yearCode].allStudents.includes(studentNumber)) {
|
||||
this.facultyStats[yearCode].allStudents.push(studentNumber)
|
||||
this.facultyStats[yearCode].faculties[facultyCode].students.push(studentNumber)
|
||||
@@ -172,7 +162,6 @@ export class CourseYearlyStatsCounter {
|
||||
this.programmes[code].passed[yearCode].push(studentNumber)
|
||||
this.programmes[code].credits[yearCode] += credits
|
||||
}
|
||||
|
||||
if (facultyCode && passed && !this.facultyStats[yearCode].allPassed.includes(studentNumber)) {
|
||||
this.facultyStats[yearCode].allPassed.push(studentNumber)
|
||||
this.facultyStats[yearCode].faculties[facultyCode].passed.push(studentNumber)
|
||||
@@ -211,7 +200,6 @@ export class CourseYearlyStatsCounter {
|
||||
|
||||
public markEnrollmentToGroup(
|
||||
studentNumber: string,
|
||||
state: string,
|
||||
enrollmentDateTime: Date,
|
||||
groupCode: number,
|
||||
groupName: string | Name,
|
||||
@@ -221,22 +209,13 @@ export class CourseYearlyStatsCounter {
|
||||
if (!this.groups[groupCode]) {
|
||||
this.initGroup(groupCode, groupName, courseCode, yearCode)
|
||||
}
|
||||
const enrollment = { studentNumber, state, enrollmentDateTime }
|
||||
const enrollment = { studentNumber, enrollmentDateTime }
|
||||
this.groups[groupCode].allEnrollments.push(enrollment)
|
||||
const oldEnrollment = this.groups[groupCode].enrollments.find(
|
||||
enrollment => enrollment.studentNumber === studentNumber
|
||||
)
|
||||
if (!oldEnrollment) {
|
||||
return this.groups[groupCode].enrollments.push({ studentNumber, state, enrollmentDateTime })
|
||||
}
|
||||
if (oldEnrollment.state === EnrollmentState.ENROLLED || oldEnrollment.state === EnrollmentState.CONFIRMED) {
|
||||
return
|
||||
}
|
||||
if (state !== EnrollmentState.ENROLLED && state !== EnrollmentState.CONFIRMED) {
|
||||
return
|
||||
}
|
||||
if (![EnrollmentState.ENROLLED, EnrollmentState.CONFIRMED].includes(state as EnrollmentState)) {
|
||||
return
|
||||
return this.groups[groupCode].enrollments.push({ studentNumber, enrollmentDateTime })
|
||||
}
|
||||
this.groups[groupCode].enrollments = this.groups[groupCode].enrollments
|
||||
.filter(enrollment => enrollment.studentNumber !== studentNumber)
|
||||
@@ -272,56 +251,25 @@ export class CourseYearlyStatsCounter {
|
||||
this.groups[groupCode].students.grades[studentNumber] = { grade, passed }
|
||||
}
|
||||
|
||||
public markCreditToStudentCategories(
|
||||
studentNumber: string,
|
||||
passed: boolean,
|
||||
attainmentDate: Date,
|
||||
groupCode: number
|
||||
) {
|
||||
public markCreditToStudentCategories(studentNumber: string, attainmentDate: Date, groupCode: number) {
|
||||
if (!this.students.has(studentNumber)) {
|
||||
this.addNewStudent(studentNumber, passed, attainmentDate, groupCode)
|
||||
this.addOrUpdateStudent(studentNumber, attainmentDate, groupCode)
|
||||
} else {
|
||||
this.updateExistingStudent(studentNumber, passed, attainmentDate, groupCode)
|
||||
this.updateExistingStudent(studentNumber, attainmentDate, groupCode)
|
||||
}
|
||||
}
|
||||
|
||||
private addNewStudent(studentNumber: string, passed: boolean, attainmentDate: Date, groupCode: number) {
|
||||
const category = passed ? 'passedFirst' : 'neverPassed'
|
||||
private addOrUpdateStudent(studentNumber: string, attainmentDate: Date, groupCode: number) {
|
||||
this.students.set(studentNumber, {
|
||||
earliestAttainment: attainmentDate,
|
||||
category,
|
||||
code: groupCode,
|
||||
})
|
||||
}
|
||||
|
||||
private updateExistingStudent(studentNumber: string, passed: boolean, attainmentDate: Date, groupCode: number) {
|
||||
private updateExistingStudent(studentNumber: string, attainmentDate: Date, groupCode: number) {
|
||||
const student = this.students.get(studentNumber)
|
||||
if (attainmentDate < student!.earliestAttainment) {
|
||||
this.updateCategoryForEarlierAttainment(studentNumber, passed, attainmentDate, groupCode, student!)
|
||||
} else if (student!.category === 'neverPassed' && passed) {
|
||||
this.students.set(studentNumber, { ...student!, category: 'passedEventually' })
|
||||
}
|
||||
}
|
||||
|
||||
private updateCategoryForEarlierAttainment(
|
||||
studentNumber: string,
|
||||
passed: boolean,
|
||||
attainmentDate: Date,
|
||||
groupCode: number,
|
||||
student: Student
|
||||
) {
|
||||
if (passed) {
|
||||
this.students.set(studentNumber, {
|
||||
earliestAttainment: attainmentDate,
|
||||
category: 'passedFirst',
|
||||
code: groupCode,
|
||||
})
|
||||
} else if (student.category === 'passedFirst') {
|
||||
this.students.set(studentNumber, {
|
||||
earliestAttainment: attainmentDate,
|
||||
category: 'passedEventually',
|
||||
code: groupCode,
|
||||
})
|
||||
this.addOrUpdateStudent(studentNumber, attainmentDate, groupCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,15 +287,42 @@ export class CourseYearlyStatsCounter {
|
||||
return this.programmes
|
||||
}
|
||||
|
||||
private parseGroupStatistics(anonymizationSalt: string | null) {
|
||||
for (const [studentNumber, data] of this.students) {
|
||||
this.groups[data.code].students.categories[data.category].push(studentNumber)
|
||||
this.groups[data.code].students.studentnumbers.push(studentNumber)
|
||||
private insertEmptyRows = async () => {
|
||||
const first = min(Object.keys(this.groups).map(Number))
|
||||
const last = max(Object.keys(this.groups).map(Number))
|
||||
if (!(first && last)) {
|
||||
return
|
||||
}
|
||||
|
||||
const getNextYear = (name: string) => {
|
||||
const years = name.split('-')
|
||||
const startYear = parseInt(years[0], 10) + 1
|
||||
const endYear = parseInt(years[1], 10) + 1
|
||||
return `${startYear}-${endYear}`
|
||||
}
|
||||
|
||||
for (let i = first + 1; i <= last; i++) {
|
||||
if (this.groups[i]) {
|
||||
continue
|
||||
}
|
||||
|
||||
const previous = this.groups[i - 1]
|
||||
const semester = await getSemesterNameByCode(i)
|
||||
const name = typeof previous.name === 'string' ? getNextYear(previous.name) : semester.name
|
||||
this.initGroup(i, name, this.groups[i - 1].coursecode, i)
|
||||
}
|
||||
}
|
||||
|
||||
private async parseGroupStatistics(anonymizationSalt: string | null) {
|
||||
for (const [studentNumber, data] of this.students) {
|
||||
this.groups[data.code].students.studentNumbers.push(studentNumber)
|
||||
}
|
||||
|
||||
await this.insertEmptyRows()
|
||||
|
||||
const groupStatistics = Object.values(this.groups).map(({ ...rest }) => {
|
||||
const { students } = rest
|
||||
const grades = {}
|
||||
const grades = {} as Grades
|
||||
Object.keys(students.grades).forEach(student => {
|
||||
const { grade, passed } = students.grades[student]
|
||||
const parsedGrade = passed ? grade : '0'
|
||||
@@ -360,7 +335,7 @@ export class CourseYearlyStatsCounter {
|
||||
...rest,
|
||||
students: { ...students, grades },
|
||||
}
|
||||
if (anonymizationSalt && normalStats.students.studentnumbers.length < 6) {
|
||||
if (anonymizationSalt && normalStats.students.studentNumbers.length < 6) {
|
||||
this.obfuscated = true
|
||||
const gradeSpread = {} as Grades
|
||||
for (const grade of Object.keys(normalStats.attempts.grades)) {
|
||||
@@ -378,14 +353,9 @@ export class CourseYearlyStatsCounter {
|
||||
},
|
||||
grades: gradeSpread,
|
||||
},
|
||||
yearcode: rest.yearcode,
|
||||
yearCode: rest.yearCode,
|
||||
students: {
|
||||
categories: {
|
||||
passedFirst: [] as string[],
|
||||
passedEventually: [] as string[],
|
||||
neverPassed: [] as string[],
|
||||
},
|
||||
studentnumbers: [] as string[],
|
||||
studentNumbers: [] as string[],
|
||||
},
|
||||
}
|
||||
return obfuscatedStats
|
||||
@@ -411,10 +381,10 @@ export class CourseYearlyStatsCounter {
|
||||
return this.facultyStats
|
||||
}
|
||||
|
||||
public getFinalStatistics(anonymizationSalt: string | null) {
|
||||
public async getFinalStatistics(anonymizationSalt: string | null) {
|
||||
return {
|
||||
programmes: this.parseProgrammeStatistics(anonymizationSalt),
|
||||
statistics: this.parseGroupStatistics(anonymizationSalt),
|
||||
statistics: await this.parseGroupStatistics(anonymizationSalt),
|
||||
facultyStats: this.parseFacultyStatistics(anonymizationSalt),
|
||||
obfuscated: this.obfuscated,
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
|
||||
const studyRightIds = Object.keys(studyRightMap)
|
||||
|
||||
const studyRightElements = await SISStudyRightElement.findAll({
|
||||
attributes: ['code', 'name', 'startDate', 'studyRightId'],
|
||||
attributes: ['code', 'name', 'startDate', 'endDate', 'studyRightId'],
|
||||
where: {
|
||||
studyRightId: {
|
||||
[Op.in]: studyRightIds,
|
||||
@@ -95,7 +95,7 @@ export const getEnrollmentsForCourses = async (codes: string[], unification: Uni
|
||||
[Op.in]: codes,
|
||||
},
|
||||
enrollment_date_time: { [Op.gte]: new Date('2021-05-31') },
|
||||
state: [EnrollmentState.ENROLLED, EnrollmentState.CONFIRMED],
|
||||
state: EnrollmentState.ENROLLED,
|
||||
is_open: getIsOpen(unification),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Name, Unification } from '../../types'
|
||||
import { Name } from '../../shared/types'
|
||||
import { Unification } from '../../types'
|
||||
|
||||
export type FormattedProgramme = {
|
||||
code: string
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import crypto from 'crypto'
|
||||
import moment from 'moment'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Course, Credit, Enrollment, Organization, SISStudyRightElement } from '../../models'
|
||||
import { Name, Unification } from '../../types'
|
||||
import { Name } from '../../shared/types'
|
||||
import { EnrollmentState, Unification } from '../../types'
|
||||
import { isOpenUniCourseCode } from '../../util'
|
||||
import { getSortRank } from '../../util/sortRank'
|
||||
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
|
||||
@@ -65,11 +66,22 @@ const parseCredit = (
|
||||
credits,
|
||||
attainment_date: attainmentDate,
|
||||
student_studentnumber: studentNumber,
|
||||
studyright_id: studyRightId,
|
||||
} = credit
|
||||
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
|
||||
|
||||
const studyRightElements = studentNumberToSrElementsMap[studentNumber] || []
|
||||
const programmes = studyRightElements.map(formatStudyRightElement)
|
||||
let programmeOfCredit: SISStudyRightElement | undefined
|
||||
programmeOfCredit = studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId)
|
||||
if (!programmeOfCredit) {
|
||||
programmeOfCredit = studyRightElements
|
||||
.filter(studyRightElement =>
|
||||
moment(attainmentDate).isBetween(moment(studyRightElement.startDate), moment(studyRightElement.endDate))
|
||||
)
|
||||
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())[0] // The newest studyRightElement
|
||||
}
|
||||
|
||||
const programmes = programmeOfCredit ? [programmeOfCredit].map(formatStudyRightElement) : []
|
||||
|
||||
const formattedCredit: FormattedCredit = {
|
||||
yearCode,
|
||||
@@ -99,7 +111,6 @@ type FormattedEnrollment = {
|
||||
semesterCode: number
|
||||
semesterName: Name
|
||||
courseCode: string
|
||||
state: string
|
||||
enrollmentDateTime: Date
|
||||
studentNumber: string
|
||||
programmes: Array<Record<string, any>>
|
||||
@@ -114,7 +125,6 @@ const parseEnrollment = (
|
||||
const {
|
||||
studentnumber: studentNumber,
|
||||
semester,
|
||||
state,
|
||||
enrollment_date_time: enrollmentDateTime,
|
||||
course_code: courseCode,
|
||||
} = enrollment
|
||||
@@ -128,7 +138,6 @@ const parseEnrollment = (
|
||||
semesterCode,
|
||||
semesterName,
|
||||
courseCode,
|
||||
state,
|
||||
enrollmentDateTime,
|
||||
studentNumber,
|
||||
programmes: studyRightElements.map(formatStudyRightElement),
|
||||
@@ -218,32 +227,42 @@ const getYearlyStatsOfNew = async (
|
||||
credits
|
||||
)
|
||||
counter.markCreditToGroup(studentNumber, passed, grade, groupCode, groupName, courseCode, yearCode)
|
||||
counter.markCreditToStudentCategories(studentNumber, passed, attainmentDate, groupCode)
|
||||
counter.markCreditToStudentCategories(studentNumber, attainmentDate, groupCode)
|
||||
}
|
||||
|
||||
enrollments.forEach(enrollment => {
|
||||
const { studentNumber, semesterCode, semesterName, yearCode, yearName, courseCode, state, enrollmentDateTime } =
|
||||
const { studentNumber, semesterCode, semesterName, yearCode, yearName, courseCode, enrollmentDateTime } =
|
||||
parseEnrollment(enrollment, anonymizationSalt, studentNumberToSrElementsMap)
|
||||
|
||||
const groupCode = separate ? semesterCode : yearCode
|
||||
const groupName = separate ? semesterName : yearName
|
||||
|
||||
counter.markEnrollmentToGroup(studentNumber, state, enrollmentDateTime, groupCode, groupName, courseCode, yearCode)
|
||||
counter.markEnrollmentToGroup(studentNumber, enrollmentDateTime, groupCode, groupName, courseCode, yearCode)
|
||||
})
|
||||
|
||||
const statistics = counter.getFinalStatistics(anonymizationSalt)
|
||||
const statistics = await counter.getFinalStatistics(anonymizationSalt)
|
||||
|
||||
let substitutionCourses: Course[] | undefined
|
||||
if (combineSubstitutions && courseForSubs?.substitutions && courseForSubs.substitutions.length > 0) {
|
||||
substitutionCourses = await Course.findAll({
|
||||
where: {
|
||||
code: {
|
||||
[Op.in]: codes,
|
||||
},
|
||||
},
|
||||
attributes: ['code', 'name'],
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...statistics,
|
||||
coursecode: courseCode,
|
||||
alternatives: codes,
|
||||
alternatives: substitutionCourses ?? [{ code: courseForSubs!.code, name: courseForSubs!.name }],
|
||||
name: course?.name,
|
||||
}
|
||||
}
|
||||
|
||||
export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unification: Unification) => {
|
||||
const is_open = getIsOpen(unification)
|
||||
|
||||
const maxAttainmentDate = new Date(
|
||||
Math.max(
|
||||
...(
|
||||
@@ -270,7 +289,7 @@ export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unif
|
||||
attainment_date: {
|
||||
[Op.gt]: attainmentThreshold,
|
||||
},
|
||||
is_open,
|
||||
is_open: getIsOpen(unification),
|
||||
},
|
||||
order: [['attainment_date', 'ASC']],
|
||||
})
|
||||
@@ -311,7 +330,12 @@ export const getCourseYearlyStats = async (
|
||||
})
|
||||
const enrollments = await Enrollment.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
where: { course_code: { [Op.in]: courseCodes } },
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: courseCodes,
|
||||
},
|
||||
state: EnrollmentState.ENROLLED,
|
||||
},
|
||||
})
|
||||
|
||||
const studentNumbers = {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user