mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7531a9def4 | ||
|
|
f5b81d73ac | ||
|
|
4544d1cdec | ||
|
|
072360763f | ||
|
|
639858f874 | ||
|
|
92f3e67293 | ||
|
|
983390040e | ||
|
|
277b97327e | ||
|
|
3dab357396 | ||
|
|
a39648f380 | ||
|
|
ccb1865853 | ||
|
|
81da089798 | ||
|
|
785b7d5d74 | ||
|
|
aca70d5442 | ||
|
|
41d93c9b43 | ||
|
|
835c9e580b | ||
|
|
9c9f317ecb | ||
|
|
3ee6758c72 | ||
|
|
88764f83de | ||
|
|
e8908d28fc | ||
|
|
a6d7da315f | ||
|
|
e72e0bb5ad | ||
|
|
15b0286eda | ||
|
|
1104483cac | ||
|
|
604bcf4998 | ||
|
|
cc839a9e14 | ||
|
|
2a076ef6e8 | ||
|
|
b05e1c22e5 | ||
|
|
b9800c857f | ||
|
|
6d535bf4bb | ||
|
|
714f85d5c0 | ||
|
|
01af4cfd2c | ||
|
|
fe55b770e9 | ||
|
|
43e4e605d6 | ||
|
|
15a54f1e26 | ||
|
|
631b5be00a | ||
|
|
b8d9648b8c | ||
|
|
d52562330b | ||
|
|
f4c9137a05 | ||
|
|
9a1802b6b4 | ||
|
|
fd2d95a832 | ||
|
|
dfb557d4e1 | ||
|
|
22e2ccf562 | ||
|
|
68480a7211 | ||
|
|
bb4edef070 | ||
|
|
6877716468 | ||
|
|
a21e91fbaa | ||
|
|
62e7de5fef | ||
|
|
785b84b14c | ||
|
|
e488327fd4 | ||
|
|
2642e15479 | ||
|
|
547d1c7d35 | ||
|
|
d57ecb2aa9 | ||
|
|
ef76b0f350 | ||
|
|
3ba2bd8d5b | ||
|
|
12976a961b | ||
|
|
01c6159ffa | ||
|
|
a6c1e3891d | ||
|
|
6c4b42c98d | ||
|
|
10941259a2 | ||
|
|
b7563cd4ec | ||
|
|
8d261aae51 | ||
|
|
ccf70ed420 | ||
|
|
20f4bd32f8 | ||
|
|
d62140e97d | ||
|
|
0e510f039f | ||
|
|
893d3847e2 | ||
|
|
0c46fcdd09 | ||
|
|
4397a542c2 | ||
|
|
25a4975265 | ||
|
|
b89967fb17 | ||
|
|
36ce4f1405 | ||
|
|
71d5849360 | ||
|
|
6f335a3555 | ||
|
|
e7eab4741e | ||
|
|
64a67151d6 | ||
|
|
229d155b7c | ||
|
|
42ef2b0245 | ||
|
|
79bfb690b7 | ||
|
|
1b8b71075a | ||
|
|
a77bba0881 | ||
|
|
eda8dcac97 | ||
|
|
b0e0bae727 | ||
|
|
47d5faec81 | ||
|
|
8f90824d8b | ||
|
|
e16f7ddf85 | ||
|
|
0c02d25257 | ||
|
|
68484728d2 | ||
|
|
1d3fbdf869 | ||
|
|
c22b415b85 | ||
|
|
920c1aac5f | ||
|
|
31787e6df0 | ||
|
|
82a4ea9465 | ||
|
|
bb7f965299 | ||
|
|
ff5fac8f0d | ||
|
|
cede895135 | ||
|
|
0ebff9dd39 | ||
|
|
ce94943d54 | ||
|
|
98f388a8a3 | ||
|
|
9b366e2974 | ||
|
|
452fb93b2c | ||
|
|
b9cd6aab7f | ||
|
|
42e76e25e6 | ||
|
|
5ae4f80fab | ||
|
|
d441f3a6b3 | ||
|
|
d22e746e02 | ||
|
|
5b5292d53a | ||
|
|
12f90192c6 | ||
|
|
94d38fecc1 | ||
|
|
6a649b5f00 | ||
|
|
ab391472a6 | ||
|
|
267067e83a | ||
|
|
17f376a681 | ||
|
|
9d83a06377 | ||
|
|
0f5c2e3160 | ||
|
|
364ba0bca3 | ||
|
|
9d5f2a51e3 | ||
|
|
d9260c17f8 | ||
|
|
612cb08689 | ||
|
|
ae90dfa006 | ||
|
|
a4635d8d5b | ||
|
|
2a48dc5a77 | ||
|
|
778807ecd0 | ||
|
|
eca6b643bd | ||
|
|
cde59177ed | ||
|
|
c229e4f030 | ||
|
|
cff0cbef04 | ||
|
|
c0a4e9d8e5 | ||
|
|
3cd2bcefea | ||
|
|
4b40011f6b | ||
|
|
b8c147ceec | ||
|
|
5d169b3321 | ||
|
|
1b86aada0e | ||
|
|
f3b027a609 | ||
|
|
3797fed1a9 | ||
|
|
ec3f36a9e4 | ||
|
|
ed7677d126 | ||
|
|
bcbeeaceb5 | ||
|
|
f80200d481 | ||
|
|
e64e0d04fd | ||
|
|
85432092fa | ||
|
|
b6a39c829e | ||
|
|
0a6edf6638 | ||
|
|
2d44506c55 | ||
|
|
d5dc511b39 | ||
|
|
1e9c2857c5 | ||
|
|
58190f8757 | ||
|
|
caa715677b | ||
|
|
94c34a10eb | ||
|
|
f86c90ff8c | ||
|
|
617379e63f | ||
|
|
cb1b44d4a3 | ||
|
|
2ae9d662dd | ||
|
|
cd73900749 | ||
|
|
60a03b8643 | ||
|
|
35ffc21b91 | ||
|
|
e2886c9e35 | ||
|
|
c50d365dcb | ||
|
|
6843b587cd | ||
|
|
d1d4e3105e | ||
|
|
0fe018bb96 | ||
|
|
d2685b13d7 | ||
|
|
ec2bdcc8ea | ||
|
|
37518f554a | ||
|
|
5b33803d9f | ||
|
|
5dd534e4d9 | ||
|
|
589bcb6128 | ||
|
|
a5af6ebd47 | ||
|
|
08b3d29b91 | ||
|
|
8ee1cac66f | ||
|
|
32a9cdbea6 | ||
|
|
372dbd4769 | ||
|
|
49089975db | ||
|
|
e4ff103cb1 | ||
|
|
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 | ||
|
|
640dc741a4 |
@@ -9,10 +9,7 @@ runs:
|
||||
node-version: 20
|
||||
- name: 'Install dependencies'
|
||||
shell: bash
|
||||
run: npm ci && cd services/frontend && npm ci && cd ../backend && npm ci
|
||||
- name: 'Create symlink for shared'
|
||||
shell: bash
|
||||
run: ln -s $PWD/services/shared $PWD/services/backend/src/shared && ln -s $PWD/services/shared $PWD/services/frontend/src/shared
|
||||
run: npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend
|
||||
- name: 'Run Stylelint for CSS files'
|
||||
shell: bash
|
||||
run: npm run stylelint "services/frontend/src/**/*.css"
|
||||
|
||||
@@ -95,10 +95,10 @@ jobs:
|
||||
run: docker create --name frontend quay.io/toska/oodikone-frontend:production
|
||||
|
||||
- name: Copy assets from the frontend container
|
||||
run: docker cp frontend:/opt/app-root/src/build ./build
|
||||
run: docker cp frontend:/opt/app-root/frontend/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 }}
|
||||
@@ -129,10 +129,10 @@ jobs:
|
||||
run: docker create --name backend quay.io/toska/oodikone-backend:production
|
||||
|
||||
- name: Copy assets from the backend container
|
||||
run: docker cp backend:/opt/app-root/src/dist ./dist
|
||||
run: docker cp backend:/opt/app-root/backend/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 }}
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
environment: production
|
||||
set_commits: 'skip'
|
||||
sourcemaps: './dist'
|
||||
url_prefix: '~/opt/app-root/src/dist/'
|
||||
url_prefix: '~/opt/app-root/backend/dist/'
|
||||
version: ${{ steps.extract_version.outputs.VERSION }}
|
||||
|
||||
report_success:
|
||||
|
||||
@@ -13,18 +13,18 @@ on:
|
||||
- 'package*.json'
|
||||
|
||||
jobs:
|
||||
lint_backend_and_frontend:
|
||||
name: 'Lint backend and frontend'
|
||||
lint_packages:
|
||||
name: 'Lint packages'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Lint backend and frontend
|
||||
- name: Lint backend, frontend and shared
|
||||
uses: ./.github/actions/lint
|
||||
|
||||
test_end_to_end:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- lint_backend_and_frontend
|
||||
- lint_packages
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -41,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
|
||||
|
||||
@@ -12,12 +12,12 @@ on:
|
||||
|
||||
jobs:
|
||||
# === Static tests / linting ===
|
||||
lint_backend_and_frontend:
|
||||
name: 'Lint backend and frontend'
|
||||
lint_packages:
|
||||
name: 'Lint packages'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Lint backend and frontend
|
||||
- name: Lint backend, frontend and shared
|
||||
uses: ./.github/actions/lint
|
||||
|
||||
# === Build database images ===
|
||||
@@ -199,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
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
with:
|
||||
node-version: 18
|
||||
- name: 'Install dependencies'
|
||||
run: npm ci && cd updater/sis-updater-scheduler && npm ci && cd ../sis-updater-worker && npm ci
|
||||
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
|
||||
- name: 'Run linter'
|
||||
run: npm run eslint -- updater
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+663
-490
File diff suppressed because it is too large
Load Diff
@@ -56,11 +56,10 @@ describe('Faculty statistics', () => {
|
||||
})
|
||||
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Basic information')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Students by starting year')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Progress')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Graduation times')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Update statistics')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,67 +71,66 @@ describe('Faculty statistics', () => {
|
||||
|
||||
describe('Info boxes', () => {
|
||||
it('Students of the faculty', () => {
|
||||
cy.get('[data-cy=StudentsOfTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=StudentsOfTheFacultyInfoBoxContent]').contains('Taulukon luvut on')
|
||||
cy.cs('students-of-the-faculty-info-box-button').click()
|
||||
cy.cs('students-of-the-faculty-info-box-content').contains('Taulukon luvut on')
|
||||
})
|
||||
|
||||
it('Graduated of the faculty', () => {
|
||||
cy.get('[data-cy=GraduatedOfTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=GraduatedOfTheFacultyInfoBoxContent]').contains('Sisältää kyseisenä')
|
||||
cy.cs('graduated-of-the-faculty-info-box-button').click()
|
||||
cy.cs('graduated-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
|
||||
})
|
||||
|
||||
it('Thesis writers of the faculty', () => {
|
||||
cy.get('[data-cy=ThesisWritersOfTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=ThesisWritersOfTheFacultyInfoBoxContent]').contains('Sisältää kyseisenä')
|
||||
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('Credits produced by the faculty', () => {
|
||||
cy.get('[data-cy=CreditsProducedByTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=CreditsProducedByTheFacultyInfoBoxContent]').contains('Sisältää opintopisteet')
|
||||
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('All graphs and tables are shown', () => {
|
||||
cy.get('[data-cy="StudentsOfTheFacultyGraph"]').should('be.visible')
|
||||
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('be.visible')
|
||||
cy.get('[data-cy="GraduatedOfTheFacultyGraph"]').should('be.visible')
|
||||
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('be.visible')
|
||||
cy.get('[data-cy="ThesisWritersOfTheFacultyGraph"]').should('be.visible')
|
||||
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('be.visible')
|
||||
cy.get('[data-cy="CreditsProducedByTheFacultyGraph"]').should('be.visible')
|
||||
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('be.visible')
|
||||
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('Correct tabs are shown', () => {
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Basic information')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Students by starting year')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Progress')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Graduation times')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('not.contain', 'Update statistics')
|
||||
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('Toggle years works', () => {
|
||||
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('contain', '2022')
|
||||
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
|
||||
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
|
||||
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
|
||||
cy.get('[data-cy="YearToggle"]').click()
|
||||
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
|
||||
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('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')
|
||||
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.get('[data-cy="FacultyExcludeSpecialsInfo"]').should('not.exist')
|
||||
cy.get('[data-cy="StudentToggle"]').click()
|
||||
cy.get('[data-cy="FacultyExcludeSpecialsInfo"]').should('be.visible')
|
||||
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 }, () => {
|
||||
@@ -144,7 +142,7 @@ describe('Faculty statistics', () => {
|
||||
'CreditsProducedByTheFaculty',
|
||||
]
|
||||
sections.forEach(section => {
|
||||
cy.get(`[data-cy="${section}Graph"]`).within(() => {
|
||||
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`
|
||||
@@ -163,14 +161,14 @@ describe('Faculty statistics', () => {
|
||||
})
|
||||
|
||||
it('Export button', { retries: 2 }, () => {
|
||||
cy.get('[data-cy=FacultyStudentTableExportButton]').should('be.visible').and('not.be.disabled').click()
|
||||
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('Info box', () => {
|
||||
cy.get('[data-cy=FacultyStudentTableInfoBoxButton]').click()
|
||||
cy.get('[data-cy=FacultyStudentTableInfoBoxContent]').contains('Opiskelijat, joiden')
|
||||
cy.cs('faculty-student-table-info-box-button').click()
|
||||
cy.cs('faculty-student-table-info-box-content').contains('Opiskelijat, joiden')
|
||||
})
|
||||
|
||||
it.skip('Study right toggle', () => {
|
||||
@@ -194,10 +192,10 @@ describe('Faculty statistics', () => {
|
||||
})
|
||||
|
||||
it('Percentage toggle works', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', '92.3 %')
|
||||
cy.get('[data-cy="HidePercentagesToggle"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('contain', '92.3 %')
|
||||
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 %')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -210,33 +208,33 @@ describe('Faculty statistics', () => {
|
||||
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.get('[data-cy=FacultyProgressExportButton]').should('be.visible').and('not.be.disabled').click()
|
||||
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.get('[data-cy=FacultyProgressInfoBoxButton]').click()
|
||||
cy.get('[data-cy=FacultyProgressInfoBoxContent]').contains('Kuvaa tiedekuntaan')
|
||||
cy.cs('FacultyProgress-info-box-button').click()
|
||||
cy.cs('FacultyProgress-info-box-content').contains('Kuvaa tiedekuntaan')
|
||||
// TODO: Click open the accordion first
|
||||
cy.get('[data-cy=BachelorMastersProgressInfoBoxButton]').click()
|
||||
cy.get('[data-cy=BachelorMastersProgressInfoBoxContent]').contains('The starting year is the')
|
||||
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.get('[data-cy="FacultyBachelorsProgressBarChart"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorMastersProgressBarChart"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyMastersProgressBarChart"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorsProgressBarChart"]').should('be.visible')
|
||||
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.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')
|
||||
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', () => {
|
||||
@@ -257,14 +255,14 @@ describe('Faculty statistics', () => {
|
||||
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.get('[data-cy=AverageGraduationTimesExportButton]').should('be.visible').and('not.be.disabled').click()
|
||||
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))
|
||||
})
|
||||
|
||||
it('Info boxes', () => {
|
||||
cy.get('[data-cy=AverageGraduationTimesInfoBoxButton]').click()
|
||||
cy.get('[data-cy=AverageGraduationTimesInfoBoxContent]').contains('Opiskelijoiden keskimääräiset')
|
||||
cy.cs('average-graduation-times-info-box-button').click()
|
||||
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset')
|
||||
})
|
||||
|
||||
it.skip('Study times toggle', () => {
|
||||
@@ -280,10 +278,10 @@ describe('Faculty statistics', () => {
|
||||
})
|
||||
|
||||
it('Graphs are visible', () => {
|
||||
cy.get('[data-cy="bachelorGraduationTimes"]').should('be.visible')
|
||||
cy.cs('bachelor-graduation-times-section').should('be.visible')
|
||||
// ! No test data for bachelor + master
|
||||
cy.get('[data-cy="masterGraduationTimes"]').should('be.visible')
|
||||
cy.get('[data-cy="doctorGraduationTimes"]').should('be.visible')
|
||||
cy.cs('master-graduation-times-section').should('be.visible')
|
||||
cy.cs('doctor-graduation-times-section').should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+85
-37
@@ -48,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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,10 +79,9 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,8 +92,7 @@ describe("Population statistics with a master's programme", () => {
|
||||
|
||||
it('is set to all by default', () => {
|
||||
runTestStepWithPreAndPostParts('studyRightTypeFilter', () => {
|
||||
const card = cy.cs('studyRightTypeFilter-filter-card')
|
||||
card.get('[data-cy="all"] input').should('be.checked')
|
||||
cy.cs('studyRightTypeFilter-filter-card').get('[data-cy="all"] input').should('be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -127,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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -197,7 +194,7 @@ describe('Population Statistics', () => {
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(1)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(6)
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(7)
|
||||
checkFilteringResult(21)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
})
|
||||
@@ -219,15 +216,19 @@ 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,7 +249,7 @@ describe('Population Statistics', () => {
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(13)
|
||||
cy.cs('StartYearAtUni-header').click()
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(5)
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(7)
|
||||
checkFilteringResult(8)
|
||||
cy.cs('reset-all-filters').click()
|
||||
checkFilteringResult(defaultAmountOfStudents)
|
||||
@@ -259,6 +260,50 @@ describe('Population Statistics', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Course Statistics', () => {
|
||||
const pathToLimits2021 =
|
||||
'/coursepopulation?coursecodes=%5B"MAT11003"%2C"57116"%2C"57016"%2C"AYMAT11003"%5D&from=72&separate=false&to=72&unifyCourses=unifyStats&years=2021-2022'
|
||||
@@ -288,10 +333,9 @@ describe('Course 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(33)
|
||||
programmeDropdown.get('i.delete').click()
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -349,9 +393,10 @@ 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', () => {
|
||||
@@ -369,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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -402,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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+10
-4
@@ -43,10 +43,16 @@ describe('Open uni population tests', () => {
|
||||
cy.contains('01.01.2015')
|
||||
cy.contains('End of the search for enrollments:')
|
||||
cy.contains('01.01.2020')
|
||||
cy.url().should(
|
||||
'include',
|
||||
'/openunipopulation?courseCode=TKT10001&courseCode=TKT10002&enddate=01-01-2020&startdate=01-01-2015'
|
||||
)
|
||||
cy.location('pathname').should('eq', '/openunipopulation')
|
||||
cy.location('search').then(search => {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
const courseCodes = params.getAll('courseCode')
|
||||
expect(courseCodes).to.include.members(['TKT10001', 'TKT10002'])
|
||||
|
||||
expect(params.get('startdate')).to.eq('01-01-2015')
|
||||
expect(params.get('enddate')).to.eq('01-01-2020')
|
||||
})
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
|
||||
})
|
||||
|
||||
@@ -3,19 +3,20 @@
|
||||
|
||||
const selectStudyProgramme = programme => {
|
||||
cy.contains('Select study programme')
|
||||
cy.get('[data-cy=select-study-programme]').click().children().contains(programme).click()
|
||||
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().children().contains(studyTrack).click()
|
||||
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')
|
||||
|
||||
@@ -90,90 +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 Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('AYMAT11001 Avoin yo: Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('57033 Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('A57033 Avoin yo: 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()
|
||||
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/v2/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/v2/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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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.
|
||||
@@ -200,121 +117,251 @@ 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('Taloustieteen maisteriohjelma')
|
||||
cy.contains('Todistusvalinta')
|
||||
cy.contains('Saksa')
|
||||
cy.contains('Female')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -332,7 +379,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()
|
||||
|
||||
+65
-48
@@ -1,15 +1,15 @@
|
||||
/// <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.get("input[placeholder='Search with a student number or name (surname firstname)']").type(text)
|
||||
cy.cs('student-search').type(text)
|
||||
}
|
||||
|
||||
const typeStudentNumberAndClick = studentNumber => {
|
||||
@@ -17,10 +17,6 @@ const typeStudentNumberAndClick = studentNumber => {
|
||||
cy.contains('td', studentNumber).click()
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
cy.get("input[placeholder='Search with a student number or name (surname firstname)']").clear()
|
||||
}
|
||||
|
||||
describe('Students tests', () => {
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
@@ -31,65 +27,84 @@ describe('Students tests', () => {
|
||||
})
|
||||
|
||||
it('Students search form is usable', () => {
|
||||
typeToSearch(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', () => {
|
||||
typeToSearch(student.lastname.slice(0, 3))
|
||||
typeToSearch(student.lastName.slice(0, 3))
|
||||
cy.contains('Search term is not accurate enough')
|
||||
typeToSearch(student.lastname.slice(3))
|
||||
typeToSearch(student.lastName.slice(3))
|
||||
cy.get('table tbody tr').should('have.length', 1)
|
||||
})
|
||||
|
||||
it('Can search with studentnumber too', () => {
|
||||
typeToSearch(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', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
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('[data-cy=student-info-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()
|
||||
@@ -98,26 +113,26 @@ describe('Students tests', () => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT12004 Tilastollinen päättely I')
|
||||
cy.contains('AYMAT12004 Avoin yo: Tilastollinen päättely I')
|
||||
cy.contains('57046 Johdatus tilastolliseen päättelyyn')
|
||||
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=sisu-link]')
|
||||
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('[data-cy=student-info-card]').within(() => {
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('Enrollments').click()
|
||||
cy.get('table tbody tr')
|
||||
.should('have.length', 3)
|
||||
@@ -129,17 +144,19 @@ describe('Students tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("Searching with bad inputs doesn't yield results", () => {
|
||||
it("Searching with bad name doesn't yield results", () => {
|
||||
typeToSearch('SWAG LITTINEN')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
clearSearch()
|
||||
})
|
||||
|
||||
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.get('[data-cy=study-rights-section]').within(() => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('study-rights-section').within(() => {
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
.parent()
|
||||
.within(() => {
|
||||
@@ -151,16 +168,16 @@ describe('Students tests', () => {
|
||||
})
|
||||
|
||||
it('Grade graph works in all three different modes', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('button', 'Grade graph').click()
|
||||
cy.contains('button', 'Show group mean').click()
|
||||
cy.get('[data-cy=group-size-input]').within(() => {
|
||||
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.get('[data-cy=group-size-input]').within(() => {
|
||||
cy.cs('group-size-input').within(() => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').clear()
|
||||
cy.get('input').type('10')
|
||||
@@ -215,7 +232,7 @@ describe('Students tests', () => {
|
||||
})
|
||||
|
||||
it("Shows 'Has not graduated' when the student has not graduated", () => {
|
||||
cy.visit(`students/${student.studentnumber}`)
|
||||
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')
|
||||
@@ -229,14 +246,14 @@ describe('Students tests', () => {
|
||||
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('[data-cy=student-info-card]').within(() => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('button', 'Update student')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,14 +79,17 @@ describe('Study guidance group tests', () => {
|
||||
})
|
||||
|
||||
it('general tab of the students table has the correct columns', () => {
|
||||
const firstRowHeadings = ['Student number', 'Credits', 'Start year at uni', 'Study programmes', 'Tags']
|
||||
const secondRowHeadings = ['All', 'Since 1.1.1970']
|
||||
const colHeaders = [
|
||||
'Student number',
|
||||
'All credits',
|
||||
'Credits since 1.1.1970',
|
||||
'Start year at uni',
|
||||
'Study programmes',
|
||||
'Tags',
|
||||
]
|
||||
cy.cs('Students (2)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').then($tr => {
|
||||
const firstRowTexts = Array.from($tr[0].children).map(elem => elem.innerText.replace('\n', ' '))
|
||||
expect(firstRowTexts).to.deep.equal(firstRowHeadings)
|
||||
const secondRowTexts = Array.from($tr[1].children).map(elem => elem.innerText)
|
||||
expect(secondRowTexts).to.deep.equal(secondRowHeadings)
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
|
||||
colHeaders.every(heading => cy.contains(heading))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -124,14 +127,16 @@ 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'])
|
||||
})
|
||||
})
|
||||
|
||||
it('general tab of the students table has the correct columns', () => {
|
||||
const firstRowHeadings = [
|
||||
const colHeaders = [
|
||||
'Student number',
|
||||
'Credits',
|
||||
'All credits',
|
||||
'Credits in HOPS',
|
||||
'Credits since 1.8.2020',
|
||||
'Start of study right',
|
||||
'Started in programme',
|
||||
'Semesters present',
|
||||
@@ -146,15 +151,9 @@ describe('Study guidance group tests', () => {
|
||||
'Latest attainment date',
|
||||
'Tags',
|
||||
]
|
||||
const secondRowHeadings = ['All', 'HOPS', 'Since 1.8.2020']
|
||||
cy.cs('Students (3)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').then($tr => {
|
||||
const firstRowTexts = Array.from($tr[0].children).map(elem => elem.innerText.replaceAll('\n', ' '))
|
||||
expect(firstRowTexts).to.deep.equal(firstRowHeadings)
|
||||
const secondRowTexts = Array.from($tr[1].children)
|
||||
.filter(elem => elem.style.display !== 'none')
|
||||
.map(elem => elem.innerText)
|
||||
expect(secondRowTexts).to.deep.equal(secondRowHeadings)
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
|
||||
colHeaders.every(heading => cy.contains(heading))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
/* eslint-disable cypress/unsafe-to-chain-command */
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const { getEmptyYears } = require('../support/commands')
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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.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', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Transferred Away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
[2023, 8, 8, 26, 0, 0],
|
||||
[2022, 25, 26, 48, 1, 4],
|
||||
[2021, 29, 32, 47, 0, 2],
|
||||
[2020, 26, 27, 12, 1, 3],
|
||||
[2019, 28, 34, 1, 0, 1],
|
||||
[2018, 40, 45, 0, 0, 0],
|
||||
[2017, 41, 47, 0, 0, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'students-of-the-study-programme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct credits', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 222],
|
||||
[2022, 3235, 3205, 0, 30, 209],
|
||||
[2021, 5133, 5108, 0, 25, 428],
|
||||
[2020, 5801, 5796, 0, 5, 94],
|
||||
[2019, 5305, 5305, 0, 0, 162],
|
||||
[2018, 3442, 3432, 0, 10, 21],
|
||||
[2017, 1211, 1211, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
it("Toggling 'Show special categories' on displays additional information", () => {
|
||||
cy.cs('special-categories-toggle').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 0, 0, 222],
|
||||
[2022, 3235, 3205, 0, 30, 0, 0, 209],
|
||||
[2021, 5133, 5108, 0, 25, 0, 0, 428],
|
||||
[2020, 5801, 5796, 0, 5, 0, 0, 94],
|
||||
[2019, 5305, 5305, 0, 0, 0, 0, 162],
|
||||
[2018, 3442, 3432, 0, 10, 0, 0, 21],
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct thesis writers and graduates', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 26, 5],
|
||||
[2022, 48, 13],
|
||||
[2021, 47, 76],
|
||||
[2020, 12, 23],
|
||||
[2019, 1, 19],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
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]
|
||||
...years.map(year => [year, 0, 0, 0]),
|
||||
[2023, 8, 8, 24],
|
||||
[2022, 25, 26, 44],
|
||||
[2021, 29, 32, 46],
|
||||
[2020, 26, 27, 11],
|
||||
[2019, 28, 34, 1],
|
||||
[2018, 40, 45, 0],
|
||||
[2017, 41, 47, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const graduatedTableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 24, 5],
|
||||
[2022, 44, 12],
|
||||
[2021, 46, 69],
|
||||
[2020, 11, 22],
|
||||
[2019, 1, 18],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(graduatedTableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
it('Year can be changed to academic year, and data changes accordingly', () => {
|
||||
cy.cs('year-toggle').click()
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Transferred away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 4, 0, 0],
|
||||
['2022 - 2023', 25, 26, 36, 0, 0],
|
||||
['2021 - 2022', 29, 32, 57, 1, 6],
|
||||
['2020 - 2021', 26, 27, 30, 0, 3],
|
||||
['2019 - 2020', 28, 34, 7, 1, 1],
|
||||
['2018 - 2019', 40, 45, 0, 0, 0],
|
||||
['2017 - 2018', 41, 47, 0, 0, 0],
|
||||
]
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const creditTableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 160, 160, 0, 0, 67],
|
||||
['2022 - 2023', 2725, 2720, 0, 5, 337],
|
||||
['2021 - 2022', 4092, 4042, 0, 50, 198],
|
||||
['2020 - 2021', 5420, 5415, 0, 5, 321],
|
||||
['2019 - 2020', 6043, 6043, 0, 0, 101],
|
||||
['2018 - 2019', 4856, 4851, 0, 5, 107],
|
||||
['2017 - 2018', 2350, 2345, 0, 5, 26],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
|
||||
cy.cs('year-toggle').click()
|
||||
})
|
||||
|
||||
it('Basic information graphs render', () => {
|
||||
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.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.cs('graduated-and-thesis-writers-of-the-programme-bar-chart-section')
|
||||
.should('contain', 'Graduated students')
|
||||
.should('contain', 'Wrote thesis')
|
||||
.should('contain', 47)
|
||||
.should('contain', 76)
|
||||
|
||||
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')
|
||||
cy.contains('48 students graduated in year 2022')
|
||||
cy.contains('median study time: 44.5 months')
|
||||
cy.contains('22 graduated on time')
|
||||
cy.contains('12 graduated max year overtime')
|
||||
cy.contains('14 graduated over year late')
|
||||
})
|
||||
|
||||
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
|
||||
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
|
||||
.should('contain', 'Datatieteen maisteriohjelma')
|
||||
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
.should('contain', 32)
|
||||
.should('contain', 21)
|
||||
.should('contain', 17)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graduation times of master programmes', () => {
|
||||
it('are split into two graphs', () => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
|
||||
|
||||
cy.cs('bachelor-breakdown-bar-chart-section')
|
||||
cy.cs('master-breakdown-bar-chart-section')
|
||||
|
||||
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')
|
||||
cy.contains('2 students graduated in year 2021')
|
||||
cy.contains('median study time: 25 months')
|
||||
cy.contains('1 graduated on time')
|
||||
cy.contains('1 graduated max year overtime')
|
||||
cy.contains('0 graduated over year late')
|
||||
})
|
||||
|
||||
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')
|
||||
cy.contains('11 students graduated in year 2023')
|
||||
cy.contains('median study time: 69 months')
|
||||
cy.contains('3 graduated on time')
|
||||
cy.contains('7 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Study tracks and class statistics tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
})
|
||||
|
||||
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('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, 1],
|
||||
['2022 - 2023', 26, 25, 0, 0, 24, 2, 19, 7, 0, 25, 1],
|
||||
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
|
||||
['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, 224, 12],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
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(() => {
|
||||
cy.get('td i.angle.right.icon').click()
|
||||
})
|
||||
|
||||
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, 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],
|
||||
]
|
||||
|
||||
dataForStudyTracks.forEach((data, index) => {
|
||||
cy.get('tbody tr.regular-row')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
cy.get('td').each((cell, i) => {
|
||||
cy.wrap(cell).contains(data[i])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.get('td i.angle.down.icon').click()
|
||||
})
|
||||
|
||||
cy.get('tbody tr.regular-row').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('total', () => {
|
||||
cy.cs('total-population-link-button').click()
|
||||
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()
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
it('Student progress data is shown correctly', () => {
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 26, 9, 9, 4, 3, 0, 1, 0],
|
||||
['2021 - 2022', 38, 8, 6, 11, 8, 5, 0, 0],
|
||||
['2020 - 2021', 30, 2, 1, 3, 7, 4, 4, 9],
|
||||
['2019 - 2020', 35, 1, 0, 2, 1, 0, 4, 27],
|
||||
['2018 - 2019', 45, 0, 1, 1, 2, 0, 3, 38],
|
||||
['2017 - 2018', 47, 0, 1, 3, 0, 1, 2, 40],
|
||||
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'study-programme-progress')
|
||||
})
|
||||
|
||||
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.cs('programme-progress-bar-chart-section').contains('49.8%').trigger('mouseover', { force: true })
|
||||
cy.contains('At least 180 credits: 114')
|
||||
})
|
||||
|
||||
it('Average graduation times section', () => {
|
||||
cy.cs('average-graduation-times-section').within(() => {
|
||||
cy.contains('2020 - 2021')
|
||||
cy.get('[aria-label="2020 - 2021, 15. On time."]').trigger('mouseover')
|
||||
cy.contains('On time: 15')
|
||||
cy.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.cs('graduation-time-toggle').click()
|
||||
|
||||
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')
|
||||
cy.contains('median study time: 33 months')
|
||||
cy.contains('15 graduated on time')
|
||||
cy.contains('3 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Study track can be changed', () => {
|
||||
beforeEach(() => {
|
||||
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.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, 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, 88, 4],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.cs('2020-population-link-button').first().click()
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
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('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.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')
|
||||
cy.get('[aria-label="2020 - 2021, 9. On time."]').trigger('mouseover')
|
||||
cy.contains('Graduated On time: 9 students')
|
||||
})
|
||||
|
||||
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')
|
||||
cy.contains('From class of 2020 - 2021, 12/13 students have graduated')
|
||||
cy.contains('median study time: 34 months')
|
||||
cy.contains('9 graduated on time')
|
||||
cy.contains('2 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Programme courses tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('programme-courses-tab').click()
|
||||
|
||||
cy.cs('by-credit-type-section')
|
||||
cy.cs('by-credit-type-tab')
|
||||
cy.cs('by-semester-tab')
|
||||
})
|
||||
|
||||
describe('By credit type tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT21001')
|
||||
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
|
||||
cy.get('td').eq(3).contains('341')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('time range selection works', () => {
|
||||
cy.cs('from-year-select').click()
|
||||
cy.cs('from-year-select-option-2021').click()
|
||||
cy.cs('to-year-select').click()
|
||||
cy.cs('to-year-select-option-2022').click()
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT21001')
|
||||
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
|
||||
cy.get('td').eq(3).contains('121')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('year toggle works', () => {
|
||||
cy.cs('year-toggle').click()
|
||||
|
||||
cy.cs('from-year-select').click()
|
||||
cy.cs('from-year-select-option-2017').click()
|
||||
cy.cs('to-year-select').click()
|
||||
cy.cs('to-year-select-option-2023').click()
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT21001')
|
||||
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
|
||||
cy.get('td').eq(3).contains('311')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: Fix sorting / filters
|
||||
it.skip('different sorting options work', () => {
|
||||
cy.cs('CoursesSortableTable').within(() => {
|
||||
// Course code
|
||||
cy.get('th').eq(0).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('odgi-zef0')
|
||||
cy.get('td').eq(1).contains('Työ- ja organisaatiopsykologian perusopinnot')
|
||||
})
|
||||
cy.get('th').eq(0).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT-yht')
|
||||
cy.get('td').eq(1).contains('Muut opinnot')
|
||||
})
|
||||
|
||||
// Course name
|
||||
cy.get('th').eq(1).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT20003')
|
||||
cy.get('td').eq(1).contains('Äidinkielen opinnot')
|
||||
})
|
||||
cy.get('th').eq(1).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
})
|
||||
|
||||
// Total credits
|
||||
cy.get('th').eq(2).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT110')
|
||||
cy.get('td').eq(1).contains('Matematiikka, perusopinnot')
|
||||
})
|
||||
cy.get('th').eq(2).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT20001')
|
||||
cy.get('td').eq(1).contains('Kypsyysnäyte')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("'Show credits/Show students' toggle works", () => {
|
||||
cy.get('thead tr').within(() => {
|
||||
cy.get('th').should('have.length', 8)
|
||||
const headers = ['Type', 'Code', 'Name', 'Total', 'Major', 'Non-major', 'Non-degree', 'Transferred']
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
|
||||
cy.cs('show-credits-students-toggle').click()
|
||||
|
||||
cy.get('thead tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 10)
|
||||
const headers = [
|
||||
'Type',
|
||||
'Code',
|
||||
'Name',
|
||||
'Total',
|
||||
'Passed',
|
||||
'Not',
|
||||
'Major',
|
||||
'Non-major',
|
||||
'Non-degree',
|
||||
'Transferred',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT21001')
|
||||
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
|
||||
cy.get('td').eq(3).contains('341')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('By semester tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('by-semester-tab').click()
|
||||
})
|
||||
|
||||
it('test', () => {
|
||||
// TODO: Implement some real tests for this tab
|
||||
cy.contains('From')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Degree courses tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('degree-courses-tab').click()
|
||||
})
|
||||
|
||||
it('Curriculum section', () => {
|
||||
cy.cs('curriculum-section')
|
||||
cy.contains('Select curriculum to edit:')
|
||||
cy.cs('curriculum-picker').contains('2023–2026')
|
||||
// TODO: Test changing the curriculum
|
||||
})
|
||||
|
||||
describe('Credit criteria section', () => {
|
||||
it('info box', () => {
|
||||
cy.cs('credit-criteria-section')
|
||||
cy.cs('credit-criteria-info-box-button').click()
|
||||
cy.cs('credit-criteria-info-box-content').contains('Here you can change')
|
||||
})
|
||||
|
||||
it.skip('changing the credit criteria works', () => {
|
||||
// TODO: Test using the inputs
|
||||
// TODO: Button status
|
||||
// TODO: Test "previously set to _" text changing when saving
|
||||
})
|
||||
})
|
||||
|
||||
it('Degree course table', () => {
|
||||
cy.cs('degree-course-table')
|
||||
// TODO: Test clicking the arrow buttons
|
||||
// TODO: Test toggling visibility for MODULES and COURSES
|
||||
// TODO: Test changing criterion labels
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tags tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
cy.cs('tags-tab').click()
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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 studentInput = '477806,478275; 478953 479239\n 480080'
|
||||
const studentNumbers = studentInput.match(/[^\s,;]+/g)
|
||||
|
||||
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.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: ${tagName}`)
|
||||
cy.contains('Students (5)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (5)').click()
|
||||
})
|
||||
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.contains(studentNumber)
|
||||
}
|
||||
|
||||
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(tagName).should('not.exist')
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags').should('not.exist')
|
||||
cy.contains(tagName).should('not.exist')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('IAM user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme', 'onlyiamrights')
|
||||
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('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.cs('basic-information-tab').click()
|
||||
|
||||
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 study tracks', () => {
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
|
||||
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.cs('degree-courses-tab').should('not.exist')
|
||||
cy.cs('tags-tab').should('not.exist')
|
||||
cy.cs('update-statistics-tab').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,804 +0,0 @@
|
||||
/// <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')
|
||||
}
|
||||
|
||||
describe('Study programme overview', () => {
|
||||
/* Basic information overview -tests */
|
||||
describe('Basic information view 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 contains correct students', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Transferred Away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
[2023, 8, 8, 26, 0, 0],
|
||||
[2022, 25, 26, 48, 1, 4],
|
||||
[2021, 29, 32, 47, 0, 2],
|
||||
[2020, 26, 27, 12, 1, 3],
|
||||
[2019, 28, 34, 1, 0, 1],
|
||||
[2018, 40, 45, 0, 0, 0],
|
||||
[2017, 41, 47, 0, 0, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudentsOfTheStudyProgramme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct credits', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 222],
|
||||
[2022, 3235, 3205, 0, 30, 209],
|
||||
[2021, 5133, 5108, 0, 25, 428],
|
||||
[2020, 5801, 5796, 0, 5, 94],
|
||||
[2019, 5305, 5305, 0, 0, 162],
|
||||
[2018, 3442, 3432, 0, 10, 21],
|
||||
[2017, 1211, 1211, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyProgramme')
|
||||
})
|
||||
|
||||
it("Toggling 'Show special categories' on displays additional information", () => {
|
||||
cy.contains('Show special categories').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 0, 0, 222],
|
||||
[2022, 3235, 3205, 0, 30, 0, 0, 209],
|
||||
[2021, 5133, 5108, 0, 25, 0, 0, 428],
|
||||
[2020, 5801, 5796, 0, 5, 0, 0, 94],
|
||||
[2019, 5305, 5305, 0, 0, 0, 0, 162],
|
||||
[2018, 3442, 3432, 0, 10, 0, 0, 21],
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyProgramme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct thesis writers and graduates', () => {
|
||||
const years = getEmptyYears()
|
||||
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],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'GraduatedAndThesisWritersOfTheProgramme')
|
||||
})
|
||||
|
||||
it('Special study rights can be excluded and basic data changes accordingly', () => {
|
||||
cy.get('[data-cy=StudentToggle]').click()
|
||||
const years = getEmptyYears()
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated]
|
||||
...years.map(year => [year, 0, 0, 0]),
|
||||
[2023, 8, 8, 24],
|
||||
[2022, 25, 26, 44],
|
||||
[2021, 29, 32, 46],
|
||||
[2020, 26, 27, 11],
|
||||
[2019, 28, 34, 1],
|
||||
[2018, 40, 45, 0],
|
||||
[2017, 41, 47, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyProgramme')
|
||||
|
||||
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],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(graduatedTableContents, 'GraduatedAndThesisWritersOfTheProgramme')
|
||||
})
|
||||
|
||||
it('Year can be changed to academic year, and data changes accordingly', () => {
|
||||
cy.get('[data-cy=YearToggle]').click({ force: true })
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Transferred away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 4, 0, 0],
|
||||
['2022 - 2023', 25, 26, 36, 0, 0],
|
||||
['2021 - 2022', 29, 32, 57, 1, 6],
|
||||
['2020 - 2021', 26, 27, 30, 0, 3],
|
||||
['2019 - 2020', 28, 34, 7, 1, 1],
|
||||
['2018 - 2019', 40, 45, 0, 0, 0],
|
||||
['2017 - 2018', 41, 47, 0, 0, 0],
|
||||
]
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyProgramme')
|
||||
|
||||
const creditTableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 160, 160, 0, 0, 67],
|
||||
['2022 - 2023', 2725, 2720, 0, 5, 337],
|
||||
['2021 - 2022', 4092, 4042, 0, 50, 198],
|
||||
['2020 - 2021', 5420, 5415, 0, 5, 321],
|
||||
['2019 - 2020', 6043, 6043, 0, 0, 101],
|
||||
['2018 - 2019', 4856, 4851, 0, 5, 107],
|
||||
['2017 - 2018', 2350, 2345, 0, 5, 26],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyProgramme')
|
||||
cy.get('[data-cy=YearToggle]').click()
|
||||
})
|
||||
|
||||
it('Basic information graphs render', () => {
|
||||
cy.get('[data-cy=Graph-StudentsOfTheStudyProgramme')
|
||||
.should('contain', 'Started studying')
|
||||
.should('contain', 'Accepted')
|
||||
.should('contain', 'Graduated')
|
||||
.should('contain', 'Transferred away')
|
||||
.should('contain', 'Transferred to')
|
||||
|
||||
cy.get('[data-cy=Graph-CreditsProducedByTheStudyProgramme')
|
||||
.should('contain', 'Degree students')
|
||||
.should('contain', 'Transferred')
|
||||
.should('contain', 5796)
|
||||
.should('contain', 428)
|
||||
|
||||
cy.get('[data-cy=Graph-GraduatedAndThesisWritersOfTheProgramme')
|
||||
.should('contain', 'Graduated students')
|
||||
.should('contain', 'Wrote thesis')
|
||||
.should('contain', 47)
|
||||
.should('contain', 66)
|
||||
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2022')
|
||||
cy.contains('48 graduated').trigger('mouseover')
|
||||
cy.contains('48 students graduated in year 2022')
|
||||
cy.contains('median study time: 44.5 months')
|
||||
cy.contains('22 graduated on time')
|
||||
cy.contains('12 graduated max year overtime')
|
||||
cy.contains('14 graduated over year late')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=Graph-ProgrammesBeforeOrAfter')
|
||||
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
|
||||
.should('contain', 'Datatieteen maisteriohjelma')
|
||||
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
.should('contain', 32)
|
||||
.should('contain', 21)
|
||||
.should('contain', 17)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graduation times of master programmes', () => {
|
||||
it('are split into two graphs', () => {
|
||||
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.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get('[data-cy=graduation-times-graphMaster]').within(() => {
|
||||
cy.contains('Master study right')
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2021')
|
||||
cy.contains('2 graduated').trigger('mouseover')
|
||||
cy.contains('2 students graduated in year 2021')
|
||||
cy.contains('median study time: 25 months')
|
||||
cy.contains('1 graduated on time')
|
||||
cy.contains('1 graduated max year overtime')
|
||||
cy.contains('0 graduated over year late')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
|
||||
cy.contains('Bachelor + master study right')
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2023')
|
||||
cy.contains('11 graduated').trigger('mouseover')
|
||||
cy.contains('11 students graduated in year 2023')
|
||||
cy.contains('median study time: 69 months')
|
||||
cy.contains('3 graduated on time')
|
||||
cy.contains('7 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/* Study track overview tests */
|
||||
describe('Study track overview works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Study tracks and class statistics').click()
|
||||
})
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('Study tracks and class statistics -tab loads', () => {
|
||||
cy.get('[data-cy=Section-studyTrackOverview]')
|
||||
cy.get('[data-cy=Section-studyTrackProgress]')
|
||||
cy.get('[data-cy=Section-averageGraduationTimesStudyTracks]')
|
||||
})
|
||||
|
||||
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, 1],
|
||||
['2022 - 2023', 26, 25, 0, 0, 24, 2, 19, 7, 0, 25, 1],
|
||||
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
|
||||
['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, 224, 12],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudyTrackOverview')
|
||||
})
|
||||
|
||||
it('Years in the students table can be expanded and study track data will be shown', () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.get('td i.angle.right.icon').click()
|
||||
})
|
||||
|
||||
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, 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],
|
||||
]
|
||||
|
||||
dataForStudyTracks.forEach((data, index) => {
|
||||
cy.get('tbody tr.regular-row')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
cy.get('td').each((cell, i) => {
|
||||
cy.wrap(cell).contains(data[i])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.get('td i.angle.down.icon').click()
|
||||
})
|
||||
|
||||
cy.get('tbody tr.regular-row').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains('class size 26 students')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with all years combined work', { retries: 2 }, () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.get('td.total-row-cell a').click()
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('class size 227 students')
|
||||
})
|
||||
|
||||
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('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', () => {
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 26, 9, 9, 4, 3, 0, 1, 0],
|
||||
['2021 - 2022', 38, 8, 6, 11, 8, 5, 0, 0],
|
||||
['2020 - 2021', 30, 2, 1, 3, 7, 4, 4, 9],
|
||||
['2019 - 2020', 35, 1, 0, 2, 1, 0, 4, 27],
|
||||
['2018 - 2019', 45, 0, 1, 1, 2, 0, 3, 38],
|
||||
['2017 - 2018', 47, 0, 1, 3, 0, 1, 2, 40],
|
||||
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudyTrackProgress')
|
||||
})
|
||||
|
||||
it('Study track overview graphs render', () => {
|
||||
cy.get('[data-cy=Graph-StudyTrackProgress]')
|
||||
.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.contains('At least 180 credits: 114')
|
||||
|
||||
cy.get("[data-cy='Section-KH50_001']").within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.get('[aria-label="2020 - 2021, 15. On time."]').trigger('mouseover')
|
||||
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("Click a bar to view that year's study track level breakdown").should('not.exist')
|
||||
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.get("[data-cy='Section-KH50_001']").within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('19 graduated').trigger('mouseover')
|
||||
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
|
||||
cy.contains('median study time: 33 months')
|
||||
cy.contains('15 graduated on time')
|
||||
cy.contains('3 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('Students of the study track are shown correctly', () => {
|
||||
cy.get("[data-cy='Section-studyTrackOverview']").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, 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, 88, 4],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'StudyTrackOverview')
|
||||
})
|
||||
|
||||
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.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('Info message about missing progress stats is displayed', () => {
|
||||
cy.contains('.divider', 'Progress of students of the study track 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.'
|
||||
)
|
||||
})
|
||||
|
||||
it('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')
|
||||
cy.get('[aria-label="2020 - 2021, 9. On time."]').trigger('mouseover')
|
||||
cy.contains('Graduated On time: 9 students')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get("[data-cy='graduation-times-graphBachelor']").within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('12 graduated').trigger('mouseover')
|
||||
cy.contains('From class of 2020 - 2021, 12/13 students have graduated')
|
||||
cy.contains('median study time: 34 months')
|
||||
cy.contains('9 graduated on time')
|
||||
cy.contains('2 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/* Programme courses -tests */
|
||||
describe('Programme courses works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Programme courses').click()
|
||||
})
|
||||
|
||||
it('content loads', () => {
|
||||
cy.get('[data-cy=CoursesYearFilter]')
|
||||
cy.get('[data-cy=CourseTabs]')
|
||||
})
|
||||
|
||||
it('time range selection works', () => {
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
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.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
cy.get('td').eq(2).contains('15')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
cy.get('td').eq(2).contains('75')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('contains correct courses in alphabetical order', () => {
|
||||
cy.get('[data-cy=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(() => {
|
||||
// Course code
|
||||
cy.get('th').eq(0).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('odgi-zef0')
|
||||
cy.get('td').eq(1).contains('Työ- ja organisaatiopsykologian perusopinnot')
|
||||
})
|
||||
cy.get('th').eq(0).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT-yht')
|
||||
cy.get('td').eq(1).contains('Muut opinnot')
|
||||
})
|
||||
|
||||
// Course name
|
||||
cy.get('th').eq(1).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT20003')
|
||||
cy.get('td').eq(1).contains('Äidinkielen opinnot')
|
||||
})
|
||||
cy.get('th').eq(1).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
})
|
||||
|
||||
// Total credits
|
||||
cy.get('th').eq(2).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT110')
|
||||
cy.get('td').eq(1).contains('Matematiikka, perusopinnot')
|
||||
})
|
||||
cy.get('th').eq(2).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT20001')
|
||||
cy.get('td').eq(1).contains('Kypsyysnäyte')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("'Show credits/Show students' toggle works", () => {
|
||||
cy.get('[data-cy=CoursesSortableTable] thead tr').within(() => {
|
||||
cy.get('th').should('have.length', 8)
|
||||
const headers = [
|
||||
'Code',
|
||||
'Name',
|
||||
'Total credits',
|
||||
'Major credits',
|
||||
'Non-major credits',
|
||||
'Non-degree credits',
|
||||
'Transferred credits',
|
||||
'Type',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
cy.get('td').eq(2).contains('75')
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy=creditsStudentsToggle]').click()
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] thead tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 7)
|
||||
const headers = [
|
||||
'Code',
|
||||
'Name',
|
||||
'Total',
|
||||
'Breakdown of total',
|
||||
'Breakdown of passed students',
|
||||
'Not included in total nor passed',
|
||||
'Type',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] thead tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 6)
|
||||
const headers = [
|
||||
'Passed',
|
||||
'Not completed',
|
||||
'Major students',
|
||||
'Non-major students',
|
||||
'Non-degree students',
|
||||
'Transferred students',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
cy.get('td').eq(2).contains('23')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Degree courses works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Degree courses').click()
|
||||
})
|
||||
|
||||
it('content loads', () => {
|
||||
cy.contains('h3', 'Select curriculum to edit:')
|
||||
cy.get('[data-cy=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(() => {
|
||||
cy.contains('Muut opinnot')
|
||||
cy.contains('Matematiikka, perusopinnot')
|
||||
cy.contains('div.green.label', 'visible')
|
||||
cy.contains('button', 'Set hidden')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/* Tag-tests */
|
||||
describe('Tags view works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
cy.get('.attached').contains('Tags').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('can create personal tags', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2022')
|
||||
|
||||
cy.get('.ui > label').click()
|
||||
cy.contains('Create a new tag').click()
|
||||
cy.contains(name)
|
||||
deleteTag(name)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
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.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains(`Tagged with: ${name}`)
|
||||
cy.contains('Students (5)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (5)').click()
|
||||
})
|
||||
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.contains(studentNumber)
|
||||
}
|
||||
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags')
|
||||
cy.contains(name)
|
||||
}
|
||||
|
||||
cy.get('a').contains('Matemaattisten tieteiden kandiohjelma').invoke('removeAttr', 'target').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?p_tab=4')
|
||||
|
||||
deleteTag(name)
|
||||
})
|
||||
|
||||
it('deleting a tag from tag view also removes it from students', () => {
|
||||
cy.contains(name).should('not.exist')
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags').should('not.exist')
|
||||
cy.contains(name).should('not.exist')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('IAM user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme', 'onlyiamrights')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
})
|
||||
|
||||
it('can access programme and correct tabs are visible', () => {
|
||||
cy.contains('Basic information')
|
||||
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.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('can access study tracks', () => {
|
||||
cy.get('.attached').contains('Study tracks and class statistics').click()
|
||||
|
||||
cy.get('[data-cy=Section-studyTrackOverview]')
|
||||
cy.get('[data-cy=Section-studyTrackProgress]')
|
||||
cy.get('[data-cy=Section-averageGraduationTimesStudyTracks]')
|
||||
})
|
||||
|
||||
it("doesn't see other tabs", () => {
|
||||
cy.get('div.ui.tabular.menu a').should('have.length', 2)
|
||||
})
|
||||
})
|
||||
})
|
||||
+17
-17
@@ -1,26 +1,26 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const progressLevels = ['Bachelors', 'BachelorMasters', 'Masters', 'Doctoral']
|
||||
const progressLevels = ['bachelors', 'bachelor-masters', 'masters', 'doctoral']
|
||||
|
||||
const checkProgressBarCharts = () => {
|
||||
progressLevels.forEach(level => cy.get(`[data-cy=Faculty${level}ProgressBarChart]`))
|
||||
progressLevels.forEach(level => cy.cs(`faculty-${level}-progress-bar-chart-section`))
|
||||
}
|
||||
|
||||
const checkProgressTables = () => {
|
||||
progressLevels.forEach(level => cy.get(`[data-cy=Faculty${level}ProgressTable]`))
|
||||
progressLevels.forEach(level => cy.cs(`${level}-faculty-progress-table`))
|
||||
}
|
||||
|
||||
const graduationTimesLevels = ['bachelor', 'master', 'doctor'] // ? Bachelor + master seems to be missing in test data
|
||||
|
||||
const checkAverageGraduationTimesBreakdownBarCharts = () => {
|
||||
graduationTimesLevels.forEach(level => {
|
||||
cy.get(`[data-cy=${level}BreakdownBarChart]`)
|
||||
cy.cs(`${level}-breakdown-bar-chart`)
|
||||
})
|
||||
}
|
||||
|
||||
const checkAverageGraduationTimesMedianBarCharts = () => {
|
||||
graduationTimesLevels.forEach(level => {
|
||||
cy.get(`[data-cy=${level}MedianBarChart]`)
|
||||
cy.cs(`${level}-median-bar-chart`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,20 +40,20 @@ describe('University view', () => {
|
||||
})
|
||||
|
||||
it("'Graduated included / Graduated excluded' toggle works", () => {
|
||||
cy.get('[data-cy=GraduatedToggle]').click()
|
||||
cy.cs('graduated-toggle').click()
|
||||
checkProgressBarCharts()
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it("'All study rights / Special study rights excluded' toggle works", () => {
|
||||
cy.get('[data-cy=StudyRightToggle]').click()
|
||||
cy.cs('study-right-toggle').click()
|
||||
checkProgressBarCharts()
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it('years in the tables can be clicked to show faculty level breakdown', () => {
|
||||
cy.get('[data-cy=StudyRightToggle]').click()
|
||||
cy.get('[data-cy=FacultyBachelorsProgressTableShowButton3]').click()
|
||||
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')
|
||||
@@ -66,16 +66,16 @@ describe('University view', () => {
|
||||
})
|
||||
|
||||
it('info boxes contain correct information', () => {
|
||||
cy.get('[data-cy=FacultyProgressInfoBoxButton]').click()
|
||||
cy.get('[data-cy=FacultyProgressInfoBoxContent]').contains('Kuvaa tiedekuntaan kuuluvien')
|
||||
cy.get('[data-cy=FacultyBachelorMastersProgressInfoBoxButton]').click()
|
||||
cy.get('[data-cy=FacultyBachelorMastersProgressInfoBoxContent]').contains('The starting year is the')
|
||||
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('Faculty graduations tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.get('[data-cy=FacultyGraduationsTab]').click()
|
||||
cy.cs('faculty-graduations-tab').click()
|
||||
})
|
||||
|
||||
it('has all the correct median time bar charts', () => {
|
||||
@@ -83,13 +83,13 @@ describe('University view', () => {
|
||||
})
|
||||
|
||||
it("'Breakdown/Median times' toggle works", () => {
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
checkAverageGraduationTimesMedianBarCharts()
|
||||
})
|
||||
|
||||
it('info boxes contain correct information', () => {
|
||||
cy.get('[data-cy=AverageGraduationTimesInfoBoxButton]').click()
|
||||
cy.get('[data-cy=AverageGraduationTimesInfoBoxContent]').contains('Opiskelijoiden keskimääräiset valmistumisajat')
|
||||
cy.cs('average-graduation-times-info-box-button').click()
|
||||
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset valmistumisajat')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Progra
|
||||
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
|
||||
|
||||
const containsLinks = links => {
|
||||
cy.get('[data-cy=nav-bar]').within(() => {
|
||||
cy.cs('nav-bar').within(() => {
|
||||
for (const link of links) {
|
||||
cy.contains(link)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const containsLinks = links => {
|
||||
}
|
||||
|
||||
const userButtonWorks = (username, mocking = false) => {
|
||||
cy.get('[data-cy=nav-bar-user-button]').click()
|
||||
cy.cs('nav-bar-user-button').click()
|
||||
cy.contains(mocking ? `Mocking as ${username}` : `Logged in as ${username}`)
|
||||
cy.contains('Language')
|
||||
cy.contains('suomi')
|
||||
@@ -61,10 +61,10 @@ describe('Users tests', () => {
|
||||
userButtonWorks('admin')
|
||||
})
|
||||
|
||||
describe('can mock as other users', () => {
|
||||
describe('can mock as other users', { retries: 3 }, () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('user-edit-button-basic').click()
|
||||
cy.get('i.spy').click()
|
||||
cy.cs('user-page-button-basic').click()
|
||||
cy.cs('mock-button').click()
|
||||
})
|
||||
|
||||
it('user button shows mocked user', () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
+3
-17
@@ -46,7 +46,7 @@ services:
|
||||
<<: *defaults
|
||||
command: >
|
||||
sh -c 'redis-server --appendonly yes && redis-cli flushall'
|
||||
image: redis:6.2.16
|
||||
image: redis:7.2.6
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
+12
-19
@@ -49,9 +49,10 @@ services:
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./environments/ha:/opt/app-root/src/src/environment
|
||||
- ./services/backend/src:/opt/app-root/src/src
|
||||
- ./services/shared/:/opt/app-root/src/src/shared
|
||||
- backend-nodemod:/opt/app-root/backend/node_modules
|
||||
- ./environments/testu:/opt/app-root/backend/src/environment
|
||||
- ./services/backend/:/opt/app-root/backend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
|
||||
frontend:
|
||||
<<: *defaults
|
||||
@@ -64,9 +65,11 @@ 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
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
- ./services/frontend/:/opt/app-root/frontend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
@@ -105,12 +108,6 @@ services:
|
||||
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 +118,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 +143,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:
|
||||
@@ -182,6 +173,8 @@ services:
|
||||
- 12347:5432
|
||||
|
||||
volumes:
|
||||
backend-nodemod:
|
||||
frontend-nodemod:
|
||||
kone-db-data:
|
||||
redis-data:
|
||||
sis-db-data:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
services:
|
||||
frontend:
|
||||
volumes:
|
||||
- frontend-nodemod:/opt/app-root/src/node_modules
|
||||
- ./services/frontend/instrumented:/opt/app-root/src/src
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
- ./services/frontend/instrumented:/opt/app-root/frontend/src
|
||||
|
||||
+9
-23
@@ -44,9 +44,9 @@ services:
|
||||
profiles:
|
||||
- oodikone
|
||||
volumes:
|
||||
- backend-nodemod:/opt/app-root/src/node_modules
|
||||
- ./services/backend/:/opt/app-root/src
|
||||
- ./services/shared/:/opt/app-root/src/src/shared
|
||||
- backend-nodemod:/opt/app-root/backend/node_modules
|
||||
- ./services/backend/:/opt/app-root/backend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
|
||||
frontend:
|
||||
<<: *defaults
|
||||
@@ -59,9 +59,9 @@ services:
|
||||
profiles:
|
||||
- oodikone
|
||||
volumes:
|
||||
- frontend-nodemod:/opt/app-root/src/node_modules
|
||||
- ./services/frontend/:/opt/app-root/src
|
||||
- ./services/shared/:/opt/app-root/src/src/shared
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
- ./services/frontend/:/opt/app-root/frontend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
@@ -76,7 +76,7 @@ services:
|
||||
<<: *defaults
|
||||
command: 'redis-server --appendonly yes'
|
||||
container_name: redis
|
||||
image: redis:6.2.16
|
||||
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:
|
||||
|
||||
@@ -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`.
|
||||
@@ -44,6 +44,8 @@ A collection of useful tools, documentation and other resources.
|
||||
|
||||
- [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)
|
||||
|
||||
<!--
|
||||
|
||||
+2
-3
@@ -80,10 +80,10 @@ export default [
|
||||
'no-param-reassign': ['error', { props: false }],
|
||||
'no-promise-executor-return': 'error',
|
||||
'no-return-assign': 'error',
|
||||
'no-return-await': 'error',
|
||||
'object-shorthand': ['error', 'always'],
|
||||
'prefer-const': 'error',
|
||||
'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
|
||||
"no-restricted-imports": ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
|
||||
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
|
||||
'react/display-name': 'off', // TODO: Delete this override
|
||||
'react/function-component-definition': [
|
||||
@@ -160,12 +160,11 @@ export default [
|
||||
'no-await-in-loop': 'off',
|
||||
'no-param-reassign': 'off',
|
||||
'no-promise-executor-return': 'off',
|
||||
'no-return-await': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
'import/internal-regex': '^@/shared/',
|
||||
'import/internal-regex': '^@oodikone/shared/',
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: [
|
||||
|
||||
Generated
+1608
-891
File diff suppressed because it is too large
Load Diff
+22
-19
@@ -23,10 +23,13 @@
|
||||
"testupdater": "docker-compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker && SCHEDULE_IMMEDIATE=weekly EXIT_AFTER_IMMEDIATES=yes docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start",
|
||||
"flushredis": "docker exec -it redis redis-cli flushall",
|
||||
"prepare": "husky",
|
||||
"rapodiff": "docker exec -t oodikone_backend_1 /opt/app-root/src/node_modules/.bin/tsx /opt/app-root/src/src/rapodiff/rapodiff.js",
|
||||
"install:local": "npm i && cd services/frontend && npm i && cd ../backend && npm i && cd ../../updater/sis-updater-scheduler && npm i && cd ../sis-updater-worker && npm i && cd ../..",
|
||||
"rapodiff": "docker exec -t oodikone_backend_1 /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
|
||||
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
|
||||
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
|
||||
"install:docker": "docker exec frontend npm i && docker exec oodikone_backend_1 npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone_sis-updater-worker_1 npm i",
|
||||
"install:both": "npm run install:local && npm run install:docker"
|
||||
"ci:docker": "docker exec frontend npm ci && docker exec oodikone_backend_1 npm ci && docker exec sis-updater-scheduler npm ci && docker exec oodikone_sis-updater-worker_1 npm ci",
|
||||
"install:both": "npm run install:local && npm run install:docker",
|
||||
"ci:both": "npm run ci:local && npm run ci:docker"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -38,27 +41,27 @@
|
||||
},
|
||||
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
|
||||
"devDependencies": {
|
||||
"@cypress/code-coverage": "^3.13.9",
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@cypress/code-coverage": "^3.14.4",
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"cypress": "^13.16.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.7.0",
|
||||
"eslint-plugin-cypress": "^4.1.0",
|
||||
"cypress": "^14.4.1",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-import-resolver-typescript": "^4.4.3",
|
||||
"eslint-plugin-cypress": "^5.1.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"globals": "^15.13.0",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"globals": "^16.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.2.11",
|
||||
"lint-staged": "^16.1.0",
|
||||
"node-actionlint": "^1.2.2",
|
||||
"nyc": "^17.1.0",
|
||||
"prettier": "3.4.2",
|
||||
"stylelint": "^16.11.0",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"typescript-eslint": "^8.18.0"
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "^16.20.0",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"typescript-eslint": "^8.34.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -7,19 +7,10 @@
|
||||
"groupSlug": "all-minor-patch",
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"matchCurrentVersion": "!/^0/",
|
||||
"automerge": true,
|
||||
"matchPackageNames": ["*"]
|
||||
},
|
||||
{
|
||||
"matchDatasources": ["docker"],
|
||||
"matchPackageNames": ["node"],
|
||||
"versionCompatibility": "^(?<version>[^-]+)(?<compatibility>-.*)?$",
|
||||
"versioning": "node"
|
||||
},
|
||||
{
|
||||
"matchDatasources": ["docker"],
|
||||
"matchPackageNames": ["node"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"enabled": false
|
||||
}
|
||||
],
|
||||
|
||||
@@ -225,21 +225,6 @@ set_up_oodikone_from_scratch() {
|
||||
infomsg "Building images"
|
||||
"$PROJECT_ROOT"/run.sh both anon build
|
||||
|
||||
TARGET_PATH="$PROJECT_ROOT/services/shared"
|
||||
LINK_PATHS=(
|
||||
"$PROJECT_ROOT/services/backend/src/shared"
|
||||
"$PROJECT_ROOT/services/frontend/src/shared"
|
||||
)
|
||||
|
||||
for SYMLINK_PATH in "${LINK_PATHS[@]}"; do
|
||||
infomsg "Linking $TARGET_PATH to $SYMLINK_PATH"
|
||||
if [ ! -L "$SYMLINK_PATH" ]; then
|
||||
ln -s "$TARGET_PATH" "$SYMLINK_PATH"
|
||||
else
|
||||
infomsg "Symbolic link $SYMLINK_PATH already exists. Skipping creation."
|
||||
fi
|
||||
done
|
||||
|
||||
successmsg "Setup ready, Oodikone can be started! See README for more info."
|
||||
}
|
||||
|
||||
|
||||
+15
-13
@@ -2,8 +2,6 @@ FROM registry.access.redhat.com/ubi9/nodejs-22-minimal AS build
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
WORKDIR /opt/app-root/src
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||
|
||||
@@ -19,23 +17,22 @@ ENV SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
|
||||
ARG SENTRY_RELEASE
|
||||
ENV SENTRY_RELEASE=$SENTRY_RELEASE
|
||||
|
||||
COPY ./backend/package*.json ./
|
||||
RUN npm ci --include=dev
|
||||
COPY ./shared ./src/shared
|
||||
WORKDIR /opt/app-root/shared
|
||||
COPY ./shared .
|
||||
|
||||
WORKDIR /opt/app-root/backend
|
||||
COPY ./backend .
|
||||
|
||||
RUN npm run build
|
||||
WORKDIR /opt/app-root
|
||||
|
||||
RUN npm ci --include=dev --prefix shared && npm ci --include=dev --prefix backend
|
||||
RUN npm run build --prefix shared && npm run build --prefix backend
|
||||
|
||||
|
||||
FROM registry.access.redhat.com/ubi9/nodejs-22-minimal
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
WORKDIR /opt/app-root/src
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||
|
||||
@@ -51,9 +48,14 @@ ENV SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
|
||||
ARG SENTRY_RELEASE
|
||||
ENV SENTRY_RELEASE=$SENTRY_RELEASE
|
||||
|
||||
COPY --from=build /opt/app-root/src/package*.json ./
|
||||
COPY --from=build /opt/app-root/src/dist ./dist
|
||||
WORKDIR /opt/app-root/shared
|
||||
COPY --from=build /opt/app-root/shared/dist .
|
||||
|
||||
RUN npm ci
|
||||
WORKDIR /opt/app-root/backend
|
||||
COPY --from=build /opt/app-root/backend/package*.json .
|
||||
COPY --from=build /opt/app-root/backend/dist ./dist
|
||||
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
@@ -2,15 +2,12 @@ FROM node:22-alpine
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
WORKDIR /opt/app-root/src
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV NODE_ENV=development
|
||||
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||
|
||||
COPY ./package* ./
|
||||
RUN npm ci
|
||||
WORKDIR /opt/app-root/backend
|
||||
COPY . .
|
||||
RUN npm ci
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["node_modules/.bin/tsx", "watch", "--clear-screen=false", "index.js"]
|
||||
|
||||
Generated
+915
-1326
File diff suppressed because it is too large
Load Diff
@@ -5,45 +5,46 @@
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/node": "^8.45.0",
|
||||
"@sentry/profiling-node": "^8.45.0",
|
||||
"axios": "^0.29.0",
|
||||
"bullmq": "^5.34.2",
|
||||
"compression": "^1.7.5",
|
||||
"@oodikone/shared": "file:../shared",
|
||||
"@sentry/node": "^9.28.1",
|
||||
"@sentry/profiling-node": "^9.28.1",
|
||||
"axios": "^1.9.0",
|
||||
"bullmq": "^5.53.2",
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^3.3.1",
|
||||
"cron": "^4.3.1",
|
||||
"express": "^4.21.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.0.2",
|
||||
"lru-cache": "^11.1.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.46",
|
||||
"morgan": "^1.10.0",
|
||||
"papaparse": "^5.4.1",
|
||||
"pg": "^8.13.1",
|
||||
"redis": "^4.7.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.16.0",
|
||||
"redis": "^4.7.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize": "^6.37.7",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"umzug": "^3.8.2",
|
||||
"unfuck-utf8-headers-middleware": "^1.0.1",
|
||||
"winston": "^3.17.0",
|
||||
"winston-gelf-transporter": "^1.0.2"
|
||||
"winston-gelf-transporter": "^1.0.2",
|
||||
"winston-loki": "^6.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/validator": "^13.12.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "^22.15.31",
|
||||
"@types/validator": "^13.15.1",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"overrides": {
|
||||
"winston-gelf-transporter": {
|
||||
"gelf-pro": "^1.3.12"
|
||||
"gelf-pro": "^1.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,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'
|
||||
@@ -70,6 +71,7 @@ module.exports = {
|
||||
DB_URL_KONE,
|
||||
DB_MAX_CONNECTIONS,
|
||||
DB_MAX_CRON_CONNECTIONS,
|
||||
CRON_SCHEDULE,
|
||||
redis,
|
||||
SECRET_TOKEN,
|
||||
DB_SCHEMA_KONE,
|
||||
|
||||
@@ -4,36 +4,36 @@ import { SequelizeStorage, Umzug } from 'umzug'
|
||||
|
||||
import conf from '../config'
|
||||
import {
|
||||
Course,
|
||||
CourseProvider,
|
||||
CourseType,
|
||||
Credit,
|
||||
CreditTeacher,
|
||||
CreditType,
|
||||
CurriculumPeriod,
|
||||
Enrollment,
|
||||
Organization,
|
||||
ProgrammeModule,
|
||||
ProgrammeModuleChild,
|
||||
Semester,
|
||||
SISStudyRight,
|
||||
SISStudyRightElement,
|
||||
Student,
|
||||
Studyplan,
|
||||
StudyrightExtent,
|
||||
Teacher,
|
||||
CourseModel,
|
||||
CourseProviderModel,
|
||||
CourseTypeModel,
|
||||
CreditModel,
|
||||
CreditTeacherModel,
|
||||
CreditTypeModel,
|
||||
CurriculumPeriodModel,
|
||||
EnrollmentModel,
|
||||
OrganizationModel,
|
||||
ProgrammeModuleModel,
|
||||
ProgrammeModuleChildModel,
|
||||
SemesterModel,
|
||||
SISStudyRightModel,
|
||||
SISStudyRightElementModel,
|
||||
StudentModel,
|
||||
StudyplanModel,
|
||||
StudyrightExtentModel,
|
||||
TeacherModel,
|
||||
} from '../models'
|
||||
import {
|
||||
CustomPopulationSearch,
|
||||
ExcludedCourse,
|
||||
OpenUniPopulationSearch,
|
||||
ProgressCriteria,
|
||||
StudyGuidanceGroupTag,
|
||||
StudyProgrammePin,
|
||||
Tag,
|
||||
TagStudent,
|
||||
CustomPopulationSearchModel,
|
||||
ExcludedCourseModel,
|
||||
OpenUniPopulationSearchModel,
|
||||
ProgressCriteriaModel,
|
||||
StudyGuidanceGroupTagModel,
|
||||
StudyProgrammePinModel,
|
||||
TagModel,
|
||||
TagStudentModel,
|
||||
} from '../models/kone'
|
||||
import { User } from '../models/user'
|
||||
import { UserModel } from '../models/user'
|
||||
|
||||
import logger from '../util/logger'
|
||||
|
||||
@@ -60,24 +60,24 @@ class DbConnection extends EventEmitter {
|
||||
logging: false,
|
||||
password: conf.SIS_PASSWORD,
|
||||
models: [
|
||||
Course,
|
||||
CourseProvider,
|
||||
CourseType,
|
||||
Credit,
|
||||
CreditTeacher,
|
||||
CreditType,
|
||||
CurriculumPeriod,
|
||||
Enrollment,
|
||||
Organization,
|
||||
ProgrammeModule,
|
||||
ProgrammeModuleChild,
|
||||
Semester,
|
||||
SISStudyRight,
|
||||
SISStudyRightElement,
|
||||
Student,
|
||||
Studyplan,
|
||||
StudyrightExtent,
|
||||
Teacher,
|
||||
CourseModel,
|
||||
CourseProviderModel,
|
||||
CourseTypeModel,
|
||||
CreditModel,
|
||||
CreditTeacherModel,
|
||||
CreditTypeModel,
|
||||
CurriculumPeriodModel,
|
||||
EnrollmentModel,
|
||||
OrganizationModel,
|
||||
ProgrammeModuleModel,
|
||||
ProgrammeModuleChildModel,
|
||||
SemesterModel,
|
||||
SISStudyRightModel,
|
||||
SISStudyRightElementModel,
|
||||
StudentModel,
|
||||
StudyplanModel,
|
||||
StudyrightExtentModel,
|
||||
TeacherModel,
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -107,14 +107,14 @@ const sequelizeKone = new Sequelize(conf.DB_URL_KONE!, {
|
||||
logging: false,
|
||||
password: conf.KONE_PASSWORD,
|
||||
models: [
|
||||
CustomPopulationSearch,
|
||||
ExcludedCourse,
|
||||
OpenUniPopulationSearch,
|
||||
ProgressCriteria,
|
||||
StudyGuidanceGroupTag,
|
||||
StudyProgrammePin,
|
||||
Tag,
|
||||
TagStudent,
|
||||
CustomPopulationSearchModel,
|
||||
ExcludedCourseModel,
|
||||
OpenUniPopulationSearchModel,
|
||||
ProgressCriteriaModel,
|
||||
StudyGuidanceGroupTagModel,
|
||||
StudyProgrammePinModel,
|
||||
TagModel,
|
||||
TagStudentModel,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -123,7 +123,7 @@ void sequelizeKone.query(`SET SESSION search_path to ${conf.DB_SCHEMA_KONE}`)
|
||||
const sequelizeUser = new Sequelize(conf.DB_URL_USER!, {
|
||||
logging: false,
|
||||
password: conf.USER_PASSWORD,
|
||||
models: [User],
|
||||
models: [UserModel],
|
||||
})
|
||||
|
||||
const initializeDatabaseConnection = async () => {
|
||||
|
||||
@@ -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'
|
||||
@@ -65,7 +65,7 @@ const refreshProgrammesAndFaculties = async () => {
|
||||
export const refreshProgramme = async (code: string) => {
|
||||
await updateBasicView(code, '')
|
||||
await updateStudyTrackView(code, '')
|
||||
const combinedProgramme = combinedStudyProgrammes[code] || ''
|
||||
const combinedProgramme = combinedStudyProgrammes[code] ?? ''
|
||||
await updateBasicView(code, combinedProgramme)
|
||||
await updateStudyTrackView(code, combinedProgramme)
|
||||
}
|
||||
@@ -105,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()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { Role } from '../shared/types'
|
||||
import { Role } from '@oodikone/shared/types'
|
||||
|
||||
export const roles =
|
||||
(requiredRoles: Role[], requiredIamGroups?: string[]) => (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
@@ -12,29 +11,45 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { ExtentCode, SemesterEnrollment } from '../types'
|
||||
import { Credit, Enrollment, Organization, SISStudyRightElement, Student, Studyplan, StudyrightExtent } from '.'
|
||||
import type {
|
||||
Credit,
|
||||
Enrollment,
|
||||
Organization,
|
||||
SISStudyRight,
|
||||
SISStudyRightElement,
|
||||
Student,
|
||||
Studyplan,
|
||||
} from '@oodikone/shared/models'
|
||||
import { ExtentCode, SemesterEnrollment } from '@oodikone/shared/types'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { EnrollmentModel } from './enrollment'
|
||||
import { OrganizationModel } from './organization'
|
||||
import { SISStudyRightElementModel } from './SISStudyRightElement'
|
||||
import { StudentModel } from './student'
|
||||
import { StudyplanModel } from './studyplan'
|
||||
import { StudyrightExtentModel } from './studyrightExtent'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
tableName: 'sis_study_rights',
|
||||
})
|
||||
export class SISStudyRight extends Model<InferAttributes<SISStudyRight>> {
|
||||
export class SISStudyRightModel extends Model<SISStudyRight> implements SISStudyRight {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
|
||||
@ForeignKey(() => Organization)
|
||||
@ForeignKey(() => OrganizationModel)
|
||||
@Column(DataType.STRING)
|
||||
facultyCode!: string
|
||||
|
||||
@BelongsTo(() => Organization, { foreignKey: 'facultyCode', targetKey: 'code' })
|
||||
@BelongsTo(() => OrganizationModel, { foreignKey: 'facultyCode', targetKey: 'code' })
|
||||
organization!: Organization
|
||||
|
||||
@HasMany(() => Studyplan)
|
||||
@HasMany(() => StudyplanModel)
|
||||
studyPlans!: Studyplan[]
|
||||
|
||||
@HasMany(() => SISStudyRightElement, { foreignKey: 'studyRightId' })
|
||||
@HasMany(() => SISStudyRightElementModel, { foreignKey: 'studyRightId' })
|
||||
studyRightElements!: SISStudyRightElement[]
|
||||
|
||||
@Column(DataType.DATE)
|
||||
@@ -52,10 +67,10 @@ export class SISStudyRight extends Model<InferAttributes<SISStudyRight>> {
|
||||
@Column(DataType.STRING)
|
||||
studentNumber!: string
|
||||
|
||||
@BelongsTo(() => Student, { foreignKey: 'studentNumber', targetKey: 'studentnumber' })
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'studentNumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
|
||||
@ForeignKey(() => StudyrightExtent)
|
||||
@ForeignKey(() => StudyrightExtentModel)
|
||||
@Column(DataType.INTEGER)
|
||||
extentCode!: ExtentCode
|
||||
|
||||
@@ -65,12 +80,18 @@ export class SISStudyRight extends Model<InferAttributes<SISStudyRight>> {
|
||||
@Column(DataType.JSONB)
|
||||
semesterEnrollments!: SemesterEnrollment[] | null
|
||||
|
||||
@HasMany(() => Credit, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
credits!: Credit[]
|
||||
|
||||
@HasMany(() => Enrollment, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
enrollments!: Enrollment[]
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
tvex!: boolean
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
expirationRuleUrns!: string[]
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
@@ -11,15 +10,17 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { DegreeProgrammeType, Phase, StudyTrack } from '../types'
|
||||
import { SISStudyRight } from './SISStudyRight'
|
||||
import type { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import type { Name, StudyTrack } from '@oodikone/shared/types'
|
||||
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
|
||||
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
tableName: 'sis_study_right_elements',
|
||||
})
|
||||
export class SISStudyRightElement extends Model<InferAttributes<SISStudyRightElement>> {
|
||||
export class SISStudyRightElementModel extends Model<SISStudyRightElement> implements SISStudyRightElement {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
@@ -36,11 +37,11 @@ export class SISStudyRightElement extends Model<InferAttributes<SISStudyRightEle
|
||||
@Column(DataType.INTEGER)
|
||||
phase!: Phase
|
||||
|
||||
@ForeignKey(() => SISStudyRight)
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column(DataType.STRING)
|
||||
studyRightId!: string
|
||||
|
||||
@BelongsTo(() => SISStudyRight, { foreignKey: 'studyRightId' })
|
||||
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyRightId' })
|
||||
studyRight!: SISStudyRight
|
||||
|
||||
@Column(DataType.STRING)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
BelongsToMany,
|
||||
@@ -13,41 +12,43 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { CourseProvider } from './courseProvider'
|
||||
import { CourseType } from './courseType'
|
||||
import { Credit } from './credit'
|
||||
import { Enrollment } from './enrollment'
|
||||
import { Organization } from './organization'
|
||||
import type { Course, CourseType, Credit, Enrollment, Organization } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
|
||||
import { CourseProviderModel } from './courseProvider'
|
||||
import { CourseTypeModel } from './courseType'
|
||||
import { CreditModel } from './credit'
|
||||
import { EnrollmentModel } from './enrollment'
|
||||
import { OrganizationModel } from './organization'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'course',
|
||||
tableName: 'course',
|
||||
})
|
||||
export class Course extends Model<InferAttributes<Course>> {
|
||||
export class CourseModel extends Model<Course> implements Course {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
|
||||
@ForeignKey(() => Course)
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
|
||||
@ForeignKey(() => CourseType)
|
||||
@ForeignKey(() => CourseTypeModel)
|
||||
@Column(DataType.STRING)
|
||||
coursetypecode!: string
|
||||
|
||||
@BelongsTo(() => CourseType, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
|
||||
@BelongsTo(() => CourseTypeModel, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
|
||||
courseType!: CourseType
|
||||
|
||||
@HasMany(() => Credit, { foreignKey: 'course_id', sourceKey: 'id' })
|
||||
@HasMany(() => CreditModel, { foreignKey: 'course_id', sourceKey: 'id' })
|
||||
credits!: Credit[]
|
||||
|
||||
@HasMany(() => Enrollment, { foreignKey: 'course_id' })
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'course_id' })
|
||||
enrollments!: Enrollment[]
|
||||
|
||||
@BelongsToMany(() => Organization, () => CourseProvider, 'coursecode')
|
||||
@BelongsToMany(() => OrganizationModel, () => CourseProviderModel, 'coursecode')
|
||||
organizations!: Organization[]
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
@@ -63,17 +64,11 @@ export class Course extends Model<InferAttributes<Course>> {
|
||||
min_attainment_date!: Date
|
||||
|
||||
@CreatedAt
|
||||
@Column({
|
||||
field: 'created_at',
|
||||
type: DataType.DATE,
|
||||
})
|
||||
@Column({ field: 'created_at', type: DataType.DATE })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdatedAt
|
||||
@Column({
|
||||
field: 'updated_at',
|
||||
type: DataType.DATE,
|
||||
})
|
||||
@Column({ field: 'updated_at', type: DataType.DATE })
|
||||
updatedAt!: Date
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
@@ -82,9 +77,6 @@ export class Course extends Model<InferAttributes<Course>> {
|
||||
@Column(DataType.STRING)
|
||||
course_unit_type!: string
|
||||
|
||||
@Column({
|
||||
field: 'main_course_code',
|
||||
type: DataType.STRING,
|
||||
})
|
||||
@Column({ field: 'main_course_code', type: DataType.STRING })
|
||||
mainCourseCode!: string
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Course } from './course'
|
||||
import { Organization } from './organization'
|
||||
import type { CourseProvider } from '@oodikone/shared/models'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
import { OrganizationModel } from './organization'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'course_provider',
|
||||
tableName: 'course_providers',
|
||||
})
|
||||
export class CourseProvider extends Model<InferAttributes<CourseProvider>> {
|
||||
@ForeignKey(() => Course)
|
||||
export class CourseProviderModel extends Model<CourseProvider> implements CourseProvider {
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
coursecode!: string
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
shares!: Array<{ share: number; startDate?: string; endDate?: string }> | null
|
||||
|
||||
@ForeignKey(() => Organization)
|
||||
@ForeignKey(() => OrganizationModel)
|
||||
@Column(DataType.STRING)
|
||||
organizationcode!: string
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { Course } from './course'
|
||||
import type { Course, CourseType } from '@oodikone/shared/models'
|
||||
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { CourseModel } from './course'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'course_type',
|
||||
tableName: 'course_types',
|
||||
})
|
||||
export class CourseType extends Model<InferAttributes<CourseType>> {
|
||||
export class CourseTypeModel extends Model<CourseType> implements CourseType {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
coursetypecode!: string
|
||||
|
||||
@HasMany(() => Course)
|
||||
@HasMany(() => CourseModel)
|
||||
courses!: Course[]
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
BelongsToMany,
|
||||
@@ -12,15 +11,23 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { CreditTypeCode } from '../types'
|
||||
import { Course, CreditTeacher, CreditType, Semester, SISStudyRight, Student, Teacher } from '.'
|
||||
import type { Credit, Course, CreditType, Semester, SISStudyRight, Student, Teacher } from '@oodikone/shared/models'
|
||||
import { CreditTypeCode } from '@oodikone/shared/types'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
import { CreditTeacherModel } from './creditTeacher'
|
||||
import { CreditTypeModel } from './creditType'
|
||||
import { SemesterModel } from './semester'
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
import { StudentModel } from './student'
|
||||
import { TeacherModel } from './teacher'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'credit',
|
||||
tableName: 'credit',
|
||||
})
|
||||
export class Credit extends Model<InferAttributes<Credit>> {
|
||||
export class CreditModel extends Model<Credit> implements Credit {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
@@ -28,24 +35,24 @@ export class Credit extends Model<InferAttributes<Credit>> {
|
||||
@Column(DataType.STRING)
|
||||
grade!: string
|
||||
|
||||
@ForeignKey(() => Student)
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
student_studentnumber!: string
|
||||
|
||||
@BelongsTo(() => Student, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
|
||||
@BelongsTo(() => Semester, { foreignKey: 'semester_composite' })
|
||||
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite' })
|
||||
semester!: Semester
|
||||
|
||||
@Column(DataType.DOUBLE)
|
||||
credits!: number
|
||||
|
||||
@ForeignKey(() => CreditType)
|
||||
@ForeignKey(() => CreditTypeModel)
|
||||
@Column(DataType.INTEGER)
|
||||
credittypecode!: CreditTypeCode
|
||||
|
||||
@BelongsTo(() => CreditType, { foreignKey: 'credittypecode', targetKey: 'credittypecode' })
|
||||
@BelongsTo(() => CreditTypeModel, { foreignKey: 'credittypecode', targetKey: 'credittypecode' })
|
||||
creditType!: CreditType
|
||||
|
||||
@Column(DataType.DATE)
|
||||
@@ -54,16 +61,16 @@ export class Credit extends Model<InferAttributes<Credit>> {
|
||||
@Column(DataType.DATE)
|
||||
attainment_date!: Date
|
||||
|
||||
@BelongsToMany(() => Teacher, () => CreditTeacher, 'credit_id')
|
||||
@BelongsToMany(() => TeacherModel, () => CreditTeacherModel, 'credit_id')
|
||||
teachers!: Teacher[]
|
||||
|
||||
@Column(DataType.STRING)
|
||||
course_code!: string
|
||||
|
||||
@BelongsTo(() => Course, { foreignKey: 'course_id' })
|
||||
@BelongsTo(() => CourseModel, { foreignKey: 'course_id' })
|
||||
course!: Course
|
||||
|
||||
@ForeignKey(() => Course)
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
course_id!: string
|
||||
|
||||
@@ -85,11 +92,11 @@ export class Credit extends Model<InferAttributes<Credit>> {
|
||||
@Column(DataType.BOOLEAN)
|
||||
is_open!: boolean
|
||||
|
||||
@ForeignKey(() => SISStudyRight)
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column(DataType.STRING)
|
||||
studyright_id!: string
|
||||
|
||||
@BelongsTo(() => SISStudyRight)
|
||||
@BelongsTo(() => SISStudyRightModel)
|
||||
studyright!: SISStudyRight
|
||||
|
||||
@CreatedAt
|
||||
@@ -108,3 +115,8 @@ export class Credit extends Model<InferAttributes<Credit>> {
|
||||
|
||||
static notUnnecessary: ({ credits }: { credits: number }) => boolean
|
||||
}
|
||||
|
||||
CreditModel.passed = ({ credittypecode }) => [CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(credittypecode)
|
||||
CreditModel.failed = ({ credittypecode }) => credittypecode === CreditTypeCode.FAILED
|
||||
CreditModel.improved = ({ credittypecode }) => credittypecode === CreditTypeCode.IMPROVED
|
||||
CreditModel.notUnnecessary = ({ credits }) => credits > 0 && credits <= 12
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Credit } from './credit'
|
||||
import { Teacher } from './teacher'
|
||||
import type { CreditTeacher } from '@oodikone/shared/models'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { TeacherModel } from './teacher'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'credit_teacher',
|
||||
tableName: 'credit_teachers',
|
||||
})
|
||||
export class CreditTeacher extends Model<InferAttributes<CreditTeacher>> {
|
||||
@ForeignKey(() => Credit)
|
||||
export class CreditTeacherModel extends Model<CreditTeacher> implements CreditTeacher {
|
||||
@ForeignKey(() => CreditModel)
|
||||
@Column(DataType.STRING)
|
||||
credit_id!: string
|
||||
|
||||
@ForeignKey(() => Teacher)
|
||||
@ForeignKey(() => TeacherModel)
|
||||
@Column(DataType.STRING)
|
||||
teacher_id!: string
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { CreditTypeCode } from '../types'
|
||||
import type { CreditType } from '@oodikone/shared/models'
|
||||
import { CreditTypeCode, Name } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'credit_type',
|
||||
tableName: 'credit_types',
|
||||
})
|
||||
export class CreditType extends Model<InferAttributes<CreditType>> {
|
||||
export class CreditTypeModel extends Model<CreditType> implements CreditType {
|
||||
@PrimaryKey
|
||||
@Column(DataType.INTEGER)
|
||||
credittypecode!: CreditTypeCode
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import type { CurriculumPeriod } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'curriculum_period',
|
||||
tableName: 'curriculum_periods',
|
||||
})
|
||||
export class CurriculumPeriod extends Model<InferAttributes<CurriculumPeriod>> {
|
||||
export class CurriculumPeriodModel extends Model<CurriculumPeriod> implements CurriculumPeriod {
|
||||
@Column({ type: DataType.STRING, primaryKey: true })
|
||||
id!: string
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
@@ -11,24 +10,29 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { EnrollmentState } from '../types'
|
||||
import { Course, Semester, SISStudyRight, Student } from '.'
|
||||
import type { Enrollment, Semester, SISStudyRight, Student } from '@oodikone/shared/models'
|
||||
import { EnrollmentState } from '@oodikone/shared/types'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
import { SemesterModel } from './semester'
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
import { StudentModel } from './student'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'enrollment',
|
||||
tableName: 'enrollment',
|
||||
})
|
||||
export class Enrollment extends Model<InferAttributes<Enrollment>> {
|
||||
export class EnrollmentModel extends Model<Enrollment> implements Enrollment {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
|
||||
@ForeignKey(() => Student)
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
|
||||
@BelongsTo(() => Student, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
|
||||
@Column(DataType.STRING)
|
||||
@@ -40,15 +44,15 @@ export class Enrollment extends Model<InferAttributes<Enrollment>> {
|
||||
@Column(DataType.DATE)
|
||||
enrollment_date_time!: Date
|
||||
|
||||
@ForeignKey(() => Course)
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
course_id!: string
|
||||
|
||||
@ForeignKey(() => Semester)
|
||||
@ForeignKey(() => SemesterModel)
|
||||
@Column(DataType.STRING)
|
||||
semester_composite!: string
|
||||
|
||||
@BelongsTo(() => Semester, { foreignKey: 'semester_composite', targetKey: 'composite' })
|
||||
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite', targetKey: 'composite' })
|
||||
semester!: Semester
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
@@ -57,15 +61,15 @@ export class Enrollment extends Model<InferAttributes<Enrollment>> {
|
||||
@Column(DataType.BOOLEAN)
|
||||
is_open!: boolean
|
||||
|
||||
@ForeignKey(() => SISStudyRight)
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column({ type: DataType.STRING, allowNull: true })
|
||||
studyright_id!: string
|
||||
|
||||
@BelongsTo(() => SISStudyRight, { foreignKey: 'studyright_id', targetKey: 'id', constraints: false })
|
||||
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyright_id', targetKey: 'id', constraints: false })
|
||||
studyright!: SISStudyRight
|
||||
|
||||
@BelongsTo(() => Course, { foreignKey: 'course_id', targetKey: 'id' })
|
||||
course!: Course
|
||||
@BelongsTo(() => CourseModel, { foreignKey: 'course_id', targetKey: 'id' })
|
||||
course!: CourseModel
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
|
||||
@@ -1,45 +1,18 @@
|
||||
import { CreditTypeCode } from '../types'
|
||||
import { Course } from './course'
|
||||
import { CourseProvider } from './courseProvider'
|
||||
import { CourseType } from './courseType'
|
||||
import { Credit } from './credit'
|
||||
import { CreditTeacher } from './creditTeacher'
|
||||
import { CreditType } from './creditType'
|
||||
import { CurriculumPeriod } from './curriculumPeriod'
|
||||
import { Enrollment } from './enrollment'
|
||||
import { Organization } from './organization'
|
||||
import { ProgrammeModule } from './programmeModule'
|
||||
import { ProgrammeModuleChild } from './programmeModuleChild'
|
||||
import { Semester } from './semester'
|
||||
import { SISStudyRight } from './SISStudyRight'
|
||||
import { SISStudyRightElement } from './SISStudyRightElement'
|
||||
import { Student } from './student'
|
||||
import { Studyplan } from './studyplan'
|
||||
import { StudyrightExtent } from './studyrightExtent'
|
||||
import { Teacher } from './teacher'
|
||||
|
||||
Credit.passed = ({ credittypecode }) => [CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(credittypecode)
|
||||
Credit.failed = ({ credittypecode }) => credittypecode === CreditTypeCode.FAILED
|
||||
Credit.improved = ({ credittypecode }) => credittypecode === CreditTypeCode.IMPROVED
|
||||
Credit.notUnnecessary = ({ credits }) => credits > 0 && credits <= 12
|
||||
|
||||
export {
|
||||
Course,
|
||||
CourseProvider,
|
||||
CourseType,
|
||||
Credit,
|
||||
CreditTeacher,
|
||||
CreditType,
|
||||
CurriculumPeriod,
|
||||
Enrollment,
|
||||
Organization,
|
||||
ProgrammeModule,
|
||||
ProgrammeModuleChild,
|
||||
Semester,
|
||||
SISStudyRight,
|
||||
SISStudyRightElement,
|
||||
Student,
|
||||
Studyplan,
|
||||
StudyrightExtent,
|
||||
Teacher,
|
||||
}
|
||||
export { CourseModel } from './course'
|
||||
export { CourseProviderModel } from './courseProvider'
|
||||
export { CourseTypeModel } from './courseType'
|
||||
export { CreditModel } from './credit'
|
||||
export { CreditTeacherModel } from './creditTeacher'
|
||||
export { CreditTypeModel } from './creditType'
|
||||
export { CurriculumPeriodModel } from './curriculumPeriod'
|
||||
export { EnrollmentModel } from './enrollment'
|
||||
export { OrganizationModel } from './organization'
|
||||
export { ProgrammeModuleModel } from './programmeModule'
|
||||
export { ProgrammeModuleChildModel } from './programmeModuleChild'
|
||||
export { SemesterModel } from './semester'
|
||||
export { SISStudyRightModel } from './SISStudyRight'
|
||||
export { SISStudyRightElementModel } from './SISStudyRightElement'
|
||||
export { StudentModel } from './student'
|
||||
export { StudyplanModel } from './studyplan'
|
||||
export { StudyrightExtentModel } from './studyrightExtent'
|
||||
export { TeacherModel } from './teacher'
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { CustomPopulationSearch, CustomPopulationSearchCreation } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'custom_population_search',
|
||||
tableName: 'custom_population_searches',
|
||||
})
|
||||
export class CustomPopulationSearch extends Model<
|
||||
InferAttributes<CustomPopulationSearch>,
|
||||
InferCreationAttributes<CustomPopulationSearch>
|
||||
> {
|
||||
export class CustomPopulationSearchModel
|
||||
extends Model<CustomPopulationSearch, CustomPopulationSearchCreation>
|
||||
implements CustomPopulationSearch
|
||||
{
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { Column, DataType, Model, PrimaryKey, Table, AutoIncrement, Default } from 'sequelize-typescript'
|
||||
|
||||
import type { ExcludedCourse, ExcludedCourseCreation } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'excluded_course',
|
||||
tableName: 'excluded_courses',
|
||||
})
|
||||
export class ExcludedCourse extends Model<InferAttributes<ExcludedCourse>, InferCreationAttributes<ExcludedCourse>> {
|
||||
export class ExcludedCourseModel extends Model<ExcludedCourse, ExcludedCourseCreation> implements ExcludedCourse {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.INTEGER)
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import { CustomPopulationSearch } from './customPopulationSearch'
|
||||
import { ExcludedCourse } from './excludedCourse'
|
||||
import { OpenUniPopulationSearch } from './openUniPopulationSearch'
|
||||
import { ProgressCriteria } from './progressCriteria'
|
||||
import { StudyGuidanceGroupTag } from './studyGuidanceGroupTag'
|
||||
import { StudyProgrammePin } from './studyProgrammePin'
|
||||
import { Tag } from './tag'
|
||||
import { TagStudent } from './tagStudent'
|
||||
|
||||
export {
|
||||
CustomPopulationSearch,
|
||||
ExcludedCourse,
|
||||
OpenUniPopulationSearch,
|
||||
ProgressCriteria,
|
||||
StudyGuidanceGroupTag,
|
||||
StudyProgrammePin,
|
||||
Tag,
|
||||
TagStudent,
|
||||
}
|
||||
export { CustomPopulationSearchModel } from './customPopulationSearch'
|
||||
export { ExcludedCourseModel } from './excludedCourse'
|
||||
export { OpenUniPopulationSearchModel } from './openUniPopulationSearch'
|
||||
export { ProgressCriteriaModel } from './progressCriteria'
|
||||
export { StudyGuidanceGroupTagModel } from './studyGuidanceGroupTag'
|
||||
export { StudyProgrammePinModel } from './studyProgrammePin'
|
||||
export { TagModel } from './tag'
|
||||
export { TagStudentModel } from './tagStudent'
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { OpenUniPopulationSearch, OpenUniPopulationSearchCreation } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
modelName: 'open_uni_population_search',
|
||||
tableName: 'open_uni_population_searches',
|
||||
})
|
||||
export class OpenUniPopulationSearch extends Model<
|
||||
InferAttributes<OpenUniPopulationSearch>,
|
||||
InferCreationAttributes<OpenUniPopulationSearch>
|
||||
> {
|
||||
export class OpenUniPopulationSearchModel
|
||||
extends Model<OpenUniPopulationSearch, OpenUniPopulationSearchCreation>
|
||||
implements OpenUniPopulationSearch
|
||||
{
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { ProgressCriteria, ProgressCriteriaCreation } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
modelName: 'progress_criteria',
|
||||
tableName: 'progress_criteria',
|
||||
})
|
||||
export class ProgressCriteria extends Model<
|
||||
InferAttributes<ProgressCriteria>,
|
||||
InferCreationAttributes<ProgressCriteria>
|
||||
> {
|
||||
export class ProgressCriteriaModel
|
||||
extends Model<ProgressCriteria, ProgressCriteriaCreation>
|
||||
implements ProgressCriteria
|
||||
{
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { CreationOptional, InferAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
|
||||
|
||||
import type { StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
modelName: 'study_guidance_group_tag',
|
||||
tableName: 'study_guidance_group_tags',
|
||||
})
|
||||
export class StudyGuidanceGroupTag extends Model<InferAttributes<StudyGuidanceGroupTag>> {
|
||||
export class StudyGuidanceGroupTagModel
|
||||
extends Model<StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation>
|
||||
implements StudyGuidanceGroupTag
|
||||
{
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { StudyProgrammePin } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
modelName: 'study_programme_pin',
|
||||
tableName: 'study_programme_pins',
|
||||
})
|
||||
export class StudyProgrammePin extends Model<InferAttributes<StudyProgrammePin>> {
|
||||
export class StudyProgrammePinModel extends Model<StudyProgrammePin> implements StudyProgrammePin {
|
||||
@PrimaryKey
|
||||
@Column(DataType.INTEGER)
|
||||
userId!: number
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import { TagStudent } from './tagStudent'
|
||||
import type { Tag, TagCreation, TagStudent } from '@oodikone/shared/models/kone'
|
||||
|
||||
import { TagStudentModel } from './tagStudent'
|
||||
|
||||
@Table({
|
||||
modelName: 'tag',
|
||||
tableName: 'tag',
|
||||
})
|
||||
export class Tag extends Model<InferAttributes<Tag>, InferCreationAttributes<Tag>> {
|
||||
export class TagModel extends Model<Tag, TagCreation> implements Tag {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@ForeignKey(() => TagStudent)
|
||||
@ForeignKey(() => TagStudentModel)
|
||||
@Column(DataType.BIGINT)
|
||||
tag_id!: CreationOptional<string>
|
||||
|
||||
@BelongsTo(() => TagStudent)
|
||||
@BelongsTo(() => TagStudentModel)
|
||||
tagStudent!: CreationOptional<TagStudent>
|
||||
|
||||
@Column(DataType.STRING)
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import { Student } from '../student'
|
||||
import { Tag } from './tag'
|
||||
import type { Tag, TagStudent, TagStudentCreation } from '@oodikone/shared/models/kone'
|
||||
|
||||
import { StudentModel } from '../student'
|
||||
import { TagModel } from './tag'
|
||||
|
||||
@Table({
|
||||
modelName: 'tag_student',
|
||||
tableName: 'tag_student',
|
||||
})
|
||||
export class TagStudent extends Model<InferAttributes<TagStudent>, InferCreationAttributes<TagStudent>> {
|
||||
export class TagStudentModel extends Model<TagStudent, TagStudentCreation> implements TagStudent {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@ForeignKey(() => Tag)
|
||||
@ForeignKey(() => TagModel)
|
||||
@Column(DataType.BIGINT)
|
||||
tag_id!: string
|
||||
|
||||
@PrimaryKey
|
||||
@ForeignKey(() => Student)
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
|
||||
@BelongsTo(() => Tag)
|
||||
@BelongsTo(() => TagModel)
|
||||
tag!: CreationOptional<Tag>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsToMany,
|
||||
Column,
|
||||
@@ -11,15 +10,20 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { Course, CourseProvider, ProgrammeModule, SISStudyRight } from '.'
|
||||
import type { Organization, ProgrammeModule, SISStudyRight } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
import { CourseProviderModel } from './courseProvider'
|
||||
import { ProgrammeModuleModel } from './programmeModule'
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'organization',
|
||||
tableName: 'organization',
|
||||
})
|
||||
export class Organization extends Model<InferAttributes<Organization>> {
|
||||
export class OrganizationModel extends Model<Organization> implements Organization {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
@@ -33,17 +37,17 @@ export class Organization extends Model<InferAttributes<Organization>> {
|
||||
@Column(DataType.STRING)
|
||||
parent_id!: string
|
||||
|
||||
@HasMany(() => Organization, { foreignKey: 'parent_id', as: 'children' })
|
||||
@HasMany(() => OrganizationModel, { foreignKey: 'parent_id', as: 'children' })
|
||||
children!: Organization[]
|
||||
|
||||
@HasMany(() => ProgrammeModule, { foreignKey: 'organization_id' })
|
||||
@HasMany(() => ProgrammeModuleModel, { foreignKey: 'organization_id' })
|
||||
programmeModules!: ProgrammeModule[]
|
||||
|
||||
@HasMany(() => SISStudyRight, { foreignKey: 'facultyCode', sourceKey: 'code' })
|
||||
@HasMany(() => SISStudyRightModel, { foreignKey: 'facultyCode', sourceKey: 'code' })
|
||||
SISStudyRights!: SISStudyRight[]
|
||||
|
||||
@BelongsToMany(() => Course, () => CourseProvider, 'organizationcode')
|
||||
courses!: Course[]
|
||||
@BelongsToMany(() => CourseModel, () => CourseProviderModel, 'organizationcode')
|
||||
courses!: CourseModel[]
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
BelongsToMany,
|
||||
@@ -12,25 +11,26 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { DegreeProgrammeType } from '../types'
|
||||
import { Organization } from './organization'
|
||||
import { ProgrammeModuleChild } from './programmeModuleChild'
|
||||
import type { Organization, ProgrammeModule } from '@oodikone/shared/models'
|
||||
import { DegreeProgrammeType, Name } from '@oodikone/shared/types'
|
||||
|
||||
import { OrganizationModel } from './organization'
|
||||
import { ProgrammeModuleChildModel } from './programmeModuleChild'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'programme_module',
|
||||
tableName: 'programme_modules',
|
||||
})
|
||||
export class ProgrammeModule extends Model<InferAttributes<ProgrammeModule>> {
|
||||
export class ProgrammeModuleModel extends Model<ProgrammeModule> implements ProgrammeModule {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
|
||||
@BelongsToMany(() => ProgrammeModule, () => ProgrammeModuleChild, 'child_id')
|
||||
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'child_id')
|
||||
parents!: ProgrammeModule[]
|
||||
|
||||
@BelongsToMany(() => ProgrammeModule, () => ProgrammeModuleChild, 'parent_id')
|
||||
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'parent_id')
|
||||
children!: ProgrammeModule[]
|
||||
|
||||
@Column(DataType.STRING)
|
||||
@@ -51,11 +51,11 @@ export class ProgrammeModule extends Model<InferAttributes<ProgrammeModule>> {
|
||||
@Column(DataType.STRING)
|
||||
studyLevel!: string
|
||||
|
||||
@ForeignKey(() => Organization)
|
||||
@ForeignKey(() => OrganizationModel)
|
||||
@Column(DataType.STRING)
|
||||
organization_id!: string
|
||||
|
||||
@BelongsTo(() => Organization, { foreignKey: 'organization_id' })
|
||||
@BelongsTo(() => OrganizationModel, { foreignKey: 'organization_id' })
|
||||
organization!: Organization
|
||||
|
||||
@Column(DataType.DATE)
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, ForeignKey, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { ProgrammeModule } from './programmeModule'
|
||||
import type { ProgrammeModuleChild } from '@oodikone/shared/models'
|
||||
|
||||
import { ProgrammeModuleModel } from './programmeModule'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'programme_module_child',
|
||||
tableName: 'programme_module_children',
|
||||
})
|
||||
export class ProgrammeModuleChild extends Model<InferAttributes<ProgrammeModuleChild>> {
|
||||
export class ProgrammeModuleChildModel extends Model<ProgrammeModuleChild> implements ProgrammeModuleChild {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
composite!: string
|
||||
|
||||
@ForeignKey(() => ProgrammeModule)
|
||||
@ForeignKey(() => ProgrammeModuleModel)
|
||||
@Column(DataType.STRING)
|
||||
parentId!: string
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import type { Semester } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'semester',
|
||||
tableName: 'semesters',
|
||||
})
|
||||
export class Semester extends Model<InferAttributes<Semester>> {
|
||||
export class SemesterModel extends Model<Semester> implements Semester {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
composite!: string
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
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 '.'
|
||||
import type { Student, Credit, Enrollment, SISStudyRight, Studyplan } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
import { GenderCode } from '@oodikone/shared/types'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { EnrollmentModel } from './enrollment'
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
import { StudyplanModel } from './studyplan'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'student',
|
||||
tableName: 'student',
|
||||
})
|
||||
export class Student extends Model<InferAttributes<Student>> {
|
||||
export class StudentModel extends Model<Student> implements Student {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
@@ -24,16 +28,16 @@ export class Student extends Model<InferAttributes<Student>> {
|
||||
@Column(DataType.STRING)
|
||||
abbreviatedname!: string
|
||||
|
||||
@HasMany(() => Enrollment, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
enrollments!: Enrollment[]
|
||||
|
||||
@HasMany(() => SISStudyRight, { foreignKey: 'studentNumber', sourceKey: 'studentnumber' })
|
||||
@HasMany(() => SISStudyRightModel, { foreignKey: 'studentNumber', sourceKey: 'studentnumber' })
|
||||
studyRights!: SISStudyRight[]
|
||||
|
||||
@HasMany(() => Studyplan, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
@HasMany(() => StudyplanModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
studyplans!: Studyplan[]
|
||||
|
||||
@HasMany(() => Credit, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
|
||||
@HasMany(() => CreditModel, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
|
||||
credits!: Credit[]
|
||||
|
||||
@Column(DataType.DATE)
|
||||
@@ -69,6 +73,9 @@ export class Student extends Model<InferAttributes<Student>> {
|
||||
@Column(DataType.BOOLEAN)
|
||||
hasPersonalIdentityCode!: boolean
|
||||
|
||||
@Column(DataType.STRING)
|
||||
preferredLanguage!: string
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
@@ -11,30 +10,33 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { Student, SISStudyRight } from '.'
|
||||
import type { SISStudyRight, Student, Studyplan } from '@oodikone/shared/models'
|
||||
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
import { StudentModel } from './student'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'studyplan',
|
||||
tableName: 'studyplan',
|
||||
})
|
||||
export class Studyplan extends Model<InferAttributes<Studyplan>> {
|
||||
export class StudyplanModel extends Model<Studyplan> implements Studyplan {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
|
||||
@ForeignKey(() => Student)
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
|
||||
@BelongsTo(() => Student, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
|
||||
@ForeignKey(() => SISStudyRight)
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column(DataType.STRING)
|
||||
sis_study_right_id!: string
|
||||
|
||||
@BelongsTo(() => SISStudyRight, { foreignKey: 'sis_study_right_id', targetKey: 'id', as: 'studyRight' })
|
||||
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'sis_study_right_id', targetKey: 'id', as: 'studyRight' })
|
||||
studyRight!: SISStudyRight
|
||||
|
||||
@Column(DataType.STRING)
|
||||
@@ -43,6 +45,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,14 +1,14 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { ExtentCode } from '../types'
|
||||
import type { StudyrightExtent } from '@oodikone/shared/models'
|
||||
import { ExtentCode } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'studyright_extent',
|
||||
tableName: 'studyright_extents',
|
||||
})
|
||||
export class StudyrightExtent extends Model<InferAttributes<StudyrightExtent>> {
|
||||
export class StudyrightExtentModel extends Model<StudyrightExtent> implements StudyrightExtent {
|
||||
@PrimaryKey
|
||||
@Column(DataType.INTEGER)
|
||||
extentcode!: ExtentCode
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { BelongsToMany, Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Credit } from './credit'
|
||||
import { CreditTeacher } from './creditTeacher'
|
||||
import type { Teacher, Credit } from '@oodikone/shared/models'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { CreditTeacherModel } from './creditTeacher'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'teacher',
|
||||
tableName: 'teacher',
|
||||
})
|
||||
export class Teacher extends Model<InferAttributes<Teacher>> {
|
||||
export class TeacherModel extends Model<Teacher> implements Teacher {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
@@ -17,7 +18,7 @@ export class Teacher extends Model<InferAttributes<Teacher>> {
|
||||
@Column(DataType.STRING)
|
||||
name!: string
|
||||
|
||||
@BelongsToMany(() => Credit, () => CreditTeacher)
|
||||
@BelongsToMany(() => CreditModel, () => CreditTeacherModel)
|
||||
credits!: Credit[]
|
||||
|
||||
@CreatedAt
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Default, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
|
||||
|
||||
import { Language } from '../shared/language'
|
||||
import { Role } from '../shared/types'
|
||||
import type { Language } from '@oodikone/shared/language'
|
||||
import type { User, UserCreation } from '@oodikone/shared/models/user'
|
||||
|
||||
import type { Role } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
@@ -10,7 +12,7 @@ import { Role } from '../shared/types'
|
||||
modelName: 'users',
|
||||
tableName: 'users',
|
||||
})
|
||||
export class User extends Model<InferAttributes<User>, InferCreationAttributes<User>> {
|
||||
export class UserModel extends Model<User, UserCreation> implements User {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
|
||||
@@ -53,9 +53,9 @@ const routes = (app: Express, url: string) => {
|
||||
app.use(`${url}/semesters/codes`, semesters)
|
||||
app.use(url, tags)
|
||||
app.use(url, programmeModules)
|
||||
app.use(`${url}/v3/course_exclusions`, courseExclusions)
|
||||
app.use(`${url}/v3/course-exclusions`, courseExclusions)
|
||||
app.use(`${url}/v2/studyprogrammes`, studyProgramme)
|
||||
app.use(`${url}/programmecriteria`, studyProgrammeCriteria)
|
||||
app.use(`${url}/programme-criteria`, studyProgrammeCriteria)
|
||||
app.use(`${url}/openunisearch`, auth.roles(['openUniSearch']), customOpenUniSearch)
|
||||
app.use(`${url}/changelog`, changelog)
|
||||
app.use(`${url}/completedcoursessearch`, completedCoursesSearch)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import axios from 'axios'
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { Release } from '@oodikone/shared/types'
|
||||
import { isDev } from '../config'
|
||||
import { Release } from '../shared/types'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const changelog: { data?: Release[] } = {}
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
export type ChangelogResBody = Release[]
|
||||
|
||||
router.get<never, ChangelogResBody>('/', async (_req: Request, res: Response) => {
|
||||
if (changelog.data) {
|
||||
return res.status(200).send(changelog.data)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
import { difference, intersection } from 'lodash'
|
||||
|
||||
import { getCompletedCourses } from '../services/completedCoursesSearch'
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { tryCatch } from '@oodikone/shared/util'
|
||||
import { Courses, FormattedStudent, getCompletedCourses } from '../services/completedCoursesSearch'
|
||||
import {
|
||||
getOpenUniSearches,
|
||||
createNewSearch,
|
||||
deleteSearch,
|
||||
updateSearch,
|
||||
FoundSearch,
|
||||
} from '../services/openUni/openUniManageSearches'
|
||||
import { hasFullAccessToStudentData } from '../util'
|
||||
import { hasFullAccessToStudentData, safeJSONParse } from '../util'
|
||||
import { getImporterClient } from '../util/importerClient'
|
||||
import logger from '../util/logger'
|
||||
|
||||
@@ -16,17 +19,22 @@ const router = Router()
|
||||
|
||||
const importerClient = getImporterClient()
|
||||
|
||||
interface GetSearchRequest extends Request {
|
||||
query: {
|
||||
studentlist: string
|
||||
courselist: string
|
||||
}
|
||||
export type SearchResBody = CanError<
|
||||
{ discardedStudentNumbers: string[] } & { students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }
|
||||
>
|
||||
export type SearchReqBody = never
|
||||
export type SearchQuery = {
|
||||
studentlist: string
|
||||
courselist: string
|
||||
}
|
||||
|
||||
router.get('/', async (req: GetSearchRequest, res: Response) => {
|
||||
const studentNumbers = JSON.parse(req.query?.studentlist) || []
|
||||
const courseCodes = JSON.parse(req.query?.courselist) || []
|
||||
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
|
||||
const { studentlist, courselist } = req.query
|
||||
const studentNumbers: string[] = (await safeJSONParse(studentlist)) ?? []
|
||||
const courseCodes: string[] = (await safeJSONParse(courselist)) ?? []
|
||||
|
||||
const { roles, studentsUserCanAccess, sisPersonId: teacherId } = req.user
|
||||
|
||||
if (!Array.isArray(studentNumbers)) {
|
||||
return res.status(400).json({ error: 'Student numbers must be of type array' })
|
||||
}
|
||||
@@ -34,48 +42,60 @@ router.get('/', async (req: GetSearchRequest, res: Response) => {
|
||||
return res.status(400).json({ error: 'Courses must be of type array' })
|
||||
}
|
||||
|
||||
const answerTimeout = new Promise(resolve => setTimeout(resolve, 6000))
|
||||
const answerTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), 6000))
|
||||
|
||||
try {
|
||||
// Teachers can also get rights to students via Importer if
|
||||
// the students have enrolled to their courses in last 8 months
|
||||
// (acual logic a bit different, see Importer)
|
||||
const teacherRightsToStudents = (await Promise.race([
|
||||
importerClient!.post('/teacher-rights/', { teacherId, studentNumbers }),
|
||||
answerTimeout,
|
||||
])) as { data: string[] }
|
||||
if (teacherRightsToStudents && Array.isArray(teacherRightsToStudents.data)) {
|
||||
studentsUserCanAccess.push(...teacherRightsToStudents.data)
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Teachers can also get rights to students via Importer if
|
||||
// the students have enrolled to their courses in last 8 months
|
||||
// (acual logic a bit different, see Importer)
|
||||
const { data: teacherRightsToStudents, error } = importerClient
|
||||
? await tryCatch<{ data: string[] }>(
|
||||
Promise.race([
|
||||
importerClient.post('/teacher-rights/', { teacherId, studentNumbers }),
|
||||
answerTimeout as Promise<never>, // This will always reject with an Error
|
||||
])
|
||||
)
|
||||
: { data: null, error: null }
|
||||
|
||||
if (error) {
|
||||
logger.error(`Importer teacher-rights request failed with message: ${error?.message}`)
|
||||
} else if (teacherRightsToStudents && Array.isArray(teacherRightsToStudents.data)) {
|
||||
studentsUserCanAccess.push(...teacherRightsToStudents.data)
|
||||
}
|
||||
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentNumbers
|
||||
: intersection(studentNumbers, studentsUserCanAccess)
|
||||
|
||||
const completedCourses = await getCompletedCourses(filteredStudentNumbers, courseCodes)
|
||||
const discardedStudentNumbers = difference(
|
||||
studentNumbers,
|
||||
completedCourses.students.map(student => student.studentNumber)
|
||||
)
|
||||
|
||||
return res.json({ discardedStudentNumbers, ...completedCourses })
|
||||
})
|
||||
|
||||
router.get('/searches', async (req: Request, res: Response) => {
|
||||
export type GetSearchResBody = FoundSearch[]
|
||||
|
||||
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
|
||||
const userId = req.user.id
|
||||
const foundSearches = await getOpenUniSearches(userId)
|
||||
return res.json(foundSearches)
|
||||
})
|
||||
|
||||
interface CreateSearchRequest extends Request {
|
||||
body: {
|
||||
courselist: string[]
|
||||
name: string
|
||||
}
|
||||
export type CreateSearchResBody = CanError<{
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
courseList: string[]
|
||||
updatedAt: string
|
||||
}>
|
||||
export type CreateSearchReqBody = {
|
||||
courselist: string[]
|
||||
name: string
|
||||
}
|
||||
|
||||
router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
|
||||
router.post<never, CreateSearchResBody, CreateSearchReqBody>('/searches', async (req, res) => {
|
||||
const courseCodes = req.body?.courselist || []
|
||||
const name = req.body?.name
|
||||
const userId = req.user.id
|
||||
@@ -98,26 +118,30 @@ router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
|
||||
})
|
||||
})
|
||||
|
||||
interface UpdateSearchRequest extends Request {
|
||||
body: {
|
||||
courselist: string[]
|
||||
}
|
||||
params: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
export type UpdateSearchParams = { id: string }
|
||||
export type UpdateSearchResBody = CanError<{
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
courseList: string[]
|
||||
updatedAt: string
|
||||
} | void>
|
||||
export type UpdateSearchReqBody = { courselist: string[] }
|
||||
|
||||
router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searches/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { courselist: courseCodes = [] } = req.body
|
||||
const { id: userId } = req.user
|
||||
|
||||
router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
|
||||
const id = req.params?.id
|
||||
const courseCodes = req.body?.courselist || []
|
||||
const userId = req.user.id
|
||||
if (!id || !userId) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const updatedSearch = await updateSearch(userId, id, courseCodes)
|
||||
if (!updatedSearch) {
|
||||
return res.status(404).json({ error: 'Courselist could not be found' })
|
||||
}
|
||||
|
||||
return res.json({
|
||||
id: updatedSearch.id,
|
||||
userId: updatedSearch.userId,
|
||||
@@ -127,22 +151,22 @@ router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
|
||||
})
|
||||
})
|
||||
|
||||
interface DeleteSearchRequest extends Request {
|
||||
params: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
export type DeleteSearchParams = { id: string }
|
||||
export type DeleteSearchResBody = CanError<string | void>
|
||||
|
||||
router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { id: userId } = req.user
|
||||
|
||||
router.delete('/searches/:id', async (req: DeleteSearchRequest, res: Response) => {
|
||||
const id = req.params?.id
|
||||
const userId = req.user.id
|
||||
if (!id || !userId) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const deletedSearch = await deleteSearch(userId, id)
|
||||
if (!deletedSearch) {
|
||||
return res.status(404).json({ error: 'Courselist could not be found' })
|
||||
}
|
||||
|
||||
return res.json(id)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { addExcludedCourses, removeExcludedCourses } from '../services/excludedCourses'
|
||||
|
||||
const router = Router()
|
||||
|
||||
interface ExcludedCoursesRequest extends Request {
|
||||
body: {
|
||||
courseCodes: string[]
|
||||
curriculumVersion: string
|
||||
}
|
||||
params: {
|
||||
code: string
|
||||
}
|
||||
export type ExcludedCoursesParams = { code: string }
|
||||
export type ExcludedCoursesResBody = void
|
||||
export type ExcludedCoursesReqBody = {
|
||||
courseCodes: string[]
|
||||
curriculumVersion: string
|
||||
}
|
||||
|
||||
router.post('/:code', async (req: ExcludedCoursesRequest, res: Response) => {
|
||||
router.post<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/:code', async (req, res) => {
|
||||
const { code: programmeCode } = req.params
|
||||
const { courseCodes, curriculumVersion } = req.body
|
||||
const result = await addExcludedCourses(programmeCode, courseCodes, curriculumVersion)
|
||||
const result = await addExcludedCourses(courseCodes, curriculumVersion, programmeCode)
|
||||
if (!result) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
res.json(result)
|
||||
res.status(201).end()
|
||||
})
|
||||
|
||||
router.delete('/:code', async (req: ExcludedCoursesRequest, res: Response) => {
|
||||
router.delete<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/:code', async (req, res) => {
|
||||
const { code: programmeCode } = req.params
|
||||
const { courseCodes, curriculumVersion } = req.body
|
||||
const result = await removeExcludedCourses(programmeCode, courseCodes, curriculumVersion)
|
||||
const result = await removeExcludedCourses(courseCodes, curriculumVersion, programmeCode)
|
||||
if (!result) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
res.json(result)
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import crypto from 'crypto'
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { serviceProvider } from '../config'
|
||||
import { getCourseYearlyStats } from '../services/courses'
|
||||
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
|
||||
import { CourseWithSubsId } from '../types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -24,24 +21,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 (mergedCourse.max_attainment_date < course.max_attainment_date) {
|
||||
mergedCourse.max_attainment_date = course.max_attainment_date
|
||||
}
|
||||
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
|
||||
mergedCourse.min_attainment_date = course.min_attainment_date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ courses: Object.values(mergedCourses) })
|
||||
})
|
||||
|
||||
interface GetCourseYearlyStatsRequest extends Request {
|
||||
@@ -72,16 +90,11 @@ router.get('/v3/courseyearlystats', async (req: GetCourseYearlyStatsRequest, res
|
||||
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
|
||||
const separate = req.query.separate === 'true'
|
||||
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info('Debugging c.y.s.: got through authorization and collected req query params.')
|
||||
|
||||
// Student numbers should be obfuscated to all other users except admins,
|
||||
// fullSisuAccess users, and users with rights to any specific study programmes
|
||||
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
|
||||
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
|
||||
const results = await getCourseYearlyStats(codes, separate, anonymizationSalt, combineSubstitutions)
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info('Debugging c.y.s.: getCourseYearlyStats returned and route should return result.')
|
||||
res.json(results)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { CurriculumPeriod } from '../models'
|
||||
import { CurriculumPeriodModel } from '../models'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const result = await CurriculumPeriod.findAll()
|
||||
const result = await CurriculumPeriodModel.findAll()
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import {
|
||||
getOpenUniSearches,
|
||||
createNewSearch,
|
||||
@@ -7,59 +8,74 @@ import {
|
||||
updateSearch,
|
||||
} from '../services/openUni/openUniManageSearches'
|
||||
import { getCustomOpenUniCourses } from '../services/openUni/openUniStats'
|
||||
import { safeJSONParse } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
interface GetSearchRequest extends Request {
|
||||
query: {
|
||||
courselist: string
|
||||
startdate: string
|
||||
enddate: string
|
||||
}
|
||||
type SearchResBody = CanError<Awaited<ReturnType<typeof getCustomOpenUniCourses>>>
|
||||
type SearchReqBody = never
|
||||
type SearchQuery = {
|
||||
courselist: string
|
||||
startdate: string
|
||||
enddate: string
|
||||
}
|
||||
|
||||
router.get('/', async (req: GetSearchRequest, res: Response) => {
|
||||
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') {
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
}
|
||||
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
|
||||
const { courselist, startdate, enddate } = req.query
|
||||
|
||||
const courseCodes = (await safeJSONParse(courselist)) ?? []
|
||||
if (!Array.isArray(courseCodes)) {
|
||||
return res.status(400).json({ error: 'Courses must be of type array' })
|
||||
}
|
||||
|
||||
const startDate = startdate !== 'null' ? new Date(startdate) : new Date(2017, 7, 1, 0, 0, 0)
|
||||
const endDate = enddate !== 'null' ? new Date(enddate) : new Date()
|
||||
if (enddate === 'null') {
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
}
|
||||
|
||||
const customOpenUniSearches = await getCustomOpenUniCourses(courseCodes, startDate, endDate)
|
||||
return res.json(customOpenUniSearches)
|
||||
})
|
||||
|
||||
router.get('/searches', async (req: Request, res: Response) => {
|
||||
const userId = req.user.id
|
||||
type GetSearchResBody = Awaited<ReturnType<typeof getOpenUniSearches>>
|
||||
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
|
||||
const { id: userId } = req.user
|
||||
const foundSearches = await getOpenUniSearches(userId)
|
||||
return res.json(foundSearches)
|
||||
})
|
||||
|
||||
interface CreateSearchRequest extends Request {
|
||||
body: {
|
||||
courselist: string[]
|
||||
name: string
|
||||
}
|
||||
type CreateSearchResBody = CanError<{
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
courseList: string[]
|
||||
updatedAt: string
|
||||
}>
|
||||
type CreateSearchReqBody = {
|
||||
courselist: string[]
|
||||
name: string
|
||||
}
|
||||
|
||||
router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
|
||||
const courseCodes = req.body?.courselist || []
|
||||
const name = req.body?.name
|
||||
const userId = req.user.id
|
||||
router.post<never, CreateSearchResBody, CreateSearchReqBody>('/searches', async (req, res) => {
|
||||
const { courselist, name } = req.body
|
||||
const { id: userId } = req.user
|
||||
|
||||
const courseCodes = courselist || []
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name missing' })
|
||||
}
|
||||
|
||||
if (courseCodes && !Array.isArray(courseCodes)) {
|
||||
return res.status(400).json({ error: 'Course codes must be type of array' })
|
||||
}
|
||||
|
||||
const createdSearch = await createNewSearch(userId, name, courseCodes)
|
||||
if (!createdSearch) {
|
||||
return res.status(400).json({ error: 'Failed to create search' })
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
id: createdSearch.id,
|
||||
userId: createdSearch.userId,
|
||||
@@ -69,26 +85,36 @@ router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
|
||||
})
|
||||
})
|
||||
|
||||
interface UpdateSearchRequest extends Request {
|
||||
body: {
|
||||
courselist: string[]
|
||||
}
|
||||
params: {
|
||||
id: string
|
||||
}
|
||||
type UpdateSearchParams = {
|
||||
id: string
|
||||
}
|
||||
type UpdateSearchResBody = CanError<{
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
courseList: string[]
|
||||
updatedAt: string
|
||||
}>
|
||||
type UpdateSearchReqBody = {
|
||||
courselist: string[]
|
||||
}
|
||||
|
||||
router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
|
||||
const id = req.params?.id
|
||||
const courseCodes = req.body?.courselist || []
|
||||
const userId = req.user.id
|
||||
router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searches/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { courselist } = req.body
|
||||
const { id: userId } = req.user
|
||||
|
||||
const courseCodes = courselist || []
|
||||
|
||||
if (!id || !userId) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const updatedSearch = await updateSearch(userId, id, courseCodes)
|
||||
if (!updatedSearch) {
|
||||
return res.status(404).json({ error: 'Open uni search could not be found' })
|
||||
}
|
||||
|
||||
return res.json({
|
||||
id: updatedSearch.id,
|
||||
userId: updatedSearch.userId,
|
||||
@@ -98,22 +124,24 @@ router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
|
||||
})
|
||||
})
|
||||
|
||||
interface DeleteSearchRequest extends Request {
|
||||
params: {
|
||||
id: string
|
||||
}
|
||||
type DeleteSearchParams = {
|
||||
id: string
|
||||
}
|
||||
type DeleteSearchResBody = CanError<string>
|
||||
|
||||
router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { id: userId } = req.user
|
||||
|
||||
router.delete('/searches/:id', async (req: DeleteSearchRequest, res: Response) => {
|
||||
const id = req.params?.id
|
||||
const userId = req.user.id
|
||||
if (!id || !userId) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const deletedSearch = await deleteSearch(userId, id)
|
||||
if (!deletedSearch) {
|
||||
return res.status(404).json({ error: 'Open uni search could not be found' })
|
||||
}
|
||||
|
||||
return res.json(id)
|
||||
})
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ router.post('/', async (req: Request, res: Response) => {
|
||||
return res.status(400).json({ error: 'Students must be of type array' })
|
||||
}
|
||||
|
||||
const customPopulationSearch = await createCustomPopulationSearch(name, id, students || [])
|
||||
const customPopulationSearch = await createCustomPopulationSearch(name, id, students ?? [])
|
||||
res.json(customPopulationSearch)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '@oodikone/shared/types'
|
||||
import * as auth from '../middleware/auth'
|
||||
import { getDegreeProgrammesOfFaculty, getFacultyCodeById } from '../services/faculty/faculty'
|
||||
import { combineFacultyBasics } from '../services/faculty/facultyBasics'
|
||||
@@ -22,7 +23,6 @@ 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 '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
// Faculty uses a lot of tools designed for Study programme.
|
||||
|
||||
@@ -3,298 +3,183 @@ import crypto from 'crypto'
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { difference, intersection, uniq } from 'lodash'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { GenderCode } from '@oodikone/shared/types'
|
||||
import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { rootOrgId } from '../config'
|
||||
import { SISStudyRight } from '../models'
|
||||
import { SISStudyRightModel } 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'
|
||||
import { GenderCode, ParsedCourse, Unarray, Unification, UnifyStatus } from '../types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
import { findByCourseAndSemesters } from '../services/students'
|
||||
import { ParsedCourse, Unarray, Unification, UnifyStatus } from '../types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, safeJSONParse } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const filterPersonalTags = (population: Record<string, any>, userId: string) => {
|
||||
return {
|
||||
...population,
|
||||
students: population.students.map(student => {
|
||||
student.tags = student.tags.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId)
|
||||
return student
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
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 = CanError<Bottlenecks>
|
||||
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: unknown) {
|
||||
if (error instanceof Error) {
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
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: unknown) {
|
||||
if (error instanceof Error) {
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
return res.status(400).end()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface PostPopulationStatisticsByStudentNumberListRequest extends Request {
|
||||
body: {
|
||||
studentnumberlist: string[]
|
||||
year: string
|
||||
studyRights: string[]
|
||||
semesters: string[]
|
||||
months: number
|
||||
}
|
||||
export type PopulationstatisticsResBody = CanError<{ students: any }>
|
||||
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 { id: userId, 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: unknown) {
|
||||
if (error instanceof Error) {
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
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 =
|
||||
await 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
|
||||
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,
|
||||
userId,
|
||||
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,
|
||||
userId,
|
||||
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: '',
|
||||
@@ -306,7 +191,6 @@ router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsReques
|
||||
email: '',
|
||||
secondaryEmail: '',
|
||||
sis_person_id: '',
|
||||
enrollments: student.enrollments?.map(enrollment => ({ ...enrollment, studentnumber: studentNumber })),
|
||||
tags: [],
|
||||
birthdate: obfuscatedBirthDate,
|
||||
obfuscated: true,
|
||||
@@ -314,9 +198,9 @@ router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsReques
|
||||
})
|
||||
}
|
||||
|
||||
res.json(filterPersonalTags(result, userId))
|
||||
return res.json(result)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
interface GetPopulationStatisticsByCourseRequest extends Request {
|
||||
query: {
|
||||
@@ -330,7 +214,7 @@ interface GetPopulationStatisticsByCourseRequest extends Request {
|
||||
|
||||
router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisticsByCourseRequest, res: Response) => {
|
||||
const { coursecodes, from, to, separate: separateString, unifyCourses } = req.query
|
||||
const { id, roles, studentsUserCanAccess: allStudentsUserCanAccess, programmeRights } = req.user
|
||||
const { id: userId, roles, studentsUserCanAccess: allStudentsUserCanAccess, programmeRights } = req.user
|
||||
|
||||
if (!coursecodes || !from || !to) {
|
||||
return res.status(400).json({ error: 'The body should have a yearcode and coursecode defined' })
|
||||
@@ -355,10 +239,10 @@ 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: [],
|
||||
userId,
|
||||
year: '1900',
|
||||
studyRights: undefined,
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
months: 10000,
|
||||
},
|
||||
studentNumbers
|
||||
)
|
||||
@@ -382,7 +266,7 @@ router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisti
|
||||
courses,
|
||||
gender_code,
|
||||
}: {
|
||||
studyRights: SISStudyRight[]
|
||||
studyRights: SISStudyRightModel[]
|
||||
studentNumber: string
|
||||
courses: ParsedCourse[]
|
||||
gender_code: GenderCode
|
||||
@@ -402,18 +286,13 @@ 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)
|
||||
)
|
||||
}
|
||||
|
||||
res.json(filterPersonalTags(result, id))
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
interface PostByStudentNumbersRequest extends Request {
|
||||
@@ -428,32 +307,27 @@ interface PostByStudentNumbersRequest extends Request {
|
||||
|
||||
router.post('/v3/populationstatisticsbystudentnumbers', async (req: PostByStudentNumbersRequest, res: Response) => {
|
||||
const { studentnumberlist, tags } = req.body
|
||||
const { roles, id, studentsUserCanAccess } = req.user
|
||||
const { id: userId, roles, studentsUserCanAccess } = req.user
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentnumberlist
|
||||
: intersection(studentnumberlist, studentsUserCanAccess)
|
||||
const studyProgrammeCode = 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] : [],
|
||||
userId,
|
||||
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)
|
||||
|
||||
res.status(200).json({ ...filterPersonalTags(resultWithStudyProgramme, id), discardedStudentNumbers })
|
||||
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
|
||||
})
|
||||
|
||||
router.get('/v3/populationstatistics/studyprogrammes', async (req: Request, res: Response) => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { getCoursesAndModules, getCurriculumVersions } from '../services/programmeModules'
|
||||
import { getCoursesAndModules, getCurriculumOptions } from '../services/programmeModules'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/v3/curriculum-options/:code', async (req: Request, res: Response) => {
|
||||
const { code } = req.params
|
||||
const result = await getCurriculumVersions(code)
|
||||
const result = await getCurriculumOptions(code)
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
|
||||
import {
|
||||
getBasicStats,
|
||||
setBasicStats,
|
||||
@@ -18,7 +19,6 @@ import { getGraduationStatsForStudyTrack } from '../services/studyProgramme/stud
|
||||
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 '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
import { logInfoForGrafana } from '../util/logInfoForGrafana'
|
||||
|
||||
@@ -27,16 +27,16 @@ const router = Router()
|
||||
interface GetCreditStatsRequest extends Request {
|
||||
query: {
|
||||
codes: string
|
||||
isAcademicYear: string
|
||||
includeSpecials: string
|
||||
specialGroups: SpecialGroups
|
||||
yearType: YearType
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) => {
|
||||
const { codes, isAcademicYear, includeSpecials } = req.query
|
||||
const { codes, specialGroups, yearType } = req.query
|
||||
const stats = {}
|
||||
for (const code of JSON.parse(codes)) {
|
||||
stats[code] = await getCreditsProduced(code, isAcademicYear !== 'false', includeSpecials !== 'false')
|
||||
stats[code] = await getCreditsProduced(code, yearType === 'ACADEMIC_YEAR', specialGroups === 'SPECIAL_INCLUDED')
|
||||
}
|
||||
return res.json({ stats })
|
||||
})
|
||||
@@ -105,20 +105,19 @@ router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) =
|
||||
|
||||
interface GetCourseStatsRequest extends Request {
|
||||
query: {
|
||||
academicyear: string
|
||||
combined_programme: string
|
||||
combinedProgramme: string
|
||||
yearType: YearType
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
const showByYear = req.query.academicyear
|
||||
const combinedProgramme = req.query?.combined_programme
|
||||
const { combinedProgramme, yearType } = req.query
|
||||
const date = new Date()
|
||||
date.setHours(23, 59, 59, 999)
|
||||
void logInfoForGrafana(code, combinedProgramme)
|
||||
try {
|
||||
const data = await getStudyProgrammeCoursesForStudyTrack(date.getTime(), code, showByYear, combinedProgramme)
|
||||
const data = await getStudyProgrammeCoursesForStudyTrack(date.getTime(), code, yearType, combinedProgramme)
|
||||
return res.json(data)
|
||||
} catch (error) {
|
||||
logger.error({ message: `Failed to get code ${code} programme courses stats`, meta: `${error}` })
|
||||
|
||||
@@ -10,12 +10,12 @@ const router = Router()
|
||||
|
||||
interface StudyProgrammeCriteriaRequest extends Request {
|
||||
query: {
|
||||
programmecode: string
|
||||
programmeCode: string
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/', async (req: StudyProgrammeCriteriaRequest, res: Response) => {
|
||||
const studyProgramme = req.query.programmecode
|
||||
const studyProgramme = req.query.programmeCode
|
||||
if (studyProgramme !== '' && !studyProgramme) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
@@ -37,7 +37,10 @@ router.post('/courses', async (req: CriteriaCoursesRequest, res: Response) => {
|
||||
return res.status(400).end()
|
||||
}
|
||||
const studyProgrammeCriteria = await saveYearlyCourseCriteria(code, courses, year)
|
||||
return res.json(studyProgrammeCriteria)
|
||||
if (!studyProgrammeCriteria) {
|
||||
return res.status(404).end()
|
||||
}
|
||||
return res.status(201).end()
|
||||
})
|
||||
|
||||
interface CriteriaCreditsRequest extends Request {
|
||||
@@ -53,7 +56,10 @@ router.post('/credits', async (req: CriteriaCreditsRequest, res: Response) => {
|
||||
return res.status(400).end()
|
||||
}
|
||||
const studyProgrammeCriteria = await saveYearlyCreditCriteria(code, credits)
|
||||
return res.json(studyProgrammeCriteria)
|
||||
if (!studyProgrammeCriteria) {
|
||||
return res.status(404).end()
|
||||
}
|
||||
return res.status(201).end()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
import { difference } from 'lodash'
|
||||
|
||||
import { Tag, TagStudent } from '../models/kone'
|
||||
import { filterStudentnumbersByAccessrights } from '../services/students'
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { NewTag, Role, StudentTag, Tag } from '@oodikone/shared/types'
|
||||
import { filterStudentNumbersByAccessRights } from '../services/students'
|
||||
import {
|
||||
findTagsByStudyTrack,
|
||||
createNewTag,
|
||||
@@ -11,20 +12,17 @@ import {
|
||||
createMultipleStudentTags,
|
||||
findTagsFromStudyTrackById,
|
||||
deleteMultipleStudentTags,
|
||||
TagFromFrontend,
|
||||
StudentTagFromFrontend,
|
||||
} from '../services/tags'
|
||||
import { Role } 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[]) => {
|
||||
@@ -39,17 +37,14 @@ const getStudyTrackCode = (studyTrack: string, combinedProgramme: string) => {
|
||||
return combinedProgramme ? `${studyTrack}-${combinedProgramme}` : studyTrack
|
||||
}
|
||||
|
||||
interface GetTagsByStudyTrackRequest extends Request {
|
||||
params: {
|
||||
studytrack: string
|
||||
}
|
||||
}
|
||||
export type GetTagsByStudyTrackParams = { studyTrack: string }
|
||||
export type GetTagsByStudyTrackResBody = Tag[] | null
|
||||
|
||||
router.get('/tags/:studytrack', async (req: GetTagsByStudyTrackRequest, res: Response) => {
|
||||
const { studytrack } = req.params
|
||||
router.get<GetTagsByStudyTrackParams, GetTagsByStudyTrackResBody>('/tags/:studyTrack', async (req, res) => {
|
||||
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,149 +52,144 @@ 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
|
||||
}
|
||||
}
|
||||
export type PostTagResBody = CanError<Tag[] | void>
|
||||
export type PostTagReqBody = { tag: NewTag }
|
||||
|
||||
router.post('/tags', async (req: PostTagRequest, res: Response) => {
|
||||
router.post<never, PostTagResBody, PostTagReqBody>('/tags', async (req, res) => {
|
||||
const {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
export type DeleteTagResBody = Tag[] | void
|
||||
export type DeleteTagReqBody = { tag: Tag }
|
||||
|
||||
router.delete('/tags', async (req: DeleteTagRequest, res: Response) => {
|
||||
const { tag } = req.body
|
||||
const { roles, id, programmeRights } = req.user
|
||||
router.delete<never, DeleteTagResBody, DeleteTagReqBody>('/tags', async (req, res) => {
|
||||
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
|
||||
export type GetStudentTagsByStudyTrackParams = { studyTrack: string }
|
||||
export type GetStudentTagsByStudyTrackResBody = StudentTag[] | null
|
||||
|
||||
router.get<GetStudentTagsByStudyTrackParams, GetStudentTagsByStudyTrackResBody>(
|
||||
'/studenttags/:studyTrack',
|
||||
async (req, res) => {
|
||||
const { studyTrack } = req.params
|
||||
const { roles, id, programmeRights } = req.user
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
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)
|
||||
const relevantTags = filterRelevantStudentTags(tags, id)
|
||||
res.status(200).json(relevantTags)
|
||||
}
|
||||
)
|
||||
|
||||
export type PostStudentTagsResBody = CanError<void>
|
||||
export type PostStudentTagsReqBody = {
|
||||
combinedProgramme: string
|
||||
studentTags: StudentTag[]
|
||||
studyTrack: string
|
||||
}
|
||||
|
||||
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 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))
|
||||
})
|
||||
|
||||
interface PostStudentTagsRequest extends Request {
|
||||
body: {
|
||||
tags: StudentTagFromFrontend[]
|
||||
studytrack: string
|
||||
combinedProgramme: string
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/studenttags', async (req: PostStudentTagsRequest, res: Response) => {
|
||||
const { tags, studytrack, combinedProgramme } = req.body
|
||||
router.post<never, PostStudentTagsResBody, PostStudentTagsReqBody>('/studenttags', async (req, res) => {
|
||||
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
|
||||
}
|
||||
export type DeleteStudentTagsResBody = CanError<void>
|
||||
export type DeleteStudentTagsReqBody = {
|
||||
combinedProgramme: string
|
||||
tagId: string
|
||||
studentNumbers: string[]
|
||||
studyTrack: string
|
||||
}
|
||||
|
||||
router.delete('/studenttags', async (req: DeleteStudentTagsRequest, res: Response) => {
|
||||
const { tagId, studentnumbers, studytrack, combinedProgramme } = req.body
|
||||
router.delete<never, DeleteStudentTagsResBody, DeleteStudentTagsReqBody>('/studenttags', async (req, res) => {
|
||||
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,11 +1,11 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { mapToProviders } from '@oodikone/shared/util'
|
||||
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'
|
||||
import { getFullStudyProgrammeRights, splitByEmptySpace } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
import { Graduated, NameWithCode } from '@oodikone/shared/types'
|
||||
import { serviceProvider } from '../config'
|
||||
import { magicFacultyCode } from '../config/organizationConstants'
|
||||
import { Organization } from '../models'
|
||||
import { OrganizationModel } from '../models'
|
||||
import { getDegreeProgrammesOfFaculty } from '../services/faculty/faculty'
|
||||
import { countGraduationTimes, LevelGraduationStats } from '../services/faculty/facultyGraduationTimes'
|
||||
import { getSortedFaculties } from '../services/faculty/facultyHelpers'
|
||||
@@ -15,16 +16,15 @@ import {
|
||||
} from '../services/faculty/facultyService'
|
||||
import { combineFacultyStudentProgress, FacultyProgressData } from '../services/faculty/facultyStudentProgress'
|
||||
import { getMedian } from '../services/studyProgramme/studyProgrammeHelpers'
|
||||
import { Graduated, NameWithCode } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const degreeNames = ['bachelor', 'bachelorMaster', 'master', 'doctor'] as const
|
||||
|
||||
const getProgrammeNames = (faculties: Organization[]) => {
|
||||
const getProgrammeNames = (faculties: OrganizationModel[]) => {
|
||||
return faculties.reduce<Record<string, NameWithCode>>((obj, faculty) => {
|
||||
const { name, code } = faculty.dataValues
|
||||
const { name, code } = faculty.toJSON()
|
||||
obj[faculty.code] = { code, ...name }
|
||||
return obj
|
||||
}, {})
|
||||
|
||||
@@ -115,8 +115,8 @@ router.post('/refresh-close-to-graduation', (req: Request, res: Response) => {
|
||||
})
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { LANGUAGE_CODES, Language } from '@oodikone/shared/language'
|
||||
import { Role } from '@oodikone/shared/types'
|
||||
import { tryCatch } from '@oodikone/shared/util'
|
||||
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, Language } from '../shared/language'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
@@ -14,7 +16,7 @@ router.get('/', auth.roles(['admin']), async (_req: Request, res: Response) => {
|
||||
res.json(results)
|
||||
})
|
||||
|
||||
router.get('/access_groups', auth.roles(['admin']), (_req: Request, res: Response) => {
|
||||
router.get('/roles', auth.roles(['admin']), (_req: Request, res: Response) => {
|
||||
res.json(roles)
|
||||
})
|
||||
|
||||
@@ -25,30 +27,29 @@ interface UidRequest extends Request {
|
||||
}
|
||||
|
||||
router.get('/:uid', auth.roles(['admin']), async (req: UidRequest, res: Response) => {
|
||||
try {
|
||||
const { uid } = req.params
|
||||
const user = await userService.findOne(uid)
|
||||
return res.json(user)
|
||||
} catch (error: any) {
|
||||
return res.status(400).json({ error: error.message })
|
||||
}
|
||||
const { uid } = req.params
|
||||
const { data: user, error } = await tryCatch(userService.findOne(uid))
|
||||
if (error) return res.status(400).json({ error: error.message })
|
||||
|
||||
return res.json(user)
|
||||
})
|
||||
|
||||
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
|
||||
try {
|
||||
await userService.modifyAccess(username, accessgroups)
|
||||
res.status(204).end()
|
||||
} catch (error) {
|
||||
res.status(400).json(error)
|
||||
router.post('/modify-roles', auth.roles(['admin']), async (req: ModifyRolesRequest, res: Response) => {
|
||||
const { username, roles } = req.body
|
||||
const { error } = await tryCatch(userService.modifyAccess(username, roles))
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json(error)
|
||||
}
|
||||
|
||||
return res.status(204).end()
|
||||
})
|
||||
|
||||
router.get('/email/preview', auth.roles(['admin']), (_req: Request, res: Response) => {
|
||||
@@ -63,18 +64,14 @@ interface EmailRequest extends Request {
|
||||
}
|
||||
|
||||
router.post('/email', auth.roles(['admin']), async (req: EmailRequest, res: Response) => {
|
||||
const userEmail = req.body.email
|
||||
if (!userEmail) {
|
||||
return res.status(400).json({ error: 'Email address is missing' })
|
||||
}
|
||||
const { email } = req.body
|
||||
if (!email) return res.status(400).json({ error: 'Email address is missing' })
|
||||
|
||||
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
|
||||
try {
|
||||
await sendNotificationAboutAccessToUser(userEmail)
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({ error: error.message })
|
||||
}
|
||||
logger.info('Succesfully sent message about Oodikone access to user')
|
||||
res.status(200).end()
|
||||
return res.status(200).end()
|
||||
})
|
||||
|
||||
interface ElementsRequest extends Request {
|
||||
@@ -107,18 +104,16 @@ interface ChangeLanguageRequest extends Request {
|
||||
}
|
||||
|
||||
router.post('/language', async (req: ChangeLanguageRequest, res: Response) => {
|
||||
const {
|
||||
body: { language },
|
||||
} = req
|
||||
const { language } = req.body
|
||||
|
||||
if (!LANGUAGE_CODES.includes(language)) {
|
||||
return res.status(400).json('Invalid language')
|
||||
}
|
||||
try {
|
||||
await userService.updateUser(req.user.username, { language })
|
||||
return res.status(204).end()
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({ error: error.message })
|
||||
}
|
||||
|
||||
const { error } = await tryCatch(userService.updateUser(req.user.username, { language }))
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
|
||||
return res.status(204).end()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
|
||||
import { facultyCodes, ignoredFacultyCodes } from '../config/organizationConstants'
|
||||
import { Graduated, SpecialGroups, YearType } from '../shared/types'
|
||||
import { redisClient } from './redis'
|
||||
import { isRelevantProgramme } from './studyProgramme/studyProgrammeHelpers'
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { col, fn, Op, where } from 'sequelize'
|
||||
|
||||
import { Course, Credit, Enrollment, Student } from '../models/index'
|
||||
import { CreditTypeCode, EnrollmentState } from '../types'
|
||||
import { CreditTypeCode, EnrollmentState } from '@oodikone/shared/types'
|
||||
import { omitKeys } from '@oodikone/shared/util'
|
||||
import { CourseModel, CreditModel, EnrollmentModel, StudentModel, StudyplanModel } from '../models'
|
||||
|
||||
type Courses = Array<Pick<Course, 'code' | 'name' | 'substitutions'>>
|
||||
export type Courses = Array<Pick<CourseModel, 'code' | 'name' | 'substitutions'>>
|
||||
|
||||
interface StudentCredit {
|
||||
date: Date
|
||||
@@ -23,9 +24,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<
|
||||
@@ -36,21 +38,26 @@ type StudentCredits = Record<
|
||||
}
|
||||
>
|
||||
|
||||
interface FormattedStudent {
|
||||
export 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<
|
||||
StudentModel,
|
||||
'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[]) => {
|
||||
const courses: Courses = (
|
||||
await Course.findAll({
|
||||
await CourseModel.findAll({
|
||||
attributes: ['code', 'name', 'substitutions'],
|
||||
where: where(fn('LOWER', col('code')), {
|
||||
[Op.in]: courseCodes.map(code => code.toLowerCase()),
|
||||
@@ -66,18 +73,19 @@ const getCredits = async (
|
||||
courseCodes: string[],
|
||||
studentNumbers: string[]
|
||||
) => {
|
||||
const credits: Array<Pick<Credit, 'course_code' | 'student_studentnumber' | 'credittypecode' | 'attainment_date'>> =
|
||||
await Credit.findAll({
|
||||
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'attainment_date'],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: fullCourseCodes,
|
||||
},
|
||||
student_studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
const credits: Array<
|
||||
Pick<CreditModel, 'course_code' | 'student_studentnumber' | 'credittypecode' | 'attainment_date'>
|
||||
> = await CreditModel.findAll({
|
||||
attributes: ['course_code', 'student_studentnumber', 'credittypecode', 'attainment_date'],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: fullCourseCodes,
|
||||
},
|
||||
})
|
||||
student_studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
},
|
||||
})
|
||||
const formattedCredits = credits.map(credit => {
|
||||
const originalCode = courseCodes.includes(credit.course_code)
|
||||
? null
|
||||
@@ -94,8 +102,8 @@ const getCredits = async (
|
||||
}
|
||||
|
||||
const getEnrollments = async (courses: Courses, fullCourseCodes: string[], studentNumbers: string[]) => {
|
||||
const enrollments: Array<Pick<Enrollment, 'course_code' | 'enrollment_date_time' | 'studentnumber'>> =
|
||||
await Enrollment.findAll({
|
||||
const enrollments: Array<Pick<EnrollmentModel, 'course_code' | 'enrollment_date_time' | 'studentnumber'>> =
|
||||
await EnrollmentModel.findAll({
|
||||
attributes: ['course_code', 'enrollment_date_time', 'studentnumber'],
|
||||
where: {
|
||||
course_code: {
|
||||
@@ -122,22 +130,33 @@ const getEnrollments = async (courses: Courses, fullCourseCodes: string[], stude
|
||||
}
|
||||
|
||||
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 StudentModel.findAll({
|
||||
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
})
|
||||
).map(student => student.toJSON())
|
||||
return students
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: StudyplanModel,
|
||||
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[]) => {
|
||||
export const getCompletedCourses = async (
|
||||
studentNumbers: string[],
|
||||
courseCodes: string[]
|
||||
): Promise<{ students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }> => {
|
||||
const courses = await getCourses(courseCodes)
|
||||
const courseCodesSet = new Set(courseCodes)
|
||||
courses.forEach(course => {
|
||||
@@ -159,7 +178,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: [],
|
||||
}
|
||||
@@ -214,7 +234,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[])
|
||||
@@ -231,7 +252,7 @@ export const getCompletedCourses = async (studentNumbers: string[], courseCodes:
|
||||
// Omit allEnrollments, we're only supposed to show the recent, relevant enrollment,
|
||||
// the user does not have rights to see all enrollments.
|
||||
return {
|
||||
students: students.map(({ allEnrollments, ...rest }) => rest),
|
||||
students: students.map(student => omitKeys(student, ['allEnrollments'])),
|
||||
courses,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { orderBy } from 'lodash'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Course } from '../../models'
|
||||
import { CourseModel } from '../../models'
|
||||
import { CourseWithSubsId } from '../../types'
|
||||
import { getSortRank } from '../../util/sortRank'
|
||||
|
||||
@@ -40,7 +41,7 @@ const codeLikeTerm = (code: string) => {
|
||||
}
|
||||
|
||||
const getRawCourses = async (name: string, code: string) => {
|
||||
return await Course.findAll({
|
||||
return await CourseModel.findAll({
|
||||
where: {
|
||||
...nameLikeTerm(name),
|
||||
...codeLikeTerm(code),
|
||||
@@ -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,11 +98,11 @@ export const getCoursesByNameAndOrCode = async (name: string, code: string) => {
|
||||
}
|
||||
})
|
||||
|
||||
return { courses }
|
||||
return courses
|
||||
}
|
||||
|
||||
export const getCoursesByCodes = (codes: string[]) => {
|
||||
return Course.findAll({
|
||||
return CourseModel.findAll({
|
||||
where: {
|
||||
code: {
|
||||
[Op.in]: codes,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Name } from '../../shared/types'
|
||||
import { EnrollmentState } from '../../types'
|
||||
import { lengthOf, percentageOf } from '../../util'
|
||||
import { Name, EnrollmentState } from '@oodikone/shared/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[] = []
|
||||
@@ -95,15 +101,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,
|
||||
@@ -176,16 +180,10 @@ export class CourseStatsCounter {
|
||||
}
|
||||
|
||||
public markEnrollment(studentNumber: string, state: EnrollmentState, semester: string) {
|
||||
if (!this.enrollments[state]) {
|
||||
this.enrollments[state] = new Set()
|
||||
}
|
||||
this.enrollments[state] ??= new Set()
|
||||
this.enrollments[state].add(studentNumber)
|
||||
if (!this.enrollments.semesters[semester]) {
|
||||
this.enrollments.semesters[semester] = {}
|
||||
}
|
||||
if (!this.enrollments.semesters[semester][state]) {
|
||||
this.enrollments.semesters[semester][state] = new Set()
|
||||
}
|
||||
this.enrollments.semesters[semester] ??= {}
|
||||
this.enrollments.semesters[semester][state] ??= new Set()
|
||||
this.enrollments.semesters[semester][state].add(studentNumber)
|
||||
}
|
||||
|
||||
@@ -233,10 +231,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']
|
||||
|
||||
@@ -250,8 +247,8 @@ export class CourseStatsCounter {
|
||||
return attemptStats
|
||||
}
|
||||
|
||||
public getFinalStats() {
|
||||
const { stats, students } = this
|
||||
public getFinalStats(populationCount: number): CourseStatistics {
|
||||
const { stats, students, course, grades } = this
|
||||
const allStudents = Object.keys(students.all)
|
||||
const filteredEnrolledNoGrade = getFilteredEnrolledNoGrade(this.enrollments, allStudents)
|
||||
|
||||
@@ -262,14 +259,14 @@ export class CourseStatsCounter {
|
||||
stats.failed = lengthOf(students.failed)
|
||||
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 { Name } from '../../shared/types'
|
||||
import { max, min } from 'lodash'
|
||||
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { getSemesterNameByCode } from '../semesters'
|
||||
import { FormattedProgramme, OrganizationDetails } from './helpers'
|
||||
|
||||
type Programme = {
|
||||
@@ -284,14 +287,42 @@ export class CourseYearlyStatsCounter {
|
||||
return this.programmes
|
||||
}
|
||||
|
||||
private parseGroupStatistics(anonymizationSalt: string | null) {
|
||||
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'
|
||||
@@ -350,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,
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Credit, Student, Semester, Organization, Enrollment, SISStudyRight, SISStudyRightElement } from '../../models'
|
||||
import { EnrollmentState, Unification } from '../../types'
|
||||
import { EnrollmentState } from '@oodikone/shared/types'
|
||||
import {
|
||||
CreditModel,
|
||||
StudentModel,
|
||||
SemesterModel,
|
||||
OrganizationModel,
|
||||
EnrollmentModel,
|
||||
SISStudyRightModel,
|
||||
SISStudyRightElementModel,
|
||||
} from '../../models'
|
||||
import { Unification } from '../../types'
|
||||
import { getIsOpen } from './helpers'
|
||||
|
||||
export const getCreditsForCourses = async (codes: string[], unification: Unification) => {
|
||||
return await Credit.findAll({
|
||||
return await CreditModel.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Student,
|
||||
model: StudentModel,
|
||||
attributes: ['studentnumber'],
|
||||
},
|
||||
{
|
||||
model: Semester,
|
||||
model: SemesterModel,
|
||||
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
|
||||
where: {
|
||||
startdate: {
|
||||
@@ -32,7 +41,7 @@ export const getCreditsForCourses = async (codes: string[], unification: Unifica
|
||||
}
|
||||
|
||||
export const getStudentNumberToSrElementsMap = async (studentNumbers: string[]) => {
|
||||
const studyRights = await SISStudyRight.findAll({
|
||||
const studyRights = await SISStudyRightModel.findAll({
|
||||
attributes: ['facultyCode', 'id', 'studentNumber'],
|
||||
where: {
|
||||
studentNumber: {
|
||||
@@ -40,7 +49,7 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
|
||||
},
|
||||
},
|
||||
include: {
|
||||
model: Organization,
|
||||
model: OrganizationModel,
|
||||
attributes: ['name', 'code'],
|
||||
},
|
||||
})
|
||||
@@ -52,8 +61,8 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
|
||||
|
||||
const studyRightIds = Object.keys(studyRightMap)
|
||||
|
||||
const studyRightElements = await SISStudyRightElement.findAll({
|
||||
attributes: ['code', 'name', 'startDate', 'studyRightId'],
|
||||
const studyRightElements = await SISStudyRightElementModel.findAll({
|
||||
attributes: ['code', 'name', 'startDate', 'endDate', 'studyRightId'],
|
||||
where: {
|
||||
studyRightId: {
|
||||
[Op.in]: studyRightIds,
|
||||
@@ -64,9 +73,7 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
|
||||
const studentNumberToSrElementsMap = studyRightElements.reduce((obj, cur) => {
|
||||
const studyRight = studyRightMap[cur.studyRightId]
|
||||
const { studentNumber } = studyRight
|
||||
if (!obj[studentNumber]) {
|
||||
obj[studentNumber] = []
|
||||
}
|
||||
obj[studentNumber] ??= []
|
||||
obj[studentNumber].push({ ...cur.toJSON(), studyRight })
|
||||
return obj
|
||||
}, {})
|
||||
@@ -74,14 +81,14 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
|
||||
}
|
||||
|
||||
export const getEnrollmentsForCourses = async (codes: string[], unification: Unification) => {
|
||||
return await Enrollment.findAll({
|
||||
return await EnrollmentModel.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Student,
|
||||
model: StudentModel,
|
||||
attributes: ['studentnumber'],
|
||||
},
|
||||
{
|
||||
model: Semester,
|
||||
model: SemesterModel,
|
||||
attributes: ['semestercode', 'name', 'yearcode', 'yearname'],
|
||||
where: {
|
||||
startdate: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Name } from '../../shared/types'
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { Unification } from '../../types'
|
||||
|
||||
export type FormattedProgramme = {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import crypto from 'crypto'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { serviceProvider } from '../../config'
|
||||
import { Course, Credit, Enrollment, Organization, SISStudyRightElement } from '../../models'
|
||||
import { Name } from '../../shared/types'
|
||||
import { EnrollmentState, Unification } from '../../types'
|
||||
import { Name, EnrollmentState } from '@oodikone/shared/types'
|
||||
import { CourseModel, CreditModel, EnrollmentModel, OrganizationModel, SISStudyRightElementModel } from '../../models'
|
||||
import { Unification } from '../../types'
|
||||
import { isOpenUniCourseCode } from '../../util'
|
||||
import logger from '../../util/logger'
|
||||
import { dateIsBetween } from '../../util/datetime'
|
||||
import { getSortRank } from '../../util/sortRank'
|
||||
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
|
||||
import {
|
||||
@@ -23,7 +22,7 @@ const sortMainCode = (codes: string[]) => {
|
||||
return codes.sort((a, b) => getSortRank(b) - getSortRank(a))
|
||||
}
|
||||
|
||||
const formatStudyRightElement = (studyRightElement: SISStudyRightElement): FormattedProgramme => ({
|
||||
const formatStudyRightElement = (studyRightElement: SISStudyRightElementModel): FormattedProgramme => ({
|
||||
code: studyRightElement.code,
|
||||
name: studyRightElement.name,
|
||||
startDate: studyRightElement.startDate,
|
||||
@@ -56,9 +55,9 @@ type FormattedCredit = {
|
||||
}
|
||||
|
||||
const parseCredit = (
|
||||
credit: Credit,
|
||||
credit: CreditModel,
|
||||
anonymizationSalt: string | null,
|
||||
studentNumberToSrElementsMap: Record<string, Array<SISStudyRightElement>>
|
||||
studentNumberToSrElementsMap: Record<string, Array<SISStudyRightElementModel>>
|
||||
) => {
|
||||
const {
|
||||
semester,
|
||||
@@ -67,11 +66,20 @@ 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)
|
||||
const programmeOfCredit: SISStudyRightElementModel | undefined =
|
||||
studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId) ??
|
||||
studyRightElements
|
||||
.filter(studyRightElement =>
|
||||
dateIsBetween(attainmentDate, studyRightElement.startDate, 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,
|
||||
@@ -81,7 +89,7 @@ const parseCredit = (
|
||||
attainmentDate,
|
||||
courseCode,
|
||||
grade,
|
||||
passed: !Credit.failed(credit) || Credit.passed(credit) || Credit.improved(credit),
|
||||
passed: !CreditModel.failed(credit) || CreditModel.passed(credit) || CreditModel.improved(credit),
|
||||
studentNumber,
|
||||
programmes,
|
||||
credits,
|
||||
@@ -108,9 +116,9 @@ type FormattedEnrollment = {
|
||||
}
|
||||
|
||||
const parseEnrollment = (
|
||||
enrollment: Enrollment,
|
||||
enrollment: EnrollmentModel,
|
||||
anonymizationSalt: string | null,
|
||||
studentNumberToSrElementsMap: Record<string, SISStudyRightElement[]>
|
||||
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>
|
||||
) => {
|
||||
const {
|
||||
studentnumber: studentNumber,
|
||||
@@ -147,13 +155,9 @@ const getYearlyStatsOfNew = async (
|
||||
unification: Unification,
|
||||
anonymizationSalt: string | null,
|
||||
combineSubstitutions: boolean,
|
||||
studentNumberToSrElementsMap: Record<string, SISStudyRightElement[]>
|
||||
studentNumberToSrElementsMap: Record<string, SISStudyRightElementModel[]>
|
||||
) => {
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s.: entered getYearlyStatsOfNew with courseCode ${courseCode} and unification ${unification}`
|
||||
)
|
||||
const courseForSubs = await Course.findOne({
|
||||
const courseForSubs = await CourseModel.findOne({
|
||||
where: { code: courseCode },
|
||||
})
|
||||
|
||||
@@ -169,18 +173,13 @@ const getYearlyStatsOfNew = async (
|
||||
const [credits, enrollments, course] = await Promise.all([
|
||||
getCreditsForCourses(codes, unification),
|
||||
getEnrollmentsForCourses(codes, unification),
|
||||
Course.findOne({
|
||||
CourseModel.findOne({
|
||||
where: {
|
||||
code: courseCode,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s., getYearlyStatsOfNew, courseCode ${courseCode} unification ${unification}: got credits ${credits.length}, enrollments ${enrollments.length} and course ${course ? 'yes' : 'no'}`
|
||||
)
|
||||
|
||||
const counter = new CourseYearlyStatsCounter()
|
||||
|
||||
for (const credit of credits) {
|
||||
@@ -238,21 +237,12 @@ const getYearlyStatsOfNew = async (
|
||||
|
||||
counter.markEnrollmentToGroup(studentNumber, enrollmentDateTime, groupCode, groupName, courseCode, yearCode)
|
||||
})
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s., getYearlyStatsOfNew, code ${courseCode} unification ${unification}: finished looping through credits.`
|
||||
)
|
||||
|
||||
const statistics = counter.getFinalStatistics(anonymizationSalt)
|
||||
const statistics = await counter.getFinalStatistics(anonymizationSalt)
|
||||
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s., getYearlyStatsOfNew, code ${courseCode} unification ${unification}: got final statistics.`
|
||||
)
|
||||
|
||||
let substitutionCourses: Course[] | undefined
|
||||
let substitutionCourses: CourseModel[] | undefined
|
||||
if (combineSubstitutions && courseForSubs?.substitutions && courseForSubs.substitutions.length > 0) {
|
||||
substitutionCourses = await Course.findAll({
|
||||
substitutionCourses = await CourseModel.findAll({
|
||||
where: {
|
||||
code: {
|
||||
[Op.in]: codes,
|
||||
@@ -262,11 +252,6 @@ const getYearlyStatsOfNew = async (
|
||||
})
|
||||
}
|
||||
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s., getYearlyStatsOfNew, code ${courseCode} unification ${unification}: got substitutionCourses ${substitutionCourses?.length} items.`
|
||||
)
|
||||
|
||||
return {
|
||||
...statistics,
|
||||
coursecode: courseCode,
|
||||
@@ -279,7 +264,7 @@ export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unif
|
||||
const maxAttainmentDate = new Date(
|
||||
Math.max(
|
||||
...(
|
||||
await Course.findAll({
|
||||
await CourseModel.findAll({
|
||||
where: {
|
||||
code: {
|
||||
[Op.in]: courseCodes,
|
||||
@@ -294,7 +279,7 @@ export const maxYearsToCreatePopulationFrom = async (courseCodes: string[], unif
|
||||
const attainmentThreshold = new Date(maxAttainmentDate.getFullYear(), 0, 1)
|
||||
attainmentThreshold.setFullYear(attainmentThreshold.getFullYear() - 6)
|
||||
|
||||
const credits = await Credit.findAll({
|
||||
const credits = await CreditModel.findAll({
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: courseCodes,
|
||||
@@ -337,11 +322,11 @@ export const getCourseYearlyStats = async (
|
||||
anonymizationSalt: string | null,
|
||||
combineSubstitutions: boolean
|
||||
) => {
|
||||
const credits = await Credit.findAll({
|
||||
const credits = await CreditModel.findAll({
|
||||
attributes: ['student_studentnumber'],
|
||||
where: { course_code: { [Op.in]: courseCodes } },
|
||||
})
|
||||
const enrollments = await Enrollment.findAll({
|
||||
const enrollments = await EnrollmentModel.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
where: {
|
||||
course_code: {
|
||||
@@ -351,9 +336,6 @@ export const getCourseYearlyStats = async (
|
||||
},
|
||||
})
|
||||
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(`Debugging c.y.s.: got credits ${credits.length} items and enrollments ${enrollments.length} items`)
|
||||
|
||||
const studentNumbers = {}
|
||||
|
||||
credits.forEach(credit => {
|
||||
@@ -365,11 +347,6 @@ export const getCourseYearlyStats = async (
|
||||
|
||||
const studentNumberToSrElementsMap = await getStudentNumberToSrElementsMap(Object.keys(studentNumbers))
|
||||
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s.: got a studentNumberToSrElementsMap of size ${Object.keys(studentNumberToSrElementsMap).length}`
|
||||
)
|
||||
|
||||
const statsRegular = await Promise.all(
|
||||
courseCodes.map(async courseCode => {
|
||||
const unifyStats = await getYearlyStatsOfNew(
|
||||
@@ -401,17 +378,15 @@ export const getCourseYearlyStats = async (
|
||||
})
|
||||
)
|
||||
|
||||
if (serviceProvider === 'fd') logger.info('Debugging c.y.s.: finished creating statsRegular')
|
||||
|
||||
return statsRegular
|
||||
}
|
||||
|
||||
export const getCourseProvidersForCourses = async (codes: string[]) => {
|
||||
return (
|
||||
await Organization.findAll({
|
||||
await OrganizationModel.findAll({
|
||||
attributes: ['code'],
|
||||
include: {
|
||||
model: Course,
|
||||
model: CourseModel,
|
||||
where: {
|
||||
code: {
|
||||
[Op.in]: codes,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { CurriculumPeriod } from '../models'
|
||||
import { CurriculumPeriodModel } from '../models'
|
||||
|
||||
export type CurriculumPeriods = Awaited<ReturnType<typeof getCurriculumPeriods>>
|
||||
|
||||
export const getCurriculumPeriods = async () => {
|
||||
const curriculumPeriods = await CurriculumPeriod.findAll()
|
||||
const curriculumPeriods = await CurriculumPeriodModel.findAll()
|
||||
const result = curriculumPeriods.reduce<
|
||||
Record<string, Pick<CurriculumPeriod, 'id' | 'name' | 'universityOrgId' | 'startDate' | 'endDate'>>
|
||||
Record<string, Pick<CurriculumPeriodModel, 'id' | 'name' | 'universityOrgId' | 'startDate' | 'endDate'>>
|
||||
>((acc, curriculumPeriod) => {
|
||||
const { id, name, universityOrgId, startDate, endDate } = curriculumPeriod
|
||||
acc[id] = { id, name, universityOrgId, startDate, endDate }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CustomPopulationSearch } from '../models/kone'
|
||||
import { CustomPopulationSearchModel } from '../models/kone'
|
||||
|
||||
export const getCustomPopulationSearchesByUser = async (userId: string) => {
|
||||
return CustomPopulationSearch.findAll({
|
||||
return CustomPopulationSearchModel.findAll({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
@@ -9,7 +9,7 @@ export const getCustomPopulationSearchesByUser = async (userId: string) => {
|
||||
}
|
||||
|
||||
export const createCustomPopulationSearch = async (name: string, userId: string, students: string[]) => {
|
||||
return CustomPopulationSearch.create({
|
||||
return CustomPopulationSearchModel.create({
|
||||
name,
|
||||
userId,
|
||||
students,
|
||||
@@ -17,7 +17,7 @@ export const createCustomPopulationSearch = async (name: string, userId: string,
|
||||
}
|
||||
|
||||
export const updateCustomPopulationSearch = async (userId: string, id: string, students: string[]) => {
|
||||
const targetCustomPopulationSearch = await CustomPopulationSearch.findOne({
|
||||
const targetCustomPopulationSearch = await CustomPopulationSearchModel.findOne({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
@@ -34,7 +34,7 @@ export const updateCustomPopulationSearch = async (userId: string, id: string, s
|
||||
}
|
||||
|
||||
export const deleteCustomPopulationSearch = async (userId: string, id: string) => {
|
||||
return CustomPopulationSearch.destroy({
|
||||
return CustomPopulationSearchModel.destroy({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const { createCipheriv, createDecipheriv, randomBytes } = require('crypto')
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||||
|
||||
const { CRYPT_KEY } = require('../config')
|
||||
import { CRYPT_KEY } from '../config'
|
||||
|
||||
const algorithm = 'aes-256-cbc'
|
||||
|
||||
const encrypt = text => {
|
||||
export const encrypt = (text: string) => {
|
||||
if (!CRYPT_KEY) throw Error('Define crypt key in environment variables')
|
||||
const iv = randomBytes(16)
|
||||
const cipher = createCipheriv(algorithm, Buffer.from(CRYPT_KEY, 'hex'), iv)
|
||||
@@ -17,15 +17,14 @@ const encrypt = text => {
|
||||
}
|
||||
}
|
||||
|
||||
const decrypt = text => {
|
||||
export type EncrypterData = { iv: string; encryptedData: string }
|
||||
export const decrypt = (data: EncrypterData) => {
|
||||
if (!CRYPT_KEY) throw Error('Define crypt key in environment variables')
|
||||
const iv = Buffer.from(text.iv, 'hex')
|
||||
const encryptedText = Buffer.from(text.encryptedData, 'hex')
|
||||
const iv = Buffer.from(data.iv, 'hex')
|
||||
const encryptedText = Buffer.from(data.encryptedData, 'hex')
|
||||
const decipher = createDecipheriv(algorithm, Buffer.from(CRYPT_KEY, 'hex'), iv)
|
||||
let decrypted = decipher.update(encryptedText)
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||
|
||||
return decrypted.toString()
|
||||
}
|
||||
|
||||
module.exports = { encrypt, decrypt }
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { ExcludedCourse } from '../models/kone'
|
||||
import { ExcludedCourseModel } from '../models/kone'
|
||||
|
||||
export const addExcludedCourses = async (programmeCode: string, courseCodes: string[], curriculumVersion: string) => {
|
||||
return ExcludedCourse.bulkCreate(
|
||||
export const addExcludedCourses = async (courseCodes: string[], curriculumVersion: string, programmeCode: string) => {
|
||||
return ExcludedCourseModel.bulkCreate(
|
||||
courseCodes.map(courseCode => ({
|
||||
programme_code: programmeCode,
|
||||
curriculum_version: curriculumVersion,
|
||||
@@ -13,11 +13,11 @@ export const addExcludedCourses = async (programmeCode: string, courseCodes: str
|
||||
}
|
||||
|
||||
export const removeExcludedCourses = async (
|
||||
programmeCode: string,
|
||||
courseCodes: string[],
|
||||
curriculumVersion: string
|
||||
curriculumVersion: string,
|
||||
programmeCode: string
|
||||
) => {
|
||||
return ExcludedCourse.destroy({
|
||||
return ExcludedCourseModel.destroy({
|
||||
where: {
|
||||
programme_code: programmeCode,
|
||||
curriculum_version: curriculumVersion,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { groupBy, orderBy } from 'lodash'
|
||||
import moment from 'moment'
|
||||
import { InferAttributes, QueryTypes } from 'sequelize'
|
||||
import { QueryTypes } from 'sequelize'
|
||||
|
||||
import { ProgrammeModule } from '@oodikone/shared/models'
|
||||
import { serviceProvider } from '../../config'
|
||||
import { programmeCodes } from '../../config/programmeCodes'
|
||||
import { dbConnections } from '../../database/connection'
|
||||
import { Organization, ProgrammeModule } from '../../models'
|
||||
import { OrganizationModel } from '../../models'
|
||||
import { CurriculumPeriods, getCurriculumPeriods } from '../curriculumPeriods'
|
||||
|
||||
const { sequelize } = dbConnections
|
||||
@@ -20,18 +21,23 @@ const mapCurriculumPeriodIdToYear = (curriculumPeriodId: string, curriculumPerio
|
||||
return { startDate: new Date('1800-08-01'), endDate: new Date('1801-08-01') }
|
||||
}
|
||||
|
||||
type ProgrammeModuleWithRelevantAttributes = Pick<
|
||||
InferAttributes<ProgrammeModule>,
|
||||
'code' | 'name' | 'degreeProgrammeType' | 'curriculum_period_ids'
|
||||
> & { progId: string }
|
||||
type ProgrammeModuleWithRelevantAttributes = Pick<ProgrammeModule, 'code' | 'name' | 'degreeProgrammeType'> & {
|
||||
curriculumPeriodIds: string[]
|
||||
progId: string
|
||||
}
|
||||
|
||||
// Some programme modules are not directly associated to a faculty (organization).
|
||||
// Some have intermediate organizations, such as department, so the connection must be digged up
|
||||
export const getDegreeProgrammesOfOrganization = async (organizationId: string, onlyCurrentProgrammes: boolean) => {
|
||||
const programmesOfOrganization: Array<Omit<ProgrammeModuleWithRelevantAttributes, 'progId'> & { valid_from: Date }> =
|
||||
const programmesOfOrganization: Array<Omit<ProgrammeModuleWithRelevantAttributes, 'progId'> & { validFrom: Date }> =
|
||||
await sequelize.query(
|
||||
`
|
||||
SELECT code, name, degree_programme_type as "degreeProgrammeType", curriculum_period_ids, valid_from
|
||||
SELECT
|
||||
code,
|
||||
curriculum_period_ids as "curriculumPeriodIds",
|
||||
degree_programme_type as "degreeProgrammeType",
|
||||
name,
|
||||
valid_from as "validFrom"
|
||||
FROM programme_modules
|
||||
WHERE organization_id IN (
|
||||
WITH RECURSIVE cte AS (
|
||||
@@ -65,7 +71,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
|
||||
|
||||
for (const programmeVersions of Object.values(programmesGroupedByCode)) {
|
||||
// Programmes are ordered by valid_from in descending order, so the first one whose valid_from date isn't in the future, is the newest version
|
||||
const newestProgrammeVersion = programmeVersions.find(prog => new Date() >= prog.valid_from)
|
||||
const newestProgrammeVersion = programmeVersions.find(prog => new Date() >= prog.validFrom)
|
||||
if (!newestProgrammeVersion) {
|
||||
continue
|
||||
}
|
||||
@@ -73,7 +79,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
|
||||
|
||||
const yearsOfProgramme = programmeVersions
|
||||
.map(prog =>
|
||||
prog.curriculum_period_ids.map(curriculumPeriodId => {
|
||||
prog.curriculumPeriodIds.map(curriculumPeriodId => {
|
||||
return mapCurriculumPeriodIdToYear(curriculumPeriodId, curriculumPeriods)
|
||||
})
|
||||
)
|
||||
@@ -85,10 +91,10 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
|
||||
if (isRelevantProgramme) {
|
||||
relevantProgrammes.push({
|
||||
code,
|
||||
name,
|
||||
curriculumPeriodIds: programmeVersions.map(prog => prog.curriculumPeriodIds).flat(),
|
||||
degreeProgrammeType,
|
||||
name,
|
||||
progId,
|
||||
curriculum_period_ids: programmeVersions.map(prog => prog.curriculum_period_ids).flat(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -98,7 +104,7 @@ export const getDegreeProgrammesOfOrganization = async (organizationId: string,
|
||||
export type ProgrammesOfOrganization = Awaited<ReturnType<typeof getDegreeProgrammesOfOrganization>>
|
||||
|
||||
export const getDegreeProgrammesOfFaculty = async (facultyCode: string, onlyCurrentProgrammes: boolean) => {
|
||||
const organization = await Organization.findOne({
|
||||
const organization = await OrganizationModel.findOne({
|
||||
attributes: ['id'],
|
||||
where: {
|
||||
code: facultyCode,
|
||||
@@ -111,7 +117,7 @@ export const getDegreeProgrammesOfFaculty = async (facultyCode: string, onlyCurr
|
||||
}
|
||||
|
||||
export const getFacultyCodeById = async (facultyId: string) => {
|
||||
const organization = await Organization.findOne({
|
||||
const organization = await OrganizationModel.findOne({
|
||||
attributes: ['code'],
|
||||
where: {
|
||||
id: facultyId,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
import { NameWithCode } from '../../shared/types'
|
||||
import { DegreeProgrammeType } from '../../types'
|
||||
import { NameWithCode, DegreeProgrammeType } from '@oodikone/shared/types'
|
||||
import { getBasicStats, setBasicStats } from '../analyticsService'
|
||||
import { getBasicStatsForStudytrack } from '../studyProgramme/studyProgrammeBasics'
|
||||
import type { ProgrammesOfOrganization } from './faculty'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user