mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86a3081a1c | ||
|
|
01b39926b6 | ||
|
|
790e3d3afd | ||
|
|
b4fbe1b47d | ||
|
|
0d2701269a | ||
|
|
8c6a634df0 | ||
|
|
a0117c0de1 | ||
|
|
3e12a4df14 | ||
|
|
5aaa343720 | ||
|
|
f2f8d5d6d0 | ||
|
|
ede3748c09 | ||
|
|
6cc230512c | ||
|
|
24e42c3b63 | ||
|
|
c3e222c155 | ||
|
|
1fb81f4bbf | ||
|
|
e3e0a24b9a | ||
|
|
66bed7f88a | ||
|
|
c59b0a3b58 | ||
|
|
37a5b17578 | ||
|
|
16d938a423 | ||
|
|
0132a98733 | ||
|
|
d99f3b326f | ||
|
|
61bc346923 | ||
|
|
358eac4de2 | ||
|
|
863a7ac157 | ||
|
|
0faef48179 | ||
|
|
b29eb0c8e6 | ||
|
|
2f8b38617b | ||
|
|
0489155da7 | ||
|
|
8bf1da6047 | ||
|
|
e0bb37a75a | ||
|
|
27d4962fcb | ||
|
|
6ca346a00a | ||
|
|
d1bbd9966e | ||
|
|
25e21ecb14 | ||
|
|
72cdbcb871 | ||
|
|
1a9759f77f | ||
|
|
b1e65803d3 | ||
|
|
e19697a9c0 | ||
|
|
a584581141 | ||
|
|
b7a98483e4 | ||
|
|
20c197f3a7 | ||
|
|
d07ba795df | ||
|
|
b17b714be2 | ||
|
|
22a5e58fe3 | ||
|
|
9a0fbd50bd | ||
|
|
51572ebeed | ||
|
|
139e253d6a | ||
|
|
0df918b47a | ||
|
|
781bc5da3d | ||
|
|
67297e536c | ||
|
|
dcdacc7187 | ||
|
|
684a90c60c | ||
|
|
0c1a1e81cb | ||
|
|
8c8eb34a05 | ||
|
|
f1ebbeb0e9 | ||
|
|
c176552326 | ||
|
|
7419cc169d | ||
|
|
c21ffe9a74 | ||
|
|
2b15168342 | ||
|
|
3e296e33bd | ||
|
|
37df5276f7 | ||
|
|
7a26501d72 | ||
|
|
561e41cab1 | ||
|
|
4409f60195 | ||
|
|
323aae2600 | ||
|
|
fa28d86638 | ||
|
|
1976d2914d | ||
|
|
52e6a49fd3 | ||
|
|
fc4e48aaa0 | ||
|
|
ae83dc3563 | ||
|
|
2109cfcf87 | ||
|
|
1a3facf680 | ||
|
|
d016cec63c | ||
|
|
74bfceaf72 | ||
|
|
a549ae5095 | ||
|
|
863cb9d134 | ||
|
|
eb2576bfad | ||
|
|
77831b3bcf | ||
|
|
99ff14b13f | ||
|
|
e3c2427885 | ||
|
|
a6e52a690f | ||
|
|
eac4a03ea8 | ||
|
|
def64d5612 | ||
|
|
9ddd42aced | ||
|
|
60aa43d520 | ||
|
|
49be4b4f54 | ||
|
|
2f5c68f439 | ||
|
|
54321f349c | ||
|
|
9aba7ab5c5 | ||
|
|
059ccbd42b | ||
|
|
733cfac1be | ||
|
|
bcf1bca28b | ||
|
|
025aca0c15 | ||
|
|
00c4a6b409 | ||
|
|
9be9509562 | ||
|
|
9e6a92c114 | ||
|
|
178ff11b48 | ||
|
|
e7a1cdf098 | ||
|
|
60f10faf9e | ||
|
|
b31b3543bd | ||
|
|
b4263f29c5 | ||
|
|
6a69a3b98b | ||
|
|
921fa2d1de | ||
|
|
c4724f8c07 | ||
|
|
44a7f8bcee | ||
|
|
a632f3757d | ||
|
|
a84ad67b76 | ||
|
|
29331e13d3 | ||
|
|
0358cb49a6 | ||
|
|
78719ee09a | ||
|
|
e66542fe89 | ||
|
|
ca9d43b4a9 | ||
|
|
286b4a90b4 | ||
|
|
cfa65da634 | ||
|
|
3c81a8cbcd | ||
|
|
444a261261 | ||
|
|
a5928b0dba | ||
|
|
27ab354a70 | ||
|
|
e293eb8471 | ||
|
|
4dcacbb2ce | ||
|
|
122eb29068 | ||
|
|
d5e6b52316 | ||
|
|
601d69cc3d | ||
|
|
e8f73adc81 | ||
|
|
c0187ad18e | ||
|
|
f877e1187d | ||
|
|
3d86be5b9a | ||
|
|
fe9aad8043 | ||
|
|
34d9855782 | ||
|
|
e1213bd768 | ||
|
|
5d6df13d8e | ||
|
|
bf08700807 | ||
|
|
c95b5a9cef | ||
|
|
cd7766982b | ||
|
|
02932036fe | ||
|
|
7b3bea1f9c | ||
|
|
09dec983aa | ||
|
|
3e8ac19af1 | ||
|
|
93aacd3e16 | ||
|
|
29108ca2b2 | ||
|
|
bfa14c9959 | ||
|
|
836f797da9 | ||
|
|
45ca6f0083 | ||
|
|
2b44529ed4 | ||
|
|
e27d162921 | ||
|
|
7ebddf076f | ||
|
|
d0283bbc34 | ||
|
|
facbb2da44 | ||
|
|
cafc09f805 | ||
|
|
c3a61ab7b3 | ||
|
|
2a0c39ab05 | ||
|
|
c83646512e | ||
|
|
82c6181cd5 | ||
|
|
cc5f5e01c6 | ||
|
|
7e04951e08 | ||
|
|
5cabca6311 | ||
|
|
96b181d3af | ||
|
|
72acdf9a3b | ||
|
|
0b29b6aa58 | ||
|
|
c272f1fde3 | ||
|
|
7174ed55c1 | ||
|
|
f958ba6701 | ||
|
|
93264fef4d | ||
|
|
a4baf8ae6d | ||
|
|
f3aa59fe1f | ||
|
|
322f19826a | ||
|
|
2a2388cbf6 | ||
|
|
d2c283c1b9 | ||
|
|
3d84fb6a74 | ||
|
|
6a53e30a94 | ||
|
|
f8616ba1c4 | ||
|
|
a87c35069d | ||
|
|
fe87b0b960 | ||
|
|
1a8b88dd0e | ||
|
|
b09cb8d574 | ||
|
|
630df92e9d | ||
|
|
948808ce11 | ||
|
|
30e822b3f6 | ||
|
|
2769d0999e | ||
|
|
86a1ef7b9f | ||
|
|
b4b6d45463 | ||
|
|
dc7ad404cb | ||
|
|
dec0087de0 | ||
|
|
61f91d1cfe | ||
|
|
c11f905a2e | ||
|
|
b2e433a79b | ||
|
|
1e7160dcf5 | ||
|
|
da973fe74a | ||
|
|
309d3128e4 | ||
|
|
4add189096 | ||
|
|
f5ddf6291b | ||
|
|
3989e0da94 | ||
|
|
3035b846a4 | ||
|
|
c88aa37b68 | ||
|
|
884191f352 | ||
|
|
35af223c32 | ||
|
|
71e639efa1 | ||
|
|
546c683c8b | ||
|
|
1c5c7781a5 | ||
|
|
99b08020db | ||
|
|
8c2efa9199 | ||
|
|
7c50c80b15 | ||
|
|
9290fbf32b | ||
|
|
ffaa3eb05b | ||
|
|
77b159739f | ||
|
|
0e63b970f7 | ||
|
|
7bedbe6451 | ||
|
|
dacf5cc51b | ||
|
|
de5fdb7eaf | ||
|
|
c35a632469 | ||
|
|
691b6d6cb3 | ||
|
|
c8a69a15b4 | ||
|
|
665b95a6f8 | ||
|
|
d6ab10bed2 | ||
|
|
a7fb7eda97 | ||
|
|
44ce8ac298 | ||
|
|
7312416e2b | ||
|
|
d967bce32b | ||
|
|
4716299771 | ||
|
|
1036b47523 | ||
|
|
317fb6f655 | ||
|
|
d6d804d288 | ||
|
|
8b3c408700 | ||
|
|
7a94c92181 | ||
|
|
2feeaddb8b | ||
|
|
6d9c4b0b0a | ||
|
|
c9abbf958a | ||
|
|
47692de1ce | ||
|
|
ad6260a313 | ||
|
|
298e43a89a | ||
|
|
055d3b4eac | ||
|
|
5a831cdc86 | ||
|
|
c23ee470d8 | ||
|
|
59b4aad1ab | ||
|
|
de3e0f5e6b | ||
|
|
7ede62760e | ||
|
|
d2fdeeb830 | ||
|
|
220952153f | ||
|
|
bace3c80af | ||
|
|
d9457bf855 | ||
|
|
2d6d12f95f | ||
|
|
93a3014209 | ||
|
|
3d6bc23658 | ||
|
|
1906827760 | ||
|
|
4b8f389dd3 | ||
|
|
5d69a9b53b | ||
|
|
5c0c267606 | ||
|
|
00c7e45672 | ||
|
|
b28d43c7f8 | ||
|
|
c0f15c9dd9 | ||
|
|
64240c9f6f | ||
|
|
cbbcdd0e40 | ||
|
|
dc7a6805a7 | ||
|
|
0321dbed62 | ||
|
|
88a46a8f23 | ||
|
|
5c26f04436 | ||
|
|
e80fa25fb3 | ||
|
|
12f11183cf | ||
|
|
abfefa8c3e | ||
|
|
eb82eee286 | ||
|
|
5ff9b3b788 | ||
|
|
f4d73b649f | ||
|
|
4136c0d3b5 | ||
|
|
2b8cf76053 | ||
|
|
e23c60cb38 | ||
|
|
676804b770 | ||
|
|
2785742934 | ||
|
|
f82638763f | ||
|
|
7253ba1e17 | ||
|
|
e2847a68ea | ||
|
|
687e231e65 | ||
|
|
0629614ed8 | ||
|
|
843e04f154 | ||
|
|
d7c3423a3f | ||
|
|
8be238d4cc | ||
|
|
ecbaa35802 | ||
|
|
732e14cb2d | ||
|
|
e98ef1b94f | ||
|
|
e254de4df3 | ||
|
|
1d169ee70a | ||
|
|
0380cab715 | ||
|
|
4391c06f00 | ||
|
|
36a3044b16 | ||
|
|
db08330cb9 | ||
|
|
0a071ee071 | ||
|
|
2354b696a5 | ||
|
|
f6db0f2fe8 | ||
|
|
e40c219e78 | ||
|
|
2e7ca4d960 | ||
|
|
c3fd874559 | ||
|
|
80768b4d15 | ||
|
|
7de955398c | ||
|
|
2510903d63 | ||
|
|
ef3aa1faf2 | ||
|
|
7444567d64 | ||
|
|
a559dab709 | ||
|
|
34feb51d7b | ||
|
|
a206a1a9b3 | ||
|
|
9a18de83f9 | ||
|
|
d1723e7ad6 | ||
|
|
e1adc24ccd | ||
|
|
1668f9581b | ||
|
|
58b0b97e3a | ||
|
|
90479e5fbf | ||
|
|
7461ace443 | ||
|
|
7490395f99 | ||
|
|
a16195d47a | ||
|
|
c4084faf89 | ||
|
|
09a9958272 | ||
|
|
e4325f67bd | ||
|
|
91145e3a88 | ||
|
|
f4920de7d2 | ||
|
|
d2f96b4657 | ||
|
|
21d252f1c7 | ||
|
|
06f63110b9 | ||
|
|
9bbd67f7a3 | ||
|
|
fb465ec17e | ||
|
|
76f06a6481 | ||
|
|
929c022bf8 | ||
|
|
d32aba88fc | ||
|
|
e9d482f99c | ||
|
|
496fd6bb15 | ||
|
|
016d8da641 | ||
|
|
37d12ac65f | ||
|
|
1b42b51e6e | ||
|
|
36230033ef | ||
|
|
dd1e143367 | ||
|
|
d44ee6738f | ||
|
|
1e8c6a2c79 | ||
|
|
3833720a22 | ||
|
|
90ae26903e | ||
|
|
b68806e6ed | ||
|
|
3a030a163d | ||
|
|
cd726716f5 | ||
|
|
627cd52b5e | ||
|
|
01afe1607c | ||
|
|
fd497422f8 | ||
|
|
fdcc445086 | ||
|
|
36d7b3f7f3 | ||
|
|
b8d8e7df87 | ||
|
|
7c2f2344b8 | ||
|
|
33691621d8 | ||
|
|
93ed56dd38 | ||
|
|
54f47b8607 | ||
|
|
db2649be6f | ||
|
|
17a07de1f0 | ||
|
|
3aa6892bff | ||
|
|
92405c3561 | ||
|
|
cfc2658078 | ||
|
|
209feebb5d | ||
|
|
0fb7a94eb1 | ||
|
|
e80c0aaf85 | ||
|
|
8dda45da48 | ||
|
|
e5a85500b3 | ||
|
|
a4b8844c77 | ||
|
|
7696e5bcd8 | ||
|
|
486561789b | ||
|
|
28fc185a2a | ||
|
|
db59d0e138 | ||
|
|
1687f0a0c6 | ||
|
|
6ac6ad687e | ||
|
|
5e1e71b559 | ||
|
|
66b5e9a65d | ||
|
|
30b1a31bb3 | ||
|
|
d6765bd7d6 | ||
|
|
5ce3337977 | ||
|
|
c5e24c7409 | ||
|
|
34f029da9e | ||
|
|
e95414806c | ||
|
|
456b420321 | ||
|
|
0eb87ea44a | ||
|
|
454fabeb1d | ||
|
|
d3f022adc5 | ||
|
|
8efe3d2aa3 | ||
|
|
d3003d5797 | ||
|
|
0dd5f32f96 | ||
|
|
85b307b306 | ||
|
|
73eadbd1b1 | ||
|
|
b294c560e4 | ||
|
|
455d9dc532 | ||
|
|
df1049a436 | ||
|
|
ac22df8ced | ||
|
|
1fd987221d | ||
|
|
45985f2c44 | ||
|
|
159e11a508 | ||
|
|
5ff68533d7 | ||
|
|
61f20173a3 | ||
|
|
987f1ec42b | ||
|
|
3f4c39600b | ||
|
|
88ef490b1b | ||
|
|
dbc8b684c2 | ||
|
|
7e135d4643 | ||
|
|
06a8760701 | ||
|
|
5eaa035f1d | ||
|
|
ae1f0ceeb7 | ||
|
|
3ca82286b4 | ||
|
|
63add43c0f | ||
|
|
72440609b4 | ||
|
|
c946cd2405 | ||
|
|
96d316ebf5 | ||
|
|
b3eb7dceac | ||
|
|
ee02d002ba | ||
|
|
34448d27d0 | ||
|
|
39ce64edce | ||
|
|
0d210a607b | ||
|
|
f1a6302928 | ||
|
|
359db8f365 | ||
|
|
387a3e0ef2 | ||
|
|
4c957b347b | ||
|
|
10d3d5aeed | ||
|
|
6e365d4a23 | ||
|
|
68292a45d0 | ||
|
|
f0088cf7a0 | ||
|
|
b8469a242e | ||
|
|
8c5d5f1ee9 | ||
|
|
80759cacee | ||
|
|
4df2cc78ff | ||
|
|
8b34f7a5f9 | ||
|
|
e81d6eb877 | ||
|
|
0c47d7c21c | ||
|
|
175996235d | ||
|
|
42409d555e | ||
|
|
23b1a02d42 | ||
|
|
1be53368c6 | ||
|
|
52e0d28a4d | ||
|
|
83114a26f9 | ||
|
|
bdc09f86e6 | ||
|
|
6dd9679267 | ||
|
|
5fb7168d17 | ||
|
|
6ac4b6fb3d | ||
|
|
1a8de4344c | ||
|
|
1493725699 | ||
|
|
6cff94e2ca | ||
|
|
d51a70e259 | ||
|
|
7f84c0b9f0 | ||
|
|
df555ca93f | ||
|
|
b68b98b62c | ||
|
|
640dc741a4 | ||
|
|
5d02b839ee | ||
|
|
ae60a38445 | ||
|
|
83d4bd26c4 | ||
|
|
481b351276 | ||
|
|
23cb8c8ee5 | ||
|
|
9bcdd81772 | ||
|
|
53e443142d | ||
|
|
da72107eb9 | ||
|
|
d2cbb89eb9 | ||
|
|
3bf0c60378 | ||
|
|
59a9854ed5 | ||
|
|
b70bc09359 | ||
|
|
8ef82d01b1 | ||
|
|
ac683175a8 | ||
|
|
5e86967627 | ||
|
|
d971bb993f | ||
|
|
2328f2deff | ||
|
|
ddeb65abef | ||
|
|
0871d8ecbd | ||
|
|
3b8aec2fef | ||
|
|
71dc06728f | ||
|
|
9484ecb456 | ||
|
|
25319d1c8f | ||
|
|
e7fd54b443 | ||
|
|
aed2bc154b | ||
|
|
09a991669c | ||
|
|
41325bfa12 | ||
|
|
17a1d3718f | ||
|
|
8b9c5b8789 | ||
|
|
e11609c335 | ||
|
|
3a5b3c2d65 | ||
|
|
33916312b5 | ||
|
|
dab8a2679f | ||
|
|
390334c820 | ||
|
|
356ffc953e | ||
|
|
a2f3a5de61 | ||
|
|
635a3fbcbc | ||
|
|
7ebcb86924 | ||
|
|
eb1781e6af | ||
|
|
6df983642b | ||
|
|
634c4c9a97 | ||
|
|
9371c7d2ab | ||
|
|
f6678739ad | ||
|
|
2882dd25c6 | ||
|
|
ed3166d08f | ||
|
|
39340108f2 | ||
|
|
94f037b6af | ||
|
|
090e81a432 | ||
|
|
6e8ae9b320 | ||
|
|
0db094655e | ||
|
|
c437ed4e96 | ||
|
|
ed5a778e50 | ||
|
|
13633ce11a | ||
|
|
eab47657e6 | ||
|
|
35267e8b6d | ||
|
|
15cc9cef4a | ||
|
|
f5e1e7267f | ||
|
|
c6eaa18a1f | ||
|
|
63e16e4332 | ||
|
|
074e41de59 | ||
|
|
46b6686f4b | ||
|
|
ede94708d5 | ||
|
|
f20953c8d2 | ||
|
|
1e728593ea | ||
|
|
21f7fcc6d2 | ||
|
|
20c026aa56 | ||
|
|
24eb824163 | ||
|
|
2065afe852 | ||
|
|
d60480d1ae | ||
|
|
1be0f9090e | ||
|
|
f9dad0a183 | ||
|
|
597f1f0c0b | ||
|
|
c26728d24a | ||
|
|
4c1e11692a | ||
|
|
132a21e113 | ||
|
|
324fcb71a9 | ||
|
|
55111201e6 | ||
|
|
067040d92f | ||
|
|
0d857d716a | ||
|
|
ec9b0b22b0 | ||
|
|
32cf9ed185 | ||
|
|
1702e086a3 | ||
|
|
8246818a94 | ||
|
|
e1ee00a331 | ||
|
|
6e22cb5e58 | ||
|
|
f4ebb3d547 | ||
|
|
5683fbc3cb | ||
|
|
0c78f1c7e2 | ||
|
|
5051bbdded | ||
|
|
c7b86856fb | ||
|
|
afcf72e29f | ||
|
|
a83482179a | ||
|
|
1e7a198dec | ||
|
|
41646985e4 | ||
|
|
80f6c7b1fb | ||
|
|
cc9465f141 | ||
|
|
d56c472e77 | ||
|
|
68e5daae13 | ||
|
|
b259f30093 | ||
|
|
804f5df55f | ||
|
|
96ec7000fa |
@@ -98,7 +98,7 @@ jobs:
|
||||
run: docker cp frontend:/opt/app-root/src/build ./build
|
||||
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v1
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_URL: https://toska.cs.helsinki.fi/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
run: docker cp backend:/opt/app-root/src/dist ./dist
|
||||
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v1
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_URL: https://toska.cs.helsinki.fi/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,3 +70,4 @@ environments/
|
||||
|
||||
# Target of shared symlink
|
||||
services/**/src/shared
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ const relativeFilePaths = files => [...files.map(file => path.relative(cwd, file
|
||||
module.exports = {
|
||||
'{services,updater}/**/*.{js,jsx,ts,tsx}': files =>
|
||||
`eslint --fix ${files.join(' ')} --report-unused-disable-directives`,
|
||||
'services/backend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/backend/tsconfig.json',
|
||||
'services/frontend/**/*.{ts,tsx}': () => 'npx tsc --noEmit --project services/frontend/tsconfig.json',
|
||||
'*.{js,jsx,ts,tsx,json,md,yml,yaml,html,css}': files => `prettier --write ${files.join(' ')}`,
|
||||
'*.css': files => `stylelint --fix ${files.join(' ')}`,
|
||||
Dockerfile: files =>
|
||||
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
|
||||
sis-db:
|
||||
<<: *defaults
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
shm_size: 2gb
|
||||
|
||||
sis-importer-db:
|
||||
@@ -28,7 +28,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-importer-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
|
||||
user-db:
|
||||
<<: *defaults
|
||||
@@ -37,4 +37,4 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const openCompletedCoursesModal = () => {
|
||||
cy.get('button').contains('Search completed courses of students').click()
|
||||
cy.contains('button', 'Search completed courses of students').click()
|
||||
}
|
||||
|
||||
const hasLanded = () => {
|
||||
@@ -15,22 +15,40 @@ const openSearch = () => {
|
||||
cy.init('/completedcoursessearch')
|
||||
hasLanded()
|
||||
}
|
||||
|
||||
const selectSavedCourselist = name => {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(name).type('{enter}')
|
||||
cy.get('[data-cy=history-search]').click()
|
||||
cy.get('[data-cy=history-search]').type(name)
|
||||
cy.get('[data-cy=history-search]').type('{downarrow}')
|
||||
cy.get('[data-cy=history-search]').type('{enter}')
|
||||
}
|
||||
|
||||
const deleteAllSearches = () => {
|
||||
cy.contains('Saved courselists')
|
||||
.get('.dropdown')
|
||||
.then(dropdown => {
|
||||
const searchItems = dropdown.find('div[role=option] > span[class=text]')
|
||||
for (let i = 0; i < searchItems.length; i++) {
|
||||
if (searchItems[i].textContent.includes('TEST-')) {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(searchItems[i].textContent).type('{enter}')
|
||||
cy.get('button').contains('Delete').click()
|
||||
const createCourseList = (courseCodes, courseListName) => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy=course-list-input]').type(courseCodes.join('\n'))
|
||||
cy.get('[data-cy=search-name]').type(courseListName)
|
||||
cy.get('[data-cy=save-courselist]').click()
|
||||
}
|
||||
|
||||
const generateCourseListName = () => `TEST-course-list-${new Date().getTime()}`
|
||||
|
||||
const deleteAllPreviousSearches = () => {
|
||||
cy.get('[data-cy=history-search]').click()
|
||||
|
||||
cy.get('.MuiAutocomplete-popper li').then($options => {
|
||||
if ($options.length > 0) {
|
||||
cy.wrap($options).each(($option, index, $list) => {
|
||||
cy.wrap($option).click()
|
||||
cy.get('[data-cy=delete-courselist]').click()
|
||||
if (index < $list.length - 1) {
|
||||
cy.get('[data-cy=history-search]').click()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
cy.contains('You have no previous searches.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('When search modal is opened', () => {
|
||||
@@ -39,8 +57,7 @@ describe('When search modal is opened', () => {
|
||||
hasLanded()
|
||||
cy.url().should('include', '/completedcoursessearch')
|
||||
cy.contains('Search completed courses of students')
|
||||
cy.get('[data-cy="open-completed-courses-modal-button"]').click()
|
||||
deleteAllSearches()
|
||||
cy.get('[data-cy=open-completed-courses-modal-button]').click()
|
||||
})
|
||||
|
||||
it('Modal opens correctly', () => {
|
||||
@@ -49,26 +66,33 @@ describe('When search modal is opened', () => {
|
||||
openCompletedCoursesModal()
|
||||
cy.contains('Search completed courses of students')
|
||||
cy.contains('Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.')
|
||||
cy.contains('Insert one or more courses, separated by a space, a newline, or a comma.')
|
||||
cy.contains('Insert one or more courses, separated by a space, a newline, a comma, or a semicolon.')
|
||||
cy.contains('Insert name for this course list if you wish to save it')
|
||||
})
|
||||
|
||||
it('Modal gets the correct course codes and student numbers from the URL', () => {
|
||||
cy.init('/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&studentList=433237&studentList=457144')
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.contains('[data-cy=student-no-input]', '433237, 457144')
|
||||
cy.contains('[data-cy=course-list-input]', 'TKT10001, TKT10002')
|
||||
})
|
||||
|
||||
describe('When a search is executed with invalid input', () => {
|
||||
it('Returns empty table and notification', () => {
|
||||
cy.init('/completedcoursessearch')
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy="student-no-input"]').type('1')
|
||||
cy.get('[data-cy="course-list-input"]').type('1')
|
||||
cy.get('[data-cy="completed-courses-search-button"]').click()
|
||||
cy.get('[data-cy="rights-notification"]').should(
|
||||
cy.get('[data-cy=student-no-input]').type('1')
|
||||
cy.get('[data-cy=course-list-input]').type('1')
|
||||
cy.get('[data-cy=completed-courses-search-button]').click()
|
||||
cy.get('[data-cy=rights-notification]').should(
|
||||
'contain.text',
|
||||
'The following students information could not be displayed'
|
||||
'The information for the following students could not be displayed'
|
||||
)
|
||||
cy.get('[data-cy="rights-notification"]').should('contain.text', '1')
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', 'Completed courses search')
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', 'Student number')
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('not.contain', '1')
|
||||
cy.contains('[data-cy=rights-notification]', '1')
|
||||
cy.contains('[data-cy=completed-courses-table-div]', 'Student number')
|
||||
cy.contains('[data-cy=completed-courses-table-div]', '1').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,9 +102,9 @@ describe('When search modal is opened', () => {
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
|
||||
cy.get('[data-cy="student-no-input"]').type(studentSet1.join('\n'))
|
||||
cy.get('[data-cy="course-list-input"]').type(coursesSet1.join('\n'))
|
||||
cy.get('[data-cy="completed-courses-search-button"]').click()
|
||||
cy.get('[data-cy=student-no-input]').type(studentSet1.join('\n'))
|
||||
cy.get('[data-cy=course-list-input]').type(coursesSet1.join('\n'))
|
||||
cy.get('[data-cy=completed-courses-search-button]').click()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,63 +119,61 @@ describe('When search modal is opened', () => {
|
||||
|
||||
it('Finds correct students and courses', () => {
|
||||
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[0])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[1])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[2])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[3])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', studentSet1[4])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[0])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[1])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[2])
|
||||
cy.get('[data-cy="completed-courses-table-div"]').should('contain.text', coursesSet1[3])
|
||||
for (const studentNumber of studentSet1) {
|
||||
cy.contains('[data-cy=completed-courses-table-div]', studentNumber)
|
||||
}
|
||||
for (const courseCode of coursesSet1) {
|
||||
cy.contains('[data-cy=completed-courses-table-div]', courseCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const courseListNames = ['TEST-courselist1', 'TEST-courselist2']
|
||||
|
||||
describe('Courselist saving-related functions work', () => {
|
||||
beforeEach(() => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy="course-list-input"]').type('CSM14204\nTKT10004')
|
||||
cy.get('[data-cy="search-name"]').type(courseListNames[0])
|
||||
cy.get('button').contains('Save').click()
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('[data-cy=history-search]').length > 0) {
|
||||
deleteAllPreviousSearches()
|
||||
} else {
|
||||
cy.contains('You have no previous searches.')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('Course list can be saved', () => {
|
||||
selectSavedCourselist(courseListNames[0])
|
||||
const courseList = generateCourseListName()
|
||||
const courses = ['CSM14204', 'TKT10004']
|
||||
createCourseList(courses, courseList)
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseListNames[0])
|
||||
cy.get('[data-cy="course-list-input"]').contains('TKT10004').contains('CSM14204')
|
||||
selectSavedCourselist(courseList)
|
||||
cy.contains('[data-cy=course-list-input]', courses.join(', '))
|
||||
})
|
||||
|
||||
it('Course list can be deleted', () => {
|
||||
const courseList = generateCourseListName()
|
||||
createCourseList(['CSM14204', 'TKT10004'], courseList)
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy="search-name"]').type(courseListNames[1])
|
||||
cy.get('button').contains('Save').click()
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseListNames[1])
|
||||
cy.get('button').contains('Delete').click()
|
||||
cy.get('[data-cy="history-search"]')
|
||||
.type(courseListNames[1])
|
||||
.type('{enter}')
|
||||
.should('not.contain', courseListNames[1])
|
||||
selectSavedCourselist(courseList)
|
||||
cy.get('[data-cy=delete-courselist]').click()
|
||||
cy.contains('You have no previous searches.')
|
||||
})
|
||||
|
||||
it('Course list can be updated', () => {
|
||||
const courseList = generateCourseListName()
|
||||
createCourseList(['CSM14204', 'TKT10004'], courseList)
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseListNames[0])
|
||||
cy.get('[data-cy="course-list-input"]').type(',TKT10001')
|
||||
cy.get('button').contains('Save').click()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.get('[data-cy=course-list-input]').type(',TKT10001')
|
||||
cy.get('[data-cy=save-courselist]').click()
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseListNames[0])
|
||||
cy.get('[data-cy="course-list-input"]').contains('TKT10001')
|
||||
selectSavedCourselist(courseList)
|
||||
cy.contains('[data-cy=course-list-input]', 'CSM14204, TKT10004, TKT10001')
|
||||
})
|
||||
})
|
||||
|
||||
+660
-487
File diff suppressed because it is too large
Load Diff
+228
-276
@@ -5,331 +5,283 @@ const path = require('path')
|
||||
const timestamp = new Date().toLocaleDateString('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' }) // YYYY-MM-DD, taking the local time zone into account
|
||||
const downloadsFolder = Cypress.config('downloadsFolder')
|
||||
|
||||
describe('Faculty overview', () => {
|
||||
describe('Faculty can be selected', () => {
|
||||
it('Faculties are listed and one can be chosen', () => {
|
||||
describe('Faculty statistics', () => {
|
||||
describe('Faculty list', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.get('[data-cy=select-faculty]').contains('a', 'Teologinen tiedekunta')
|
||||
cy.contains('a', 'H99').should('not.exist')
|
||||
})
|
||||
|
||||
it('contains faculty names and faculty codes', () => {
|
||||
cy.contains('Faculties')
|
||||
cy.contains('Teologinen tiedekunta')
|
||||
cy.contains('H10')
|
||||
cy.contains('Oikeustieteellinen tiedekunta')
|
||||
cy.contains('H20')
|
||||
cy.contains('Lääketieteellinen tiedekunta')
|
||||
cy.contains('H30')
|
||||
cy.contains('Humanistinen tiedekunta')
|
||||
cy.contains('H40')
|
||||
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
|
||||
cy.contains('H50')
|
||||
cy.contains('Farmasian tiedekunta')
|
||||
cy.contains('H55')
|
||||
cy.contains('Bio- ja ympäristötieteellinen tiedekunta')
|
||||
cy.contains('H57')
|
||||
cy.contains('Kasvatustieteellinen tiedekunta')
|
||||
cy.contains('H60')
|
||||
cy.contains('Valtiotieteellinen tiedekunta')
|
||||
cy.contains('H70')
|
||||
cy.contains('Svenska social- och kommunalhögskolan')
|
||||
cy.contains('H74')
|
||||
cy.contains('Maatalous-metsätieteellinen tiedekunta')
|
||||
cy.contains('H80')
|
||||
cy.contains('Eläinlääketieteellinen tiedekunta')
|
||||
cy.contains('H90')
|
||||
})
|
||||
|
||||
it('contains a working link to faculty page', () => {
|
||||
cy.contains('Faculties')
|
||||
cy.contains('a', 'Teologinen tiedekunta').click()
|
||||
cy.contains('.header', 'Teologinen tiedekunta')
|
||||
cy.location('pathname').should('eq', '/faculties/hy-org-1000000580')
|
||||
cy.contains('Teologinen tiedekunta')
|
||||
cy.contains('H10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Basic information: admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties', 'admin')
|
||||
cy.contains('a', 'Eläinlääketieteellinen tiedekunta').click()
|
||||
describe('Basic information tab', () => {
|
||||
describe('Admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties', 'admin')
|
||||
cy.contains('a', 'Eläinlääketieteellinen tiedekunta').click()
|
||||
})
|
||||
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.cs('faculty-tabs').should('contain', 'Basic information')
|
||||
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
|
||||
cy.cs('faculty-tabs').should('contain', 'Graduation times')
|
||||
cy.cs('faculty-tabs').should('contain', 'Update statistics')
|
||||
})
|
||||
})
|
||||
|
||||
it('Credits produced by faculty are shown', () => {
|
||||
cy.get('[data-cy="Section-creditsProducedByTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
})
|
||||
describe('Basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
})
|
||||
|
||||
it('Update statistics tab is shown', () => {
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Update statistics')
|
||||
})
|
||||
})
|
||||
describe('Info boxes', () => {
|
||||
it('Students of the faculty', () => {
|
||||
cy.cs('students-of-the-faculty-info-box-button').click()
|
||||
cy.cs('students-of-the-faculty-info-box-content').contains('Taulukon luvut on')
|
||||
})
|
||||
|
||||
describe('Basic information: basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
})
|
||||
it('Graduated of the faculty', () => {
|
||||
cy.cs('graduated-of-the-faculty-info-box-button').click()
|
||||
cy.cs('graduated-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
|
||||
})
|
||||
|
||||
it('Basic information tab show all graphs and tables', () => {
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-ThesisWritersOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-StudentsOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-GraduatedOfTheFaculty"]').should('be.visible')
|
||||
})
|
||||
it('Thesis writers of the faculty', () => {
|
||||
cy.cs('thesis-writers-of-the-faculty-info-box-button').click()
|
||||
cy.cs('thesis-writers-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
|
||||
})
|
||||
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Basic information')
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Graduation times')
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('contain', 'Programmes and student populations')
|
||||
cy.get('[data-cy="FacultySegmentContainer"]').should('not.contain', 'Update statistics')
|
||||
})
|
||||
it('Credits produced by the faculty', () => {
|
||||
cy.cs('credits-produced-by-the-faculty-info-box-button').click()
|
||||
cy.cs('credits-produced-by-the-faculty-info-box-content').contains('Sisältää opintopisteet')
|
||||
})
|
||||
})
|
||||
|
||||
it('Toggle years works', () => {
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('contain', '2022')
|
||||
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('contain', '2022')
|
||||
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('contain', '2022')
|
||||
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('contain', '2022')
|
||||
cy.get('[data-cy="YearToggle"]').click()
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="Table-ThesisWritersOfTheFaculty"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="Table-StudentsOfTheFaculty"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="Table-GraduatedOfTheFaculty"]').should('contain', '2022 - 2023')
|
||||
})
|
||||
it('All graphs and tables are shown', () => {
|
||||
cy.cs('students-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('graduated-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('thesis-writers-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('credits-produced-by-the-faculty-stacked-bar-chart-section').should('be.visible')
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('be.visible')
|
||||
})
|
||||
|
||||
it('Toggle programmes works', () => {
|
||||
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('not.exist')
|
||||
cy.get('[data-cy="ProgrammeToggle"]').click()
|
||||
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('be.visible')
|
||||
})
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.cs('faculty-tabs').should('contain', 'Basic information')
|
||||
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
|
||||
cy.cs('faculty-tabs').should('contain', 'Graduation times')
|
||||
cy.cs('faculty-tabs').should('not.contain', 'Update statistics')
|
||||
})
|
||||
|
||||
it('Students of the faculty infobox works', () => {
|
||||
cy.get('[data-cy="studentsOfTheFaculty-info-content"]').should('not.exist')
|
||||
cy.get('[data-cy="studentsOfTheFaculty-open-info"]').click()
|
||||
cy.get('[data-cy="studentsOfTheFaculty-info-content"]').should('be.visible')
|
||||
cy.get('[data-cy="studentsOfTheFaculty-close-info"]').click()
|
||||
cy.get('[data-cy="studentsOfTheFaculty-info-content"]').should('not.exist')
|
||||
})
|
||||
it('Toggle years works', () => {
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('year-toggle').click()
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
})
|
||||
|
||||
it('Data can be exported to Excel files', { retries: 3 }, () => {
|
||||
const sections = [
|
||||
'StudentsOfTheFaculty',
|
||||
'GraduatedOfTheFaculty',
|
||||
'ThesisWritersOfTheFaculty',
|
||||
'CreditsProducedByTheFaculty',
|
||||
]
|
||||
sections.forEach(section => {
|
||||
cy.get(`[data-cy="Graph-${section}"]`).within(() => {
|
||||
cy.get('[aria-label="View chart menu, Chart"]').click({ force: true })
|
||||
cy.contains('li.highcharts-menu-item', 'Download XLS').click()
|
||||
const downloadedFile = `oodikone_${section}_H50_${timestamp}.xls`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
it('Toggle programmes works', () => {
|
||||
cy.cs('faculty-programmes-shown-info').should('not.exist')
|
||||
cy.cs('programme-toggle').click()
|
||||
cy.cs('faculty-programmes-shown-info').should('be.visible')
|
||||
})
|
||||
|
||||
it('Toggle study rights works', () => {
|
||||
cy.cs('faculty-exclude-specials-info').should('not.exist')
|
||||
cy.cs('study-right-toggle').click()
|
||||
cy.cs('faculty-exclude-specials-info').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Data can be exported to Excel files', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
const sections = [
|
||||
'StudentsOfTheFaculty',
|
||||
'GraduatedOfTheFaculty',
|
||||
'ThesisWritersOfTheFaculty',
|
||||
'CreditsProducedByTheFaculty',
|
||||
]
|
||||
sections.forEach(section => {
|
||||
cy.cs(`${section}Graph`).within(() => {
|
||||
cy.get('[aria-label="View chart menu, Chart"]').click({ force: true })
|
||||
cy.contains('li.highcharts-menu-item', 'Download XLS').click()
|
||||
const downloadedFile = `oodikone_${section}_H50_${timestamp}.xls`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Study programme information: users with access', () => {
|
||||
describe('Students by starting year tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Maatalous-metsätieteellinen tiedekunta').click()
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Students by starting year').click()
|
||||
})
|
||||
|
||||
it('Study programme credit information is not visible in the beginning', () => {
|
||||
cy.get('table[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
|
||||
it('Export button', { retries: 2 }, () => {
|
||||
cy.cs('faculty-student-table-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_programme_stats_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it('Study programme credit information can be toggled', () => {
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
|
||||
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-0"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-3"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Button-Hide-CreditsProducedByTheFaculty-0"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-3"]').should('be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-4"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-5"]').should('not.be.visible')
|
||||
it('Info box', () => {
|
||||
cy.cs('faculty-student-table-info-box-button').click()
|
||||
cy.cs('faculty-student-table-info-box-content').contains('Opiskelijat, joiden')
|
||||
})
|
||||
|
||||
it('Graph stays open when sorted', () => {
|
||||
cy.get('[data-cy="Table-CreditsProducedByTheFaculty"]').should('be.visible')
|
||||
cy.get('[data-cy="Button-Show-CreditsProducedByTheFaculty-0"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
|
||||
cy.get('[data-cy="Menu-CreditsProducedByTheFaculty-Total"]').click()
|
||||
cy.get('[data-cy="Cell-CreditsProducedByTheFaculty-0"]').should('be.visible')
|
||||
it.skip('Study right toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Graduated toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Hoverable country list', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Expandable rows', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Population links', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it('Percentage toggle works', () => {
|
||||
cy.cs('faculty-student-stats-table').should('be.visible')
|
||||
cy.cs('faculty-student-stats-table').should('not.contain', '92.3 %')
|
||||
cy.cs('percentage-toggle').click()
|
||||
cy.cs('faculty-student-stats-table').should('contain', '92.3 %')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Average graduation times', () => {
|
||||
describe('Progress tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Progress').click()
|
||||
})
|
||||
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.cs('FacultyProgress-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_progress_tab_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it.skip('Info boxes', () => {
|
||||
cy.cs('FacultyProgress-info-box-button').click()
|
||||
cy.cs('FacultyProgress-info-box-content').contains('Kuvaa tiedekuntaan')
|
||||
// TODO: Click open the accordion first
|
||||
cy.cs('BachelorMastersProgress-info-box-button').click()
|
||||
cy.cs('BachelorMastersProgress-info-box-content').contains('The starting year is the')
|
||||
})
|
||||
|
||||
it.skip('Progress bar charts exist', () => {
|
||||
// TODO: Click open the accordion first
|
||||
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyBachelorMastersProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyMastersProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Progress tables exist', () => {
|
||||
// TODO: Click open the accordion first
|
||||
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
|
||||
cy.cs('FacultyBachelorMasterProgressTable').should('be.visible')
|
||||
cy.cs('FacultyMastersProgressTable').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Graduated toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Study right toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graduation times tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Graduation times').click()
|
||||
})
|
||||
|
||||
it('User can view graduation graphs', () => {
|
||||
cy.get('[data-cy="Section-AverageGraduationTimes"]').should('be.visible')
|
||||
cy.get('[data-cy="Section-bachelor"]').should('be.visible')
|
||||
cy.get('[data-cy="Section-master"]').should('be.visible')
|
||||
cy.get('[data-cy="Section-master"]').within(() => {
|
||||
cy.contains('text.highcharts-axis-title', 'Graduation year')
|
||||
cy.contains('.message', "Click a bar to view that year's programme level breakdown").should('be.visible')
|
||||
})
|
||||
})
|
||||
|
||||
it('Graphs display data', () => {
|
||||
cy.get('[data-cy="Section-bachelor"]').within(() => {
|
||||
cy.get('div[class="faculty-breakdown-graph"]')
|
||||
})
|
||||
|
||||
cy.get('[data-cy="GraduationTimeToggle"]').click()
|
||||
|
||||
cy.get('[data-cy="Section-bachelor"]').within(() => {
|
||||
cy.get('div[class="faculty-graph"]')
|
||||
cy.contains('44 graduated').trigger('mouseover')
|
||||
cy.contains('44 students graduated in year 2023')
|
||||
cy.contains('median study time: 37.5 months')
|
||||
cy.contains('21 graduated on time')
|
||||
cy.contains('11 graduated max year overtime')
|
||||
cy.contains('12 graduated over year late')
|
||||
|
||||
cy.contains('44 graduated').click()
|
||||
cy.contains('Year 2023 by graduation year')
|
||||
cy.get('div[class="programmes-graph"]').should('be.visible')
|
||||
cy.get('div[class="programmes-graph"]').within(() => {
|
||||
cy.contains('MAT')
|
||||
cy.get('[aria-label="MAT, 36."]').trigger('mouseover')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('KH50_001')
|
||||
cy.contains('26 students graduated in year 2023')
|
||||
cy.contains('median study time: 36 months')
|
||||
cy.contains('14 graduated on time')
|
||||
cy.contains('6 graduated max year overtime')
|
||||
cy.contains('6 graduated over year late')
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy="Section-master"]').within(() => {
|
||||
cy.get('div[class="faculty-graph"]')
|
||||
cy.contains('4 graduated').trigger('mouseover')
|
||||
cy.contains('4 students graduated in year 2023')
|
||||
cy.contains('median study time: 23 months')
|
||||
cy.contains('3 graduated on time')
|
||||
cy.contains('0 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
|
||||
cy.contains('4 graduated').click()
|
||||
cy.contains('Year 2023 by graduation year')
|
||||
cy.get('div[class="programmes-graph"]').should('be.visible')
|
||||
cy.get('div[class="programmes-graph"]').within(() => {
|
||||
cy.contains('MAST')
|
||||
cy.get('[aria-label="MAST, 23."]').trigger('mouseover')
|
||||
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
cy.contains('MH50_001')
|
||||
cy.contains('3 students graduated in year 2023')
|
||||
cy.contains('median study time: 23 months')
|
||||
cy.contains('2 graduated on time')
|
||||
cy.contains('0 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Graduation times grouping and time types can be toggled', () => {
|
||||
cy.get('[data-cy="GraduationTimeToggle"]').click()
|
||||
cy.get('[data-cy="GroupByToggle"]').click()
|
||||
cy.get('[data-cy="Section-master"]').should('be.visible')
|
||||
|
||||
cy.get('[data-cy="Section-bachelor"]').within(() => {
|
||||
cy.get('div[class="faculty-graph"]')
|
||||
cy.contains('24 graduated').trigger('mouseover')
|
||||
cy.contains('From class of 2020 - 2021, 24/37 students have graduated')
|
||||
})
|
||||
|
||||
cy.get('[data-cy="GraduationTimeToggle"]').click()
|
||||
cy.get('[data-cy="Section-bachelor"]').within(() => {
|
||||
cy.get('[aria-label="2018 - 2019, 29. On time."]').click()
|
||||
cy.get('div[class="programmes-breakdown-graph"]').should('be.visible')
|
||||
cy.get('div[class="programmes-breakdown-graph"]').within(() => {
|
||||
cy.contains('Year 2018 - 2019 by start year')
|
||||
cy.contains('MAT')
|
||||
cy.get('[aria-label="MAT, 20. Max. year overtime."]').trigger('mouseover')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('KH50_001')
|
||||
cy.contains('Max. year overtime: 20')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Data can be exported to an Excel file', () => {
|
||||
cy.get('[data-cy="DownloadButton-AverageGraduationTimes"]').click()
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.cs('average-graduation-times-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_graduation_times_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Faculty Progress and student population tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/faculties')
|
||||
cy.contains('a', 'Matemaattis-luonnontieteellinen tiedekunta').click()
|
||||
cy.contains('Programmes and student populations').click()
|
||||
it('Info boxes', () => {
|
||||
cy.cs('average-graduation-times-info-box-button').click()
|
||||
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset')
|
||||
})
|
||||
|
||||
it('User can view studentdata table without %', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', '0 %')
|
||||
it.skip('Study times toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it('User can view studentdata table with %', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="HidePercentagesToggle"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('contain', '0 %')
|
||||
it.skip('Year toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it('Percentage Graphs exists', () => {
|
||||
cy.get('[data-cy="Graph-FacultyBachelorsProgress"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-FacultyBachelorMastersProgress"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-FacultyMastersProgress"]').should('be.visible')
|
||||
cy.get('[data-cy="Graph-FacultyBachelorsProgress"]').should('be.visible')
|
||||
it.skip('Study programme toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it('Graduations can be excluded', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="StudentToggle"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('Transfers can be excluded', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="GraduatedToggle"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('Progress tables exists', () => {
|
||||
cy.get('[data-cy="FacultyBachelorsProgressTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorMasterProgressTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyMastersProgressTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorsProgressTable"]').should('be.visible')
|
||||
})
|
||||
|
||||
it('Progress tables can be toggled', () => {
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-5"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Button-Show-FacultyBachelorsProgressTable-5"]').click()
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-0"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-1"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-2"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-3"]').should('not.be.visible')
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-5"]').should('be.visible')
|
||||
cy.get('[data-cy="Button-Hide-FacultyBachelorsProgressTable-5"]').click()
|
||||
cy.get('[data-cy="Cell-FacultyBachelorsProgressTable-5"]').should('not.be.visible')
|
||||
})
|
||||
|
||||
it('Studentstats table can be toggled', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', 'TKT')
|
||||
cy.get('[data-cy="Button-FacultyStudentStatsTable-6"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('contain', 'TKT')
|
||||
cy.get('[data-cy="Button-FacultyStudentStatsTable-6"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', 'TKT')
|
||||
})
|
||||
|
||||
it('Progress infobox can be toggled', () => {
|
||||
cy.get('[data-cy="InfoFacultyProgress-info-content"]').should('not.exist')
|
||||
cy.get('[data-cy="InfoFacultyProgress-open-info"]').click()
|
||||
cy.get('[data-cy="InfoFacultyProgress-info-content"]').should('be.visible')
|
||||
cy.get('[data-cy="InfoFacultyProgress-close-info"]').click()
|
||||
cy.get('[data-cy="InfoFacultyProgress-info-content"]').should('not.exist')
|
||||
it('Graphs are visible', () => {
|
||||
cy.cs('bachelor-graduation-times-section').should('be.visible')
|
||||
// ! No test data for bachelor + master
|
||||
cy.cs('master-graduation-times-section').should('be.visible')
|
||||
cy.cs('doctor-graduation-times-section').should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+83
-35
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('When language center is opened', () => {
|
||||
cy.get('table > tbody > tr:first').within(() => {
|
||||
cy.get('td').should('have.length', 15)
|
||||
cy.get('td').eq(0).contains('All courses total')
|
||||
const numbers = [36, 9, 34, 755, 15, 40, 6, 48, 1, 24, 1059, 49, 0, 2076]
|
||||
const numbers = [36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0, 2076]
|
||||
numbers.forEach((number, index) => {
|
||||
cy.get('td')
|
||||
.eq(index + 1)
|
||||
@@ -76,12 +76,12 @@ describe('When language center is opened', () => {
|
||||
|
||||
it('Faculties tab "exceeding" button works', () => {
|
||||
cy.get('[data-cy="difference-button"]').click()
|
||||
checkNumbers([...new Array(12).fill(0), 1, 227], 15)
|
||||
checkNumbers([...new Array(12).fill(0), 1, 221], 15)
|
||||
})
|
||||
|
||||
it('Faculties tab semester selector changes numbers', () => {
|
||||
chooseSemester('Syksy 2020', 'from')
|
||||
checkNumbers([28, 6, 28, 495, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
|
||||
checkNumbers([28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,86 +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().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().contains('2020–2023').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(true)
|
||||
})
|
||||
cy.get('[data-cy=curriculum-picker]').click().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.
|
||||
@@ -196,121 +117,249 @@ 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/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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Students', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Students (27)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
|
||||
})
|
||||
})
|
||||
|
||||
it("'General tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('522142')
|
||||
cy.contains('Tilastotiede')
|
||||
cy.contains('Matematiikka')
|
||||
cy.contains('Teoreettisten ja laskennallisten menetelmien maisteriohjelma')
|
||||
cy.contains('Todistusvalinta')
|
||||
})
|
||||
})
|
||||
|
||||
it("'Courses tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Courses').click()
|
||||
cy.contains('MAT12001')
|
||||
cy.contains('MAT21001')
|
||||
})
|
||||
})
|
||||
|
||||
it("'Modules tab' Displays correct modules based on the selected programme", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Modules').click()
|
||||
cy.contains('MAT011')
|
||||
cy.contains('MAT110')
|
||||
})
|
||||
cy.contains('Courses of class').click()
|
||||
cy.cs('curriculum-picker').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.contains('Courses of class').click()
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('MAT011').should('not.exist')
|
||||
cy.contains('MAT110')
|
||||
})
|
||||
})
|
||||
|
||||
it.skip("Empty 'tags' tab has a link to the page where tags can be created", () => {
|
||||
// TODO: This fails in the pipeline, but works locally. Investigate.
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Tags').click()
|
||||
cy.contains('No tags defined. You can define them here.').find('a').click()
|
||||
})
|
||||
cy.url().should('include', '/study-programme/KH50_001?tab=4')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Create new tag')
|
||||
cy.cs('create-button').should('be.disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -328,7 +377,9 @@ describe('Population statistics tests', () => {
|
||||
cy.contains(nonExisting).should('not.exist')
|
||||
cy.contains('button', 'Check student numbers').click()
|
||||
cy.contains('Check for student numbers')
|
||||
cy.get('textarea').type(existing).type('{enter}').type(nonExisting)
|
||||
cy.get('textarea').type(existing)
|
||||
cy.get('textarea').type('{enter}')
|
||||
cy.get('textarea').type(nonExisting)
|
||||
cy.contains('button', 'Check students').click()
|
||||
cy.contains('#checkstudentsresults', 'Results').within(() => {
|
||||
cy.contains('Student numbers in list and in Sisu').click()
|
||||
|
||||
+107
-78
@@ -1,20 +1,24 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const student = {
|
||||
firstnames: 'Varpu Roope',
|
||||
lastname: 'Mårtensson',
|
||||
studentnumber: '550003',
|
||||
sis_person_id: 'hy-hlo-115926826',
|
||||
firstNames: 'Varpu Roope',
|
||||
lastName: 'Mårtensson',
|
||||
studentNumber: '550003',
|
||||
sisPersonId: 'hy-hlo-115926826',
|
||||
email: 'sisutestidata134902@testisisudata.fi',
|
||||
}
|
||||
|
||||
const typeToSearch = text => {
|
||||
cy.cs('student-search').type(text)
|
||||
}
|
||||
|
||||
const typeStudentNumberAndClick = studentNumber => {
|
||||
cy.get('.prompt').type(studentNumber)
|
||||
cy.contains('td a', studentNumber).click()
|
||||
typeToSearch(studentNumber)
|
||||
cy.contains('td', studentNumber).click()
|
||||
}
|
||||
|
||||
describe('Students tests', () => {
|
||||
describe('when using basic user', () => {
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init()
|
||||
cy.contains('Students').click()
|
||||
@@ -23,94 +27,112 @@ describe('Students tests', () => {
|
||||
})
|
||||
|
||||
it('Students search form is usable', () => {
|
||||
cy.get('.prompt').type(student.lastname)
|
||||
typeToSearch(student.lastName)
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name').should('not.exist')
|
||||
cy.contains('First names').should('not.exist')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.firstnames)
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name')
|
||||
cy.contains('First names')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains(student.lastName)
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name').should('not.exist')
|
||||
cy.contains('First names').should('not.exist')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
})
|
||||
|
||||
it('Search term must be at least 4 characters long', () => {
|
||||
cy.get('.prompt').type(student.lastname.slice(0, 3))
|
||||
cy.contains('No search results or search term is not accurate enough')
|
||||
cy.get('.prompt').type(student.lastname.slice(3))
|
||||
typeToSearch(student.lastName.slice(0, 3))
|
||||
cy.contains('Search term is not accurate enough')
|
||||
typeToSearch(student.lastName.slice(3))
|
||||
cy.get('table tbody tr').should('have.length', 1)
|
||||
})
|
||||
|
||||
it('can search with studentnumber too', () => {
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains(student.studentnumber)
|
||||
it('Can search with student number too', () => {
|
||||
typeToSearch(student.studentNumber)
|
||||
cy.contains(student.studentNumber)
|
||||
})
|
||||
|
||||
it('Can get student specific page by clicking student', () => {
|
||||
cy.get('.prompt').type(student.studentnumber)
|
||||
cy.contains('td a', student.studentnumber).click()
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma (01.08.2020–31.07.2027)')
|
||||
cy.contains(student.lastname).should('not.exist')
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.email).should('not.exist')
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.lastname)
|
||||
cy.contains(student.firstnames)
|
||||
cy.contains(student.lastName)
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains(student.email)
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.lastname).should('not.exist')
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.email).should('not.exist')
|
||||
})
|
||||
|
||||
it("'Update student' button is not shown", () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('div.ui.fluid.card').within(() => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('button', 'Update student').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('Can get back to search menu', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.go('back')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
cy.contains('Credits').should('not.exist')
|
||||
})
|
||||
|
||||
it('Can jump to course', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('Tilastollinen päättely I (MAT12004)')
|
||||
.parent()
|
||||
.siblings()
|
||||
.last()
|
||||
.within(() => {
|
||||
cy.get('.level').click()
|
||||
cy.get('a').click()
|
||||
})
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT12004 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=sisulink] > a')
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('sisu-link')
|
||||
.should('have.attr', 'href')
|
||||
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sis_person_id}/basic/basic-info`)
|
||||
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sisPersonId}/basic/basic-info`)
|
||||
})
|
||||
|
||||
it('Semester enrollments can be toggled', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
const programmes = [
|
||||
'Oikeustieteen tohtoriohjelma',
|
||||
'Matemaattisten tieteiden kandiohjelma',
|
||||
'Oikeustieteen maisterin koulutusohjelmaOikeusnotaarin koulutusohjelma',
|
||||
]
|
||||
cy.get('div.ui.fluid.card').within(() => {
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('Enrollments').click()
|
||||
cy.get('table tbody tr')
|
||||
.should('have.length', 3)
|
||||
@@ -122,52 +144,57 @@ describe('Students tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Searching with bad inputs doesnt yield results', () => {
|
||||
cy.get('.prompt').type('SWAG LITTINEN')
|
||||
it("Searching with bad name doesn't yield results", () => {
|
||||
typeToSearch('SWAG LITTINEN')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
})
|
||||
|
||||
cy.get('.prompt').clear().type('01114')
|
||||
it("Searching with bad student number doesn't yield results", () => {
|
||||
typeToSearch('01114')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
})
|
||||
|
||||
it('Can jump to population page', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.contains('.ui.table', 'Completed').within(() => {
|
||||
cy.get('i.level.up.alternate.icon').click()
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('study-rights-section').within(() => {
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
})
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('class size 30 students')
|
||||
})
|
||||
|
||||
it('Grade graph works in all three different modes', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.contains('a.item', 'Grade graph').click()
|
||||
cy.contains('text.highcharts-title', 'Grade plot')
|
||||
cy.contains('a.item', 'Show group mean').click()
|
||||
cy.get('.labeled.input').within(() => {
|
||||
cy.contains('.label', 'Group size')
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('button', 'Grade graph').click()
|
||||
cy.contains('button', 'Show group mean').click()
|
||||
cy.cs('group-size-input').within(() => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').should('have.value', '5')
|
||||
})
|
||||
cy.contains('.highcharts-container text', "Nov '22")
|
||||
cy.contains('.highcharts-container text', "Jul '22").should('not.exist')
|
||||
cy.contains('text.highcharts-title', 'Grade plot')
|
||||
cy.get('.labeled.input').within(() => {
|
||||
cy.contains('.label', 'Group size')
|
||||
cy.get('input').clear().type('10')
|
||||
cy.cs('group-size-input').within(() => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').clear()
|
||||
cy.get('input').type('10')
|
||||
cy.get('input').should('have.value', '10')
|
||||
})
|
||||
cy.contains('.highcharts-container text', "Jul '22")
|
||||
cy.contains('.highcharts-container text', "Nov '22").should('not.exist')
|
||||
cy.contains('a.item', 'Show semester mean').click()
|
||||
cy.contains('button', 'Show semester mean').click()
|
||||
cy.contains('.highcharts-container text', "Jan '22")
|
||||
})
|
||||
|
||||
describe('Bachelor Honours section', () => {
|
||||
it("Shows 'Qualified for Honours' tag and main modules info when the student is qualified", () => {
|
||||
cy.visit('/students/495976')
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.green.tag.label', 'Qualified for Honours')
|
||||
cy.contains('Main courses and other modules').click()
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-qualified]', 'Qualified for Honours')
|
||||
cy.contains('Study modules').click()
|
||||
cy.get('[data-cy=main-modules] tbody tr')
|
||||
.should('have.length', 3)
|
||||
.each(($tr, index) => {
|
||||
@@ -186,54 +213,54 @@ describe('Students tests', () => {
|
||||
|
||||
it("Shows 'Did not graduate in time' when the student has graduated but not in time", () => {
|
||||
cy.visit('/students/540355')
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.red.tag.label', 'Not qualified for Honours')
|
||||
cy.contains('.red.tag.label', 'Did not graduate in time')
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
|
||||
cy.contains('[data-cy=honours-chip-error]', 'Did not graduate in time')
|
||||
})
|
||||
|
||||
it("Shows 'Module grades too low' when the student has graduated in time but has too low grades", () => {
|
||||
cy.visit('/students/547934')
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.red.tag.label', 'Not qualified for Honours')
|
||||
cy.contains('.red.tag.label', 'Module grades too low')
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
|
||||
cy.contains('[data-cy=honours-chip-error]', 'Module grades too low')
|
||||
})
|
||||
|
||||
it("Shows 'Might need further inspection' when the student has graduated in time but has more than four main modules", () => {
|
||||
cy.visit('/students/478837')
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.blue.tag.label', 'Might need further inspection')
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-inspection]', 'Might need further inspection')
|
||||
})
|
||||
|
||||
it("Shows 'Has not graduated' when the student has not graduated", () => {
|
||||
cy.visit(`students/${student.studentnumber}`)
|
||||
cy.contains('.divider h4', 'Bachelor Honours')
|
||||
cy.contains('.red.tag.label', 'Not qualified for Honours')
|
||||
cy.contains('.red.tag.label', 'Has not graduated')
|
||||
cy.visit(`students/${student.studentNumber}`)
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
|
||||
cy.contains('[data-cy=honours-chip-error]', 'Has not graduated')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Use admin to see all students
|
||||
describe('when using admin user', () => {
|
||||
describe('When using admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/students', 'admin')
|
||||
})
|
||||
|
||||
it('Does not crash if student has no studyright or courses', () => {
|
||||
it('Does not crash if student has no study rights or courses', () => {
|
||||
typeStudentNumberAndClick('450730')
|
||||
cy.contains('Credits: 0')
|
||||
})
|
||||
|
||||
it("'Update student' button is shown", () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('div.ui.fluid.card').within(() => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('button', 'Update student')
|
||||
})
|
||||
})
|
||||
|
||||
it('Bachelor Honours section is not shown for students outside of Faculty of Science', () => {
|
||||
cy.visit('/students/453146')
|
||||
cy.contains('.divider h4', 'Bachelor Honours').should('not.exist')
|
||||
cy.contains('h2', 'Bachelor Honours').should('not.exist')
|
||||
})
|
||||
|
||||
it('When a study plan is selected, courses included in the study plan are highlighted with a blue background', () => {
|
||||
@@ -241,9 +268,11 @@ describe('Students tests', () => {
|
||||
cy.contains('table tbody tr', 'Kulttuurien tutkimuksen kandiohjelma (01.08.2020–30.06.2023)').within(() => {
|
||||
cy.get('td').eq(0).click()
|
||||
})
|
||||
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)')
|
||||
.should('have.attr', 'style')
|
||||
.and('equal', 'background-color: rgb(232, 244, 255);')
|
||||
cy.contains('table tbody tr', 'Kandidaatintutkielma (KUKA-LIS222)').should(
|
||||
'have.css',
|
||||
'background-color',
|
||||
'rgb(232, 244, 255)'
|
||||
)
|
||||
})
|
||||
|
||||
it('When a study plan is selected, the time frame of the credit graph is updated', () => {
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('Study guidance group tests', () => {
|
||||
cy.cs('Students (3)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
|
||||
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
|
||||
expect(tabNames).to.deep.equal(['General', 'Courses', 'Progress'])
|
||||
expect(tabNames).to.deep.equal(['General', 'Courses', 'Modules', 'Progress'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -141,7 +141,7 @@ describe('Study guidance group tests', () => {
|
||||
'Transferred from',
|
||||
'Admission type',
|
||||
'Gender',
|
||||
'Citizenship',
|
||||
'Citizenships',
|
||||
'Curriculum period',
|
||||
'Latest attainment date',
|
||||
'Tags',
|
||||
|
||||
+212
-152
@@ -1,17 +1,52 @@
|
||||
/* eslint-disable cypress/unsafe-to-chain-command */
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const { getEmptyYears } = require('../support/commands')
|
||||
|
||||
const deleteTag = name => {
|
||||
cy.contains('td', name).siblings().contains('Delete').click()
|
||||
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.contains('Confirm').click()
|
||||
cy.contains('td', name).should('not.exist')
|
||||
cy.cs('confirm-delete-tag-button').click()
|
||||
}
|
||||
|
||||
describe('Study programme overview', () => {
|
||||
/* Basic information overview -tests */
|
||||
describe('Basic information view works for basic user', () => {
|
||||
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 })
|
||||
@@ -19,11 +54,11 @@ describe('Study programme overview', () => {
|
||||
|
||||
// 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]')
|
||||
cy.cs('students-of-the-study-programme-section')
|
||||
cy.cs('credits-produced-by-the-study-programme-section')
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
|
||||
cy.cs('programmes-before-or-after-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
})
|
||||
|
||||
it('Basic information contains correct students', () => {
|
||||
@@ -40,7 +75,7 @@ describe('Study programme overview', () => {
|
||||
[2017, 41, 47, 0, 0, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudentsOfTheStudyProgramme')
|
||||
cy.checkTableStats(tableContents, 'students-of-the-study-programme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct credits', () => {
|
||||
@@ -56,11 +91,11 @@ describe('Study programme overview', () => {
|
||||
[2017, 1211, 1211, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyProgramme')
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
it("Toggling 'Show special categories' on displays additional information", () => {
|
||||
cy.contains('Show special categories').click()
|
||||
cy.cs('special-categories-toggle').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0]),
|
||||
@@ -73,7 +108,7 @@ describe('Study programme overview', () => {
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyProgramme')
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct thesis writers and graduates', () => {
|
||||
@@ -81,20 +116,20 @@ describe('Study programme overview', () => {
|
||||
const tableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 26, 0],
|
||||
[2022, 48, 0],
|
||||
[2021, 47, 66],
|
||||
[2020, 12, 18],
|
||||
[2019, 1, 15],
|
||||
[2023, 26, 5],
|
||||
[2022, 48, 13],
|
||||
[2021, 47, 76],
|
||||
[2020, 12, 23],
|
||||
[2019, 1, 19],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'GraduatedAndThesisWritersOfTheProgramme')
|
||||
cy.checkTableStats(tableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
it('Special study rights can be excluded and basic data changes accordingly', () => {
|
||||
cy.get('[data-cy=StudentToggle]').click()
|
||||
cy.cs('study-right-toggle').click()
|
||||
const years = getEmptyYears()
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated]
|
||||
@@ -108,25 +143,25 @@ describe('Study programme overview', () => {
|
||||
[2017, 41, 47, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyProgramme')
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const graduatedTableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 24, 0],
|
||||
[2022, 44, 0],
|
||||
[2021, 46, 60],
|
||||
[2020, 11, 17],
|
||||
[2019, 1, 15],
|
||||
[2023, 24, 5],
|
||||
[2022, 44, 12],
|
||||
[2021, 46, 69],
|
||||
[2020, 11, 22],
|
||||
[2019, 1, 18],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(graduatedTableContents, 'GraduatedAndThesisWritersOfTheProgramme')
|
||||
cy.checkTableStats(graduatedTableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
it('Year can be changed to academic year, and data changes accordingly', () => {
|
||||
cy.get('[data-cy=YearToggle]').click({ force: true })
|
||||
cy.cs('year-toggle').click()
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
const studentTableContents = [
|
||||
@@ -140,7 +175,7 @@ describe('Study programme overview', () => {
|
||||
['2018 - 2019', 40, 45, 0, 0, 0],
|
||||
['2017 - 2018', 41, 47, 0, 0, 0],
|
||||
]
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyProgramme')
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const creditTableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
@@ -153,33 +188,33 @@ describe('Study programme overview', () => {
|
||||
['2017 - 2018', 2350, 2345, 0, 5, 26],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyProgramme')
|
||||
cy.get('[data-cy=YearToggle]').click()
|
||||
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
|
||||
cy.cs('year-toggle').click()
|
||||
})
|
||||
|
||||
it('Basic information graphs render', () => {
|
||||
cy.get('[data-cy=Graph-StudentsOfTheStudyProgramme')
|
||||
cy.cs('students-of-the-study-programme-line-graph-section')
|
||||
.should('contain', 'Started studying')
|
||||
.should('contain', 'Accepted')
|
||||
.should('contain', 'Graduated')
|
||||
.should('contain', 'Transferred away')
|
||||
.should('contain', 'Transferred to')
|
||||
|
||||
cy.get('[data-cy=Graph-CreditsProducedByTheStudyProgramme')
|
||||
cy.cs('credits-produced-by-the-study-programme-stacked-bar-chart-section')
|
||||
.should('contain', 'Degree students')
|
||||
.should('contain', 'Transferred')
|
||||
.should('contain', 5796)
|
||||
.should('contain', 428)
|
||||
|
||||
cy.get('[data-cy=Graph-GraduatedAndThesisWritersOfTheProgramme')
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-bar-chart-section')
|
||||
.should('contain', 'Graduated students')
|
||||
.should('contain', 'Wrote thesis')
|
||||
.should('contain', 47)
|
||||
.should('contain', 66)
|
||||
.should('contain', 76)
|
||||
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
|
||||
cy.cs('bachelor-breakdown-bar-chart-section')
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2022')
|
||||
cy.contains('48 graduated').trigger('mouseover')
|
||||
@@ -190,7 +225,7 @@ describe('Study programme overview', () => {
|
||||
cy.contains('14 graduated over year late')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=Graph-ProgrammesBeforeOrAfter')
|
||||
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
|
||||
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
|
||||
.should('contain', 'Datatieteen maisteriohjelma')
|
||||
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
@@ -205,11 +240,11 @@ describe('Study programme overview', () => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
|
||||
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownMaster]')
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
|
||||
cy.cs('bachelor-breakdown-bar-chart-section')
|
||||
cy.cs('master-breakdown-bar-chart-section')
|
||||
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get('[data-cy=graduation-times-graphMaster]').within(() => {
|
||||
cy.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')
|
||||
@@ -221,7 +256,7 @@ describe('Study programme overview', () => {
|
||||
cy.contains('0 graduated over year late')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
|
||||
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Bachelor + master study right')
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2023')
|
||||
@@ -235,39 +270,38 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/* Study track overview tests */
|
||||
describe('Study track overview works for basic user', () => {
|
||||
describe('Study tracks and class statistics tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Study tracks and class statistics').click()
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
})
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('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('Study tracks and class statistics', () => {
|
||||
cy.cs('Section-studyTrackOverview')
|
||||
cy.cs('Section-studyTrackProgress')
|
||||
cy.cs('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, 0],
|
||||
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 1],
|
||||
['2022 - 2023', 26, 25, 0, 0, 24, 2, 19, 7, 0, 25, 1],
|
||||
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 35, 3],
|
||||
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 1],
|
||||
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 34, 1],
|
||||
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
|
||||
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 3],
|
||||
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 35, 2],
|
||||
['2018 - 2019', 45, 40, 0, 0, 6, 39, 26, 19, 0, 44, 1],
|
||||
['2017 - 2018', 47, 41, 0, 0, 5, 42, 31, 16, 0, 47, 0],
|
||||
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 222, 7],
|
||||
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 224, 12],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudyTrackOverview')
|
||||
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
it('Years in the students table can be expanded and study track data will be shown', () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
@@ -276,7 +310,7 @@ describe('Study programme overview', () => {
|
||||
|
||||
const dataForStudyTracks = [
|
||||
['Ekonometria, MAT-EKO', 2, 2, 0, 0, 0, 2, 1, 1, 0, 2, 0],
|
||||
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 1],
|
||||
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
|
||||
['Tietojenkäsittelyteoria, MAT-TIE', 2, 1, 0, 0, 1, 1, 2, 0, 0, 2, 0],
|
||||
['Tilastotiede, MAT-TIL', 4, 4, 0, 0, 0, 4, 1, 3, 0, 4, 0],
|
||||
]
|
||||
@@ -302,7 +336,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('Links to class statistics page work', () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
@@ -315,7 +349,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('Links to class statistics page with all years combined work', { retries: 2 }, () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
|
||||
cy.get('td.total-row-cell a').click()
|
||||
})
|
||||
|
||||
@@ -324,7 +358,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
@@ -357,20 +391,20 @@ describe('Study programme overview', () => {
|
||||
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudyTrackProgress')
|
||||
cy.checkTableStats(tableContents, 'study-track-progress-basic')
|
||||
})
|
||||
|
||||
it('Study track overview graphs render', () => {
|
||||
cy.get('[data-cy=Graph-StudyTrackProgress]')
|
||||
cy.cs('study-track-progress-bar-chart')
|
||||
.should('contain', 'Less than 30 credits')
|
||||
.should('contain', '30–60 credits')
|
||||
.should('contain', 'At least 180 credits')
|
||||
.should('contain', '49.8%') // The percentage for total, at least 180 credits, to check that the graph renders
|
||||
|
||||
cy.get('[data-cy=Graph-StudyTrackProgress]').contains('49.8%').trigger('mouseover', { force: true })
|
||||
cy.cs('study-track-progress-bar-chart').contains('49.8%').trigger('mouseover', { force: true })
|
||||
cy.contains('At least 180 credits: 114')
|
||||
|
||||
cy.get("[data-cy='Section-KH50_001']").within(() => {
|
||||
cy.cs('KH50_001-section').within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.get('[aria-label="2020 - 2021, 15. On time."]').trigger('mouseover')
|
||||
@@ -385,9 +419,9 @@ describe('Study programme overview', () => {
|
||||
cy.contains('On time: 14')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
|
||||
cy.get("[data-cy='Section-KH50_001']").within(() => {
|
||||
cy.cs('KH50_001-section').within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('19 graduated').trigger('mouseover')
|
||||
@@ -406,24 +440,22 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
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'
|
||||
)
|
||||
cy.cs('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, 1],
|
||||
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 16, 1],
|
||||
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
|
||||
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 17, 1],
|
||||
['2018 - 2019', 23, 21, 0, 0, 2, 21, 11, 12, 0, 23, 0],
|
||||
['2017 - 2018', 28, 24, 0, 0, 1, 27, 15, 13, 0, 28, 0],
|
||||
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 87, 2],
|
||||
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 88, 4],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'StudyTrackOverview')
|
||||
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
|
||||
cy.get('tbody tr.regular-row')
|
||||
.eq(2)
|
||||
.within(() => {
|
||||
@@ -445,7 +477,7 @@ describe('Study programme overview', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('Average graduation times are displayed correctly', () => {
|
||||
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')
|
||||
@@ -454,8 +486,8 @@ describe('Study programme overview', () => {
|
||||
cy.contains('Graduated On time: 9 students')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get("[data-cy='graduation-times-graphBachelor']").within(() => {
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('bachelor-median-time-bar-chart').within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('12 graduated').trigger('mouseover')
|
||||
@@ -469,17 +501,16 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/* Programme courses -tests */
|
||||
describe('Programme courses works for basic user', () => {
|
||||
describe('Programme courses tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Programme courses').click()
|
||||
cy.cs('programme-courses-tab').click()
|
||||
})
|
||||
|
||||
it('content loads', () => {
|
||||
cy.get('[data-cy=CoursesYearFilter]')
|
||||
cy.get('[data-cy=CourseTabs]')
|
||||
cy.cs('CoursesYearFilter')
|
||||
cy.cs('CourseTabs')
|
||||
})
|
||||
|
||||
it('time range selection works', () => {
|
||||
@@ -492,8 +523,8 @@ describe('Study programme overview', () => {
|
||||
cy.get('td').eq(2).contains('75')
|
||||
})
|
||||
})
|
||||
cy.get('[data-cy=fromYear]').click().contains('2018').click()
|
||||
cy.get('[data-cy=toYear').click().contains('2019').click()
|
||||
cy.cs('fromYear').click().contains('2018').click()
|
||||
cy.cs('toYear').click().contains('2019').click()
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
@@ -507,9 +538,9 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it("'Calendar year/Academic year' toggle works", () => {
|
||||
cy.get('[data-cy=calendarAcademicYearToggle]').click()
|
||||
cy.get('[data-cy=fromYear]').contains('2017-2018')
|
||||
cy.get('[data-cy=toYear').contains('2023-2024')
|
||||
cy.cs('calendarAcademicYearToggle').click()
|
||||
cy.cs('fromYear').contains('2017-2018')
|
||||
cy.cs('toYear').contains('2023-2024')
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
@@ -522,14 +553,14 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('contains correct courses in alphabetical order', () => {
|
||||
cy.get('[data-cy=CoursesSortableTable]').within(() => {
|
||||
cy.cs('CoursesSortableTable').within(() => {
|
||||
cy.get('tr').eq(1).contains('Advanced calculus')
|
||||
cy.get('tr').eq(-1).contains('Äidinkielen opinnot')
|
||||
})
|
||||
})
|
||||
|
||||
it('different sorting options work', () => {
|
||||
cy.get('[data-cy=CoursesSortableTable]').within(() => {
|
||||
cy.cs('CoursesSortableTable').within(() => {
|
||||
// Course code
|
||||
cy.get('th').eq(0).click()
|
||||
cy.get('tr')
|
||||
@@ -608,7 +639,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy=creditsStudentsToggle]').click()
|
||||
cy.cs('creditsStudentsToggle').click()
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] thead tr')
|
||||
.eq(0)
|
||||
@@ -657,16 +688,16 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Degree courses works for basic user', () => {
|
||||
describe('Degree courses tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Degree courses').click()
|
||||
cy.cs('degree-courses-tab').click()
|
||||
})
|
||||
|
||||
it('content loads', () => {
|
||||
cy.contains('h3', 'Select curriculum to edit:')
|
||||
cy.get('[data-cy=curriculum-picker]').contains('2023–2026')
|
||||
cy.cs('curriculum-picker').contains('2023–2026')
|
||||
cy.contains('Change visibility of degree courses and select criteria for academic years')
|
||||
cy.contains('form', 'First year (12 months) last set: 0')
|
||||
cy.get('table').within(() => {
|
||||
@@ -678,60 +709,80 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/* Tag-tests */
|
||||
describe('Tags view works for basic user', () => {
|
||||
describe('Tags tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
cy.get('.attached').contains('Tags').click()
|
||||
cy.cs('tags-tab').click()
|
||||
})
|
||||
|
||||
it('can create and delete tags for population', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2022')
|
||||
cy.contains('Create a new tag').click()
|
||||
cy.contains(name)
|
||||
cy.contains('2022')
|
||||
deleteTag(name)
|
||||
it('info box', () => {
|
||||
cy.cs('create-new-tag-info-box-button').click()
|
||||
cy.cs('create-new-tag-info-box-content').contains('Here you can create')
|
||||
})
|
||||
|
||||
it('can create personal tags', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2022')
|
||||
describe('Adding tags to populations and removing them works', () => {
|
||||
beforeEach(() => {
|
||||
cy.contains('This study programme does not have any tags yet')
|
||||
cy.cs('tag-name-text-field').type(tagName)
|
||||
})
|
||||
|
||||
cy.get('.ui > label').click()
|
||||
cy.contains('Create a new tag').click()
|
||||
cy.contains(name)
|
||||
deleteTag(name)
|
||||
afterEach(() => {
|
||||
deleteTag(tagName)
|
||||
cy.contains('This study programme does not have any tags yet')
|
||||
})
|
||||
|
||||
it('can create and delete tags without start year', () => {
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.contains('No associated start year')
|
||||
})
|
||||
|
||||
it.skip('can create and delete tags with start year', () => {
|
||||
// TODO: This test fails in the pipeline, but works locally. Investigate.
|
||||
selectYear('2022')
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.contains('Associated start year 2022')
|
||||
})
|
||||
|
||||
it.skip('population link works', () => {
|
||||
// TODO: This test fails in the pipeline, but works locally. Investigate.
|
||||
selectYear('2022')
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.cs('population-link-button').click()
|
||||
cy.contains(`Tagged with: ${tagName}`)
|
||||
cy.go('back')
|
||||
})
|
||||
|
||||
it('can create personal tags', () => {
|
||||
cy.cs('personal-tag-toggle').click()
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.cs(`${tagName}-visibility-icon`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Adding tags to students and removing them works', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
const studentInput = '477806,478275; 478953 479239\n 480080'
|
||||
const studentNumbers = studentInput.match(/[^\s,;]+/g)
|
||||
|
||||
it('can add tags to students', () => {
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2022')
|
||||
cy.contains('Create a new tag').click()
|
||||
cy.contains(name)
|
||||
it.skip('can add tags to students', () => {
|
||||
// TODO: This test fails in the pipeline, but works locally. Investigate.
|
||||
cy.cs('tag-name-text-field').type(tagName)
|
||||
selectYear('2022')
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
|
||||
cy.contains('Add a tag to students').click()
|
||||
cy.get('.form > .field > .dropdown').click().get('.ui > input.search').type(name).click()
|
||||
|
||||
cy.get('.form > .field > .dropdown > .visible').contains(name).click()
|
||||
|
||||
cy.get('textarea').type(studentInput)
|
||||
cy.contains('Add tags').click()
|
||||
|
||||
cy.contains('Successfully added tags to students.')
|
||||
|
||||
cy.contains('td', name).get('i.level.up.alternate.icon').click()
|
||||
cy.cs('add-students-button').click()
|
||||
cy.cs('add-students-text-field').type(studentInput)
|
||||
cy.cs('add-students-confirm-button').click()
|
||||
cy.contains('Students added to tag')
|
||||
|
||||
cy.cs('population-link-button').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains(`Tagged with: ${name}`)
|
||||
cy.contains(`Tagged with: ${tagName}`)
|
||||
cy.contains('Students (5)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
@@ -742,17 +793,24 @@ describe('Study programme overview', () => {
|
||||
cy.contains(studentNumber)
|
||||
}
|
||||
|
||||
cy.go('back')
|
||||
deleteTag(name)
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags')
|
||||
cy.contains(tagName)
|
||||
}
|
||||
|
||||
cy.get('a').contains('Matemaattisten tieteiden kandiohjelma').invoke('removeAttr', 'target').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?tab=4')
|
||||
|
||||
deleteTag(tagName)
|
||||
})
|
||||
|
||||
it('deleting a tag from tag view also removes it from students', () => {
|
||||
cy.contains(name).should('not.exist')
|
||||
cy.contains(tagName).should('not.exist')
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.contains('Students').click()
|
||||
cy.get('.prompt').type(studentNumber)
|
||||
cy.contains('a', studentNumber).click()
|
||||
cy.contains(name).should('not.exist')
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags').should('not.exist')
|
||||
cy.contains(tagName).should('not.exist')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -773,25 +831,27 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('can access basic information', () => {
|
||||
cy.contains('Basic information').click()
|
||||
cy.cs('basic-information-tab').click()
|
||||
|
||||
cy.get('[data-cy=Section-studentsOfTheStudyProgramme]')
|
||||
cy.get('[data-cy=Section-creditsProducedByTheStudyProgramme]')
|
||||
cy.get('[data-cy=Section-graduatedAndThesisWritersOfTheProgramme]')
|
||||
cy.get('[data-cy=Section-programmesBeforeOrAfter]')
|
||||
cy.get('[data-cy=Section-averageGraduationTimes]')
|
||||
cy.cs('students-of-the-study-programme-section')
|
||||
cy.cs('credits-produced-by-the-study-programme-section')
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
cy.cs('programmes-before-or-after-section')
|
||||
})
|
||||
|
||||
it('can access study tracks', () => {
|
||||
cy.get('.attached').contains('Study tracks and class statistics').click()
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
|
||||
cy.get('[data-cy=Section-studyTrackOverview]')
|
||||
cy.get('[data-cy=Section-studyTrackProgress]')
|
||||
cy.get('[data-cy=Section-averageGraduationTimesStudyTracks]')
|
||||
cy.cs('Section-studyTrackOverview')
|
||||
cy.cs('Section-studyTrackProgress')
|
||||
cy.cs('Section-averageGraduationTimesStudyTracks')
|
||||
})
|
||||
|
||||
it("doesn't see other tabs", () => {
|
||||
cy.get('div.ui.tabular.menu a').should('have.length', 2)
|
||||
cy.cs('degree-courses-tab').should('not.exist')
|
||||
cy.cs('tags-tab').should('not.exist')
|
||||
cy.cs('update-statistics-tab').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
+17
-18
@@ -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(`faculty-${level}-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`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ describe('University view', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/university')
|
||||
cy.contains('University')
|
||||
cy.contains('Programme MH90_001 (Veterinary medicine bachelor + licentiate) is currently excluded.')
|
||||
})
|
||||
|
||||
describe('Faculty progress tab', () => {
|
||||
@@ -41,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('faculty-bachelors-progress-table-show-button3').click()
|
||||
cy.contains('29.5%').trigger('mouseover', { force: true })
|
||||
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
|
||||
cy.contains('H50')
|
||||
@@ -67,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', () => {
|
||||
@@ -84,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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+6
-15
@@ -8,12 +8,8 @@ const visibleLinks = {
|
||||
visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Programmes', 'Students']
|
||||
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
|
||||
|
||||
const hasVisibleItems = visibleItems => {
|
||||
cy.get('[data-cy=nav-bar]').children().should('have.length', visibleItems)
|
||||
}
|
||||
|
||||
const containsLinks = links => {
|
||||
cy.get('[data-cy=nav-bar]').within(() => {
|
||||
cy.cs('nav-bar').within(() => {
|
||||
for (const link of links) {
|
||||
cy.contains(link)
|
||||
}
|
||||
@@ -21,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')
|
||||
@@ -34,7 +30,6 @@ describe('Users tests', () => {
|
||||
describe('Using as user with just grp-oodikone-user, no other rights', () => {
|
||||
it('shows correct tabs', () => {
|
||||
cy.init('', 'norights')
|
||||
hasVisibleItems(7)
|
||||
containsLinks(visibleLinks.norights)
|
||||
userButtonWorks('norights')
|
||||
})
|
||||
@@ -43,7 +38,6 @@ describe('Users tests', () => {
|
||||
describe('Using as coursestatistics user', () => {
|
||||
it('shows correct tabs', () => {
|
||||
cy.init('', 'onlycoursestatistics')
|
||||
hasVisibleItems(7)
|
||||
containsLinks(visibleLinks.onlycoursestatistics)
|
||||
userButtonWorks('onlycoursestatistics')
|
||||
})
|
||||
@@ -52,7 +46,6 @@ describe('Users tests', () => {
|
||||
describe('Using as basic user', () => {
|
||||
it('shows correct tabs', () => {
|
||||
cy.init('')
|
||||
hasVisibleItems(10)
|
||||
containsLinks(visibleLinks.basic)
|
||||
userButtonWorks('basic')
|
||||
})
|
||||
@@ -64,15 +57,14 @@ describe('Users tests', () => {
|
||||
})
|
||||
|
||||
it('should see more stuff than others', () => {
|
||||
hasVisibleItems(13)
|
||||
containsLinks(visibleLinks.admin)
|
||||
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', () => {
|
||||
@@ -80,8 +72,7 @@ describe('Users tests', () => {
|
||||
})
|
||||
|
||||
it("only the mocked user's programmes are visible", () => {
|
||||
cy.get('[data-cy=nav-bar-button-studyProgramme]').click()
|
||||
cy.get('[data-cy=nav-bar-button-class]').click()
|
||||
cy.visit('/populations')
|
||||
cy.contains('label', 'Study programme')
|
||||
.siblings()
|
||||
.within(() => {
|
||||
|
||||
@@ -19,4 +19,17 @@
|
||||
module.exports = (on, config) => {
|
||||
// `on` is used to hook into various events Cypress emits
|
||||
// `config` is the resolved Cypress config
|
||||
on('before:browser:launch', (browser = {}, launchOptions) => {
|
||||
// By manually setting the browser width and height in headless
|
||||
// mode, screenshots and videos will have a better quality
|
||||
// https://www.cypress.io/blog/generate-high-resolution-videos-and-screenshots
|
||||
const width = 2560
|
||||
const height = 1440
|
||||
|
||||
if (browser.name === 'chrome' && browser.isHeadless) {
|
||||
launchOptions.args.push(`--window-size=${width},${height}`)
|
||||
launchOptions.args.push('--force-device-scale-factor=1')
|
||||
}
|
||||
return launchOptions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ Cypress.Commands.add('selectFromDropdown', { prevSubject: true }, (s, index) =>
|
||||
})
|
||||
|
||||
Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
|
||||
cy.get(`[data-cy=Table-${tableName}] tbody`).within(() => {
|
||||
cy.get(`[data-cy=${tableName}-data-table] tbody`).within(() => {
|
||||
correctStats.forEach((values, trIndex) => {
|
||||
cy.get('tr')
|
||||
.filter(':visible')
|
||||
|
||||
+4
-18
@@ -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
|
||||
|
||||
@@ -83,7 +83,7 @@ services:
|
||||
|
||||
jami-db:
|
||||
<<: *defaults
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
environment:
|
||||
- PGDATA=/data
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
@@ -118,18 +118,15 @@ services:
|
||||
- redis
|
||||
- sis-db
|
||||
- sis-importer-db
|
||||
- sis-updater-nats
|
||||
environment:
|
||||
CI: 'true'
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
SIS_IMPORTER_PASSWORD: postgres
|
||||
SIS_IMPORTER_USER: postgres
|
||||
SIS_PASSWORD: postgres
|
||||
SIS_NATS_TOKEN: dev
|
||||
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
|
||||
profiles:
|
||||
- updater
|
||||
|
||||
@@ -141,28 +138,17 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-importer-db
|
||||
- sis-updater-nats
|
||||
environment:
|
||||
CI: 'true'
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SECRET_TOKEN: dev
|
||||
SIS_IMPORTER_DATABASE: sis-importer-db
|
||||
SIS_IMPORTER_HOST: sis-importer-db
|
||||
SIS_IMPORTER_PASSWORD: postgres
|
||||
SIS_IMPORTER_USER: postgres
|
||||
SIS_NATS_TOKEN: dev
|
||||
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
|
||||
SCHEDULE_IMMEDIATE: '${SCHEDULE_IMMEDIATE}'
|
||||
EXIT_AFTER_IMMEDIATES: '${EXIT_AFTER_IMMEDIATES}'
|
||||
ports:
|
||||
- 8082:8082
|
||||
profiles:
|
||||
- updater
|
||||
|
||||
sis-updater-nats:
|
||||
<<: *defaults
|
||||
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_channels 1000 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
|
||||
container_name: sis-updater-nats
|
||||
image: nats-streaming:0.25.6
|
||||
profiles:
|
||||
- updater
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
SENTRY_DSN: ''
|
||||
SERVICE_PROVIDER: fd
|
||||
LANGUAGE_CENTER_VIEW_ENABLED: 'false' # currently no intention to be enabled
|
||||
SIS_URL: 'https://sis-qa.funidata.fi'
|
||||
NODE_ENV: production
|
||||
image: ${DOCKER_OODIKONE_NAMESPACE:-637423527834.dkr.ecr.eu-north-1.amazonaws.com/oodikone}/oodikone-frontend:${DOCKER_IMAGE_TAG:-dev}
|
||||
|
||||
|
||||
+6
-17
@@ -64,6 +64,7 @@ services:
|
||||
environment:
|
||||
REACT_APP_SERVICE_PROVIDER: fd
|
||||
REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED: 'false'
|
||||
REACT_APP_SIS_URL: 'https://sis-qa.funidata.fi'
|
||||
volumes:
|
||||
- ./services/frontend/src/:/opt/app-root/src/src
|
||||
- ./services/shared/:/opt/app-root/src/src/shared
|
||||
@@ -75,7 +76,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: kone-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
ports:
|
||||
- 12349:5432
|
||||
volumes:
|
||||
@@ -98,19 +99,13 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: sis-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
volumes:
|
||||
- sis-db-data:/data
|
||||
shm_size: 2gb
|
||||
ports:
|
||||
- 12345:5432
|
||||
|
||||
sis-updater-nats:
|
||||
<<: *defaults
|
||||
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_channels 1000 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
|
||||
container_name: sis-updater-nats
|
||||
image: nats-streaming:0.25.6
|
||||
|
||||
sis-updater-scheduler:
|
||||
<<: *defaults
|
||||
build:
|
||||
@@ -121,17 +116,14 @@ services:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-updater-nats
|
||||
environment:
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SECRET_TOKEN: dev
|
||||
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
|
||||
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
|
||||
SIS_IMPORTER_DATABASE: importer-db
|
||||
SIS_IMPORTER_PASSWORD: dev
|
||||
SIS_IMPORTER_USER: dev
|
||||
SIS_NATS_TOKEN: dev
|
||||
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
|
||||
SERVICE_PROVIDER: fd
|
||||
ports:
|
||||
- 8082:8082
|
||||
@@ -149,20 +141,17 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-db
|
||||
- sis-updater-nats
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
environment:
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
|
||||
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
|
||||
SIS_IMPORTER_DATABASE: importer-db
|
||||
SIS_IMPORTER_PASSWORD: dev
|
||||
SIS_IMPORTER_USER: dev
|
||||
SIS_PASSWORD: postgres
|
||||
SIS_NATS_TOKEN: dev
|
||||
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
|
||||
ROOT_ORG_ID: ${ROOT_ORG_ID}
|
||||
SERVICE_PROVIDER: fd
|
||||
volumes:
|
||||
@@ -175,7 +164,7 @@ services:
|
||||
PGDATA: /data
|
||||
POSTGRES_DB: user-db
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
volumes:
|
||||
- user-db-data:/data
|
||||
ports:
|
||||
|
||||
+4
-18
@@ -76,7 +76,7 @@ services:
|
||||
<<: *defaults
|
||||
command: 'redis-server --appendonly yes'
|
||||
container_name: redis
|
||||
image: redis:6.2.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:
|
||||
@@ -189,7 +175,7 @@ services:
|
||||
|
||||
jami-db:
|
||||
<<: *defaults
|
||||
image: postgres:15.8
|
||||
image: postgres:15.10
|
||||
environment:
|
||||
- PGDATA=/data
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
|
||||
@@ -12,15 +12,12 @@ Roles are defined [here](../services/backend/src/config/roles.ts). Criteria for
|
||||
- studyGuidanceGroups
|
||||
- teachers
|
||||
|
||||
The roles `openUniSearch` and `studyGuidanceGroups` simply enable certain views (_Open uni student population_ and _Guidance groups_ respectively).
|
||||
The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply enable certain views (_Faculties_, _Open uni student population_, and _Guidance groups_ respectively).
|
||||
|
||||
### courseStatistics
|
||||
|
||||
- Can see statistics of every course but year statistics of ≤ 5 students are hidden
|
||||
|
||||
### facultyStatistics
|
||||
|
||||
- Can see statistics of every course and year
|
||||
- If the user has [full rights](#programmes) to **any** programme, they can also see exact statistics even if there are ≤ 5 students (the same behavior as with `admin` or `fullSisuAccess` roles)
|
||||
|
||||
### teachers
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
<!--
|
||||
|
||||
+8
-3
@@ -80,7 +80,6 @@ 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 } }],
|
||||
@@ -90,6 +89,7 @@ export default [
|
||||
'error',
|
||||
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
|
||||
],
|
||||
'react/jsx-boolean-value': ['error', 'never'],
|
||||
'react/jsx-filename-extension': ['error', { allow: 'as-needed', extensions: ['.jsx', '.tsx'] }],
|
||||
'react/jsx-sort-props': ['error', { reservedFirst: false }],
|
||||
'react/no-array-index-key': 'error',
|
||||
@@ -99,7 +99,7 @@ export default [
|
||||
'react/prefer-stateless-function': 'error',
|
||||
'react/prop-types': 'off',
|
||||
...pluginReactHooks.configs.recommended.rules,
|
||||
'react-hooks/exhaustive-deps': 'off', // TODO: Delete this override
|
||||
'react-hooks/exhaustive-deps': 'warn', // TODO: Delete this override
|
||||
'@typescript-eslint/array-type': 'off',
|
||||
'@typescript-eslint/consistent-indexed-object-style': 'off',
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
@@ -132,6 +132,12 @@ export default [
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['services/**/shared/**/*'],
|
||||
rules: {
|
||||
'import/no-unused-modules': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['cypress/**/*.js', 'services/backend/**/*.js', 'updater/**/*.js'],
|
||||
languageOptions: {
|
||||
@@ -153,7 +159,6 @@ export default [
|
||||
'no-await-in-loop': 'off',
|
||||
'no-param-reassign': 'off',
|
||||
'no-promise-executor-return': 'off',
|
||||
'no-return-await': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Generated
+1068
-716
File diff suppressed because it is too large
Load Diff
+16
-16
@@ -38,27 +38,27 @@
|
||||
},
|
||||
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
|
||||
"devDependencies": {
|
||||
"@cypress/code-coverage": "^3.13.6",
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@cypress/code-coverage": "^3.13.12",
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"cypress": "^13.15.2",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.3",
|
||||
"cypress": "^14.1.0",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-config-prettier": "^10.0.2",
|
||||
"eslint-import-resolver-typescript": "^3.8.3",
|
||||
"eslint-plugin-cypress": "^4.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.0.0",
|
||||
"globals": "^15.12.0",
|
||||
"husky": "^9.1.6",
|
||||
"lint-staged": "^15.2.10",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.4.3",
|
||||
"node-actionlint": "^1.2.2",
|
||||
"nyc": "^17.1.0",
|
||||
"prettier": "3.3.3",
|
||||
"stylelint": "^16.10.0",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"typescript-eslint": "^8.13.0"
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "^16.15.0",
|
||||
"stylelint-config-standard": "^37.0.0",
|
||||
"typescript-eslint": "^8.26.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
+1
-12
@@ -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
|
||||
}
|
||||
],
|
||||
@@ -28,7 +19,5 @@
|
||||
"labels": ["security"]
|
||||
},
|
||||
"addLabels": ["dependencies"],
|
||||
"rangeStrategy": "bump",
|
||||
"schedule": ["before 5:00am"],
|
||||
"timezone": "Europe/Helsinki"
|
||||
"rangeStrategy": "bump"
|
||||
}
|
||||
|
||||
Generated
+459
-835
File diff suppressed because it is too large
Load Diff
@@ -5,26 +5,24 @@
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/node": "^8.37.1",
|
||||
"@sentry/profiling-node": "^8.37.1",
|
||||
"axios": "^0.28.1",
|
||||
"bullmq": "^5.25.4",
|
||||
"compression": "^1.7.5",
|
||||
"@sentry/node": "^8.55.0",
|
||||
"@sentry/profiling-node": "^8.55.0",
|
||||
"axios": "^1.8.1",
|
||||
"bullmq": "^5.41.7",
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^3.1.9",
|
||||
"express": "^4.21.1",
|
||||
"cron": "^4.1.0",
|
||||
"express": "^4.21.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^11.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.46",
|
||||
"morgan": "^1.10.0",
|
||||
"oodikone-shared": "file:../oodikone-shared",
|
||||
"papaparse": "^5.4.1",
|
||||
"pg": "^8.13.1",
|
||||
"papaparse": "^5.5.2",
|
||||
"pg": "^8.13.3",
|
||||
"redis": "^4.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize": "^6.37.6",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"umzug": "^3.8.2",
|
||||
"unfuck-utf8-headers-middleware": "^1.0.1",
|
||||
@@ -35,16 +33,16 @@
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/node": "^22.13.9",
|
||||
"@types/validator": "^13.12.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"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,
|
||||
|
||||
@@ -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'
|
||||
@@ -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,7 @@
|
||||
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 '.'
|
||||
|
||||
@@ -56,23 +57,8 @@ export class Student extends Model<InferAttributes<Student>> {
|
||||
@Column(DataType.STRING)
|
||||
phone_number!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
country_fi!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
country_sv!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
country_en!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
home_country_fi!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
home_country_sv!: string
|
||||
|
||||
@Column(DataType.STRING)
|
||||
home_country_en!: string
|
||||
@Column(DataType.JSONB)
|
||||
citizenships!: Name[]
|
||||
|
||||
@Column(DataType.STRING)
|
||||
gender_code!: GenderCode
|
||||
@@ -83,6 +69,9 @@ export class Student extends Model<InferAttributes<Student>> {
|
||||
@Column(DataType.BOOLEAN)
|
||||
hasPersonalIdentityCode!: boolean
|
||||
|
||||
@Column(DataType.STRING)
|
||||
preferredLanguage!: string
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
|
||||
@@ -43,6 +43,9 @@ export class Studyplan extends Model<InferAttributes<Studyplan>> {
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
included_courses!: string[]
|
||||
|
||||
@Column({ type: DataType.ARRAY(DataType.TEXT), field: 'included_modules' })
|
||||
includedModules!: string[]
|
||||
|
||||
@Column(DataType.STRING)
|
||||
sisu_id!: string
|
||||
|
||||
|
||||
@@ -16,18 +16,21 @@ router.get('/', async (_req: Request, res: Response) => {
|
||||
const fakeRelease: Release[] = [
|
||||
{
|
||||
description: '**Feature 1**\n- Added a fancy new feature \n\n**Feature 2**\n- Fixed a bug\n- Fixed another bug',
|
||||
title: 'Release 1',
|
||||
title: 'Release 3',
|
||||
time: new Date().toISOString(),
|
||||
version: '0.0.3',
|
||||
},
|
||||
{
|
||||
description: "Let's not spam the GitHub API in development!",
|
||||
title: 'Release 2',
|
||||
time: new Date().toISOString(),
|
||||
version: '0.0.2',
|
||||
},
|
||||
{
|
||||
description: 'This release should not be visible on the frontpage',
|
||||
title: 'Release 3',
|
||||
title: 'Release 1',
|
||||
time: new Date().toISOString(),
|
||||
version: '0.0.1',
|
||||
},
|
||||
]
|
||||
return res.status(200).json(fakeRelease)
|
||||
@@ -37,6 +40,7 @@ router.get('/', async (_req: Request, res: Response) => {
|
||||
description: release.body,
|
||||
time: release.published_at,
|
||||
title: release.name,
|
||||
version: release.tag_name,
|
||||
}))
|
||||
changelog.data = releasesFromAPI
|
||||
res.status(200).json(releasesFromAPI)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import crypto from 'crypto'
|
||||
import { Request, Response, Router } from 'express'
|
||||
import moment from 'moment'
|
||||
|
||||
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 +22,45 @@ router.get('/v2/coursesmulti', async (req: GetCoursesRequest, res: Response) =>
|
||||
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
|
||||
}
|
||||
|
||||
let results: { courses: CourseWithSubsId[] } = { courses: [] }
|
||||
results = await getCoursesByNameAndOrCode(name, code)
|
||||
const courses = await getCoursesByNameAndOrCode(name, code)
|
||||
|
||||
if (combineSubstitutions === 'false') {
|
||||
const courseCodes = results.courses.map(course => course.code)
|
||||
const courseCodes = courses.map(course => course.code)
|
||||
const substitutions = [
|
||||
...new Set(
|
||||
results.courses
|
||||
.flatMap(course => course.substitutions)
|
||||
.filter(code => code !== null && !courseCodes.includes(code))
|
||||
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
|
||||
),
|
||||
]
|
||||
const substitutionDetails = await getCoursesByCodes(substitutions)
|
||||
for (const substitution of substitutionDetails) {
|
||||
results.courses.push(substitution.dataValues)
|
||||
courses.push(substitution.toJSON())
|
||||
}
|
||||
}
|
||||
res.json(results)
|
||||
|
||||
const mergedCourses = {}
|
||||
|
||||
for (const course of courses) {
|
||||
const groupId = combineSubstitutions === 'true' ? course.subsId! : course.code
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) {
|
||||
continue
|
||||
}
|
||||
if (!mergedCourses[groupId]) {
|
||||
mergedCourses[groupId] = {
|
||||
...course,
|
||||
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
|
||||
}
|
||||
} else {
|
||||
const mergedCourse = mergedCourses[groupId]
|
||||
if (moment(mergedCourse.max_attainment_date).isBefore(course.max_attainment_date)) {
|
||||
mergedCourse.max_attainment_date = course.max_attainment_date
|
||||
}
|
||||
if (moment(mergedCourse.min_attainment_date).isAfter(course.min_attainment_date)) {
|
||||
mergedCourse.min_attainment_date = course.min_attainment_date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ courses: Object.values(mergedCourses) })
|
||||
})
|
||||
|
||||
interface GetCourseYearlyStatsRequest extends Request {
|
||||
@@ -72,16 +91,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)
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getDegreeProgrammesOfFaculty, getFacultyCodeById } from '../services/fa
|
||||
import { combineFacultyBasics } from '../services/faculty/facultyBasics'
|
||||
import { getFacultyCredits } from '../services/faculty/facultyCredits'
|
||||
import { countGraduationTimes } from '../services/faculty/facultyGraduationTimes'
|
||||
import { getSortedFaculties } from '../services/faculty/facultyHelpers'
|
||||
import { getFacultiesForFacultyList } from '../services/faculty/facultyHelpers'
|
||||
import {
|
||||
getBasicStats,
|
||||
setBasicStats,
|
||||
@@ -22,7 +22,7 @@ import { combineFacultyStudentProgress } from '../services/faculty/facultyStuden
|
||||
import { combineFacultyStudents } from '../services/faculty/facultyStudents'
|
||||
import { combineFacultyThesisWriters } from '../services/faculty/facultyThesisWriters'
|
||||
import { updateFacultyOverview, updateFacultyProgressOverview } from '../services/faculty/facultyUpdates'
|
||||
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../types'
|
||||
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
// Faculty uses a lot of tools designed for Study programme.
|
||||
@@ -31,7 +31,7 @@ import logger from '../util/logger'
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const faculties = await getSortedFaculties()
|
||||
const faculties = await getFacultiesForFacultyList()
|
||||
res.json(faculties)
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { difference, intersection, uniq } from 'lodash'
|
||||
import { rootOrgId } from '../config'
|
||||
import { SISStudyRight } from '../models'
|
||||
import { maxYearsToCreatePopulationFrom, getCourseProvidersForCourses } from '../services/courses'
|
||||
import { encrypt, decrypt } from '../services/encrypt'
|
||||
import { encrypt, decrypt, type EncrypterData } from '../services/encrypt'
|
||||
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from '../services/faculty/faculty'
|
||||
import { bottlenecksOf } from '../services/populations/bottlenecksOf'
|
||||
import { optimizedStatisticsOf } from '../services/populations/optimizedStatisticsOf'
|
||||
@@ -29,12 +29,11 @@ const filterPersonalTags = (population: Record<string, any>, userId: string) =>
|
||||
}
|
||||
}
|
||||
|
||||
type EncryptedStudent = EncrypterData
|
||||
const isEncryptedStudent = (student?: string | EncryptedStudent) => {
|
||||
return (student as EncryptedStudent)?.encryptedData !== undefined
|
||||
}
|
||||
|
||||
type EncryptedStudent = { iv: string; encryptedData: string }
|
||||
|
||||
interface PostPopulationStatisticsRequest extends Request {
|
||||
body: {
|
||||
year: string
|
||||
@@ -92,7 +91,7 @@ router.post('/v2/populationstatistics/courses', async (req: PostPopulationStatis
|
||||
req.body.years.map(year => {
|
||||
if (req.body.selectedStudentsByYear) {
|
||||
req.body.selectedStudents = encrypted
|
||||
? req.body.selectedStudents.filter(isEncryptedStudent).map(decrypt)
|
||||
? (req.body.selectedStudents as EncryptedStudent[]).filter(isEncryptedStudent).map(decrypt)
|
||||
: req.body.selectedStudentsByYear[year]
|
||||
}
|
||||
const newMonths = (upperYearBound - Number(year)) * 12
|
||||
@@ -111,7 +110,7 @@ router.post('/v2/populationstatistics/courses', async (req: PostPopulationStatis
|
||||
}
|
||||
|
||||
if (encrypted) {
|
||||
req.body.selectedStudents = req.body.selectedStudents.map(decrypt)
|
||||
req.body.selectedStudents = (req.body.selectedStudents as EncryptedStudent[]).map(decrypt)
|
||||
}
|
||||
|
||||
const query = { ...req.body, selectedStudents: req.body.selectedStudents as string[] }
|
||||
@@ -252,6 +251,7 @@ router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsReques
|
||||
req.query.months = '12'
|
||||
}
|
||||
|
||||
let result: { students: any[]; courses: any[] } | { error: string }
|
||||
if (req.query.years) {
|
||||
const upperYearBound = new Date().getFullYear() + 1
|
||||
const multiPopulationStudentPromises = Promise.all(
|
||||
@@ -268,54 +268,53 @@ router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsReques
|
||||
)
|
||||
const multiPopulationStudents = await multiPopulationStudentPromises
|
||||
|
||||
const result = populationStudentsMerger(multiPopulationStudents)
|
||||
res.json(filterPersonalTags(result, userId))
|
||||
result = populationStudentsMerger(multiPopulationStudents)
|
||||
} else {
|
||||
const result: any = await optimizedStatisticsOf({
|
||||
result = 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
|
||||
if (
|
||||
!hasFullAccessToStudentData(roles) &&
|
||||
!fullProgrammeRights.includes(studyRights.programme) &&
|
||||
!fullProgrammeRights.includes(studyRights.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
|
||||
return {
|
||||
...student,
|
||||
firstnames: '',
|
||||
lastname: '',
|
||||
phoneNumber: '',
|
||||
iv,
|
||||
studentNumber,
|
||||
name: '',
|
||||
email: '',
|
||||
secondaryEmail: '',
|
||||
sis_person_id: '',
|
||||
enrollments: student.enrollments?.map(enrollment => ({ ...enrollment, studentnumber: studentNumber })),
|
||||
tags: [],
|
||||
birthdate: obfuscatedBirthDate,
|
||||
obfuscated: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
res.json(filterPersonalTags(result, userId))
|
||||
}
|
||||
|
||||
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
|
||||
if (
|
||||
!hasFullAccessToStudentData(roles) &&
|
||||
!fullProgrammeRights.includes(studyRights.programme) &&
|
||||
!fullProgrammeRights.includes(studyRights.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
|
||||
return {
|
||||
...student,
|
||||
firstnames: '',
|
||||
lastname: '',
|
||||
phoneNumber: '',
|
||||
iv,
|
||||
studentNumber,
|
||||
name: '',
|
||||
email: '',
|
||||
secondaryEmail: '',
|
||||
sis_person_id: '',
|
||||
enrollments: student.enrollments?.map(enrollment => ({ ...enrollment, studentnumber: studentNumber })),
|
||||
tags: [],
|
||||
birthdate: obfuscatedBirthDate,
|
||||
obfuscated: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return res.json(filterPersonalTags(result, userId))
|
||||
})
|
||||
|
||||
interface GetPopulationStatisticsByCourseRequest extends Request {
|
||||
|
||||
@@ -18,7 +18,7 @@ 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 '../types'
|
||||
import { Graduated, SpecialGroups, YearType } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
import { logInfoForGrafana } from '../util/logInfoForGrafana'
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { difference } from 'lodash'
|
||||
|
||||
import { Tag, TagStudent } from '../models/kone'
|
||||
import { filterStudentnumbersByAccessrights } from '../services/students'
|
||||
import { filterStudentNumbersByAccessRights } from '../services/students'
|
||||
import {
|
||||
findTagsByStudyTrack,
|
||||
createNewTag,
|
||||
@@ -11,20 +10,18 @@ import {
|
||||
createMultipleStudentTags,
|
||||
findTagsFromStudyTrackById,
|
||||
deleteMultipleStudentTags,
|
||||
TagFromFrontend,
|
||||
StudentTagFromFrontend,
|
||||
} from '../services/tags'
|
||||
import { Role } from '../shared/types'
|
||||
import { NewTag, Role, StudentTag, Tag } from '../shared/types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const filterRelevantTags = (tags: Tag[], userId: string) => {
|
||||
return tags.filter(tag => !tag.personal_user_id || tag.personal_user_id === userId)
|
||||
return tags.filter(tag => !tag.personalUserId || tag.personalUserId === userId)
|
||||
}
|
||||
|
||||
const filterRelevantStudentTags = (studentTags: TagStudent[], userId: string) => {
|
||||
return studentTags.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId)
|
||||
const filterRelevantStudentTags = (studentTags: StudentTag[], userId: string) => {
|
||||
return studentTags.filter(({ tag }) => !tag?.personalUserId || tag.personalUserId === userId)
|
||||
}
|
||||
|
||||
const userIsUnauthorized = (programmeRights: string[], programmeCodes: string[], roles: Role[]) => {
|
||||
@@ -41,15 +38,15 @@ const getStudyTrackCode = (studyTrack: string, combinedProgramme: string) => {
|
||||
|
||||
interface GetTagsByStudyTrackRequest extends Request {
|
||||
params: {
|
||||
studytrack: string
|
||||
studyTrack: string
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/tags/:studytrack', async (req: GetTagsByStudyTrackRequest, res: Response) => {
|
||||
const { studytrack } = req.params
|
||||
router.get('/tags/:studyTrack', async (req: GetTagsByStudyTrackRequest, res: Response) => {
|
||||
const { studyTrack } = req.params
|
||||
const { roles, id, programmeRights } = req.user
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeCodes = getProgrammeCodes(studytrack)
|
||||
const programmeCodes = getProgrammeCodes(studyTrack)
|
||||
|
||||
// Respond with null and 200 instead of 403 if the user isn't authorized to view the tags
|
||||
// This is to avoid unnecessary noise in Sentry
|
||||
@@ -57,149 +54,148 @@ router.get('/tags/:studytrack', async (req: GetTagsByStudyTrackRequest, res: Res
|
||||
return res.json(null)
|
||||
}
|
||||
|
||||
const tags = await findTagsByStudyTrack(studytrack)
|
||||
const tags = await findTagsByStudyTrack(studyTrack)
|
||||
res.status(200).json(filterRelevantTags(tags, id))
|
||||
})
|
||||
|
||||
interface PostTagRequest extends Request {
|
||||
body: {
|
||||
tag: TagFromFrontend
|
||||
}
|
||||
body: { tag: NewTag }
|
||||
}
|
||||
|
||||
router.post('/tags', async (req: PostTagRequest, res: Response) => {
|
||||
const {
|
||||
tag: { studytrack, tagname, year, personal_user_id },
|
||||
tag: { name, personalUserId, studyTrack, year },
|
||||
} = req.body
|
||||
const { roles, id, programmeRights } = req.user
|
||||
|
||||
if (!tagname || !studytrack || !year) {
|
||||
if (!name || !studyTrack) {
|
||||
return res.status(400).json({ error: 'Missing required fields' })
|
||||
}
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeCodes = getProgrammeCodes(studytrack)
|
||||
const programmeCodes = getProgrammeCodes(studyTrack)
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
await createNewTag({ studytrack, tagname, year, personal_user_id })
|
||||
const tags = await findTagsByStudyTrack(studytrack)
|
||||
await createNewTag({ name, personalUserId, studyTrack, year })
|
||||
const tags = await findTagsByStudyTrack(studyTrack)
|
||||
res.status(200).json(filterRelevantTags(tags, id))
|
||||
})
|
||||
|
||||
interface DeleteTagRequest extends Request {
|
||||
body: {
|
||||
tag: {
|
||||
studytrack: string
|
||||
tag_id: string
|
||||
personal_user_id: string
|
||||
}
|
||||
}
|
||||
body: { tag: Tag }
|
||||
}
|
||||
|
||||
router.delete('/tags', async (req: DeleteTagRequest, res: Response) => {
|
||||
const { tag } = req.body
|
||||
const { roles, id, programmeRights } = req.user
|
||||
const {
|
||||
tag: { id, personalUserId, studyTrack },
|
||||
} = req.body
|
||||
const { roles, id: userId, programmeRights } = req.user
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeCodes = getProgrammeCodes(tag.studytrack)
|
||||
const programmeCodes = getProgrammeCodes(studyTrack)
|
||||
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles) && !(tag.personal_user_id === id)) {
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles) && !(personalUserId === userId)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
await deleteTag(tag.tag_id)
|
||||
const tags = await findTagsByStudyTrack(tag.studytrack)
|
||||
await deleteTag(id)
|
||||
const tags = await findTagsByStudyTrack(studyTrack)
|
||||
res.status(200).json(filterRelevantTags(tags, id))
|
||||
})
|
||||
|
||||
interface GetStudentTagsByStudyTrackRequest extends Request {
|
||||
params: {
|
||||
studytrack: string
|
||||
studyTrack: string
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/studenttags/:studytrack', async (req: GetStudentTagsByStudyTrackRequest, res: Response) => {
|
||||
const { studytrack } = req.params
|
||||
router.get('/studenttags/:studyTrack', async (req: GetStudentTagsByStudyTrackRequest, res: Response) => {
|
||||
const { studyTrack } = req.params
|
||||
const { roles, id, programmeRights } = req.user
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeCodes = getProgrammeCodes(studytrack)
|
||||
const programmeCodes = getProgrammeCodes(studyTrack)
|
||||
const lacksProgrammeAccess = programmeCodes.every(code => !fullStudyProgrammeRights.includes(code))
|
||||
if (lacksProgrammeAccess && !roles?.includes('admin')) {
|
||||
return res.json(null)
|
||||
}
|
||||
|
||||
const tags = await getStudentTagsByStudyTrack(studytrack)
|
||||
res.status(200).json(filterRelevantStudentTags(tags, id))
|
||||
const tags = await getStudentTagsByStudyTrack(studyTrack)
|
||||
const relevantTags = filterRelevantStudentTags(tags, id)
|
||||
res.status(200).json(relevantTags)
|
||||
})
|
||||
|
||||
interface PostStudentTagsRequest extends Request {
|
||||
body: {
|
||||
tags: StudentTagFromFrontend[]
|
||||
studytrack: string
|
||||
combinedProgramme: string
|
||||
studentTags: StudentTag[]
|
||||
studyTrack: string
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/studenttags', async (req: PostStudentTagsRequest, res: Response) => {
|
||||
const { tags, studytrack, combinedProgramme } = req.body
|
||||
const { combinedProgramme, studentTags, studyTrack } = req.body
|
||||
const { roles, programmeRights } = req.user
|
||||
|
||||
if (!studentTags || !studyTrack) {
|
||||
return res.status(400).json({ error: 'Missing required fields' })
|
||||
}
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) {
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studyTrack, combinedProgramme], roles)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
const studyTrackCode = getStudyTrackCode(studytrack, combinedProgramme)
|
||||
const studyTrackCode = getStudyTrackCode(studyTrack, combinedProgramme)
|
||||
const existingTags = await findTagsByStudyTrack(studyTrackCode)
|
||||
const existingTagids = existingTags.map(tag => tag.tag_id)
|
||||
const tagIds = [...new Set(tags.map(tag => tag.tag_id))]
|
||||
if (!tagIds.find(tag => existingTagids.includes(tag))) {
|
||||
const existingTagIds = existingTags.map(tag => tag.id)
|
||||
const tagIds = [...new Set(studentTags.map(studentTag => studentTag.tagId))]
|
||||
if (!tagIds.find(tag => existingTagIds.includes(tag))) {
|
||||
return res.status(400).json({ error: 'The tag does not exist' })
|
||||
}
|
||||
|
||||
const studentNumbers = tags.map(tag => tag.studentnumber)
|
||||
const studentFromProgrammes = combinedProgramme ? [studytrack, combinedProgramme] : [studytrack]
|
||||
const students = await filterStudentnumbersByAccessrights(studentNumbers, studentFromProgrammes)
|
||||
const studentNumbers = studentTags.map(studentTag => studentTag.studentNumber)
|
||||
const studentFromProgrammes = combinedProgramme ? [studyTrack, combinedProgramme] : [studyTrack]
|
||||
const students = await filterStudentNumbersByAccessRights(studentNumbers, studentFromProgrammes)
|
||||
const missingStudents = difference(studentNumbers, students)
|
||||
if (missingStudents.length !== 0) {
|
||||
const errorMessage = `Could not find the following students from the programme: ${missingStudents.join(', ')}`
|
||||
return res.status(400).json({ error: errorMessage })
|
||||
}
|
||||
|
||||
await createMultipleStudentTags(tags)
|
||||
await createMultipleStudentTags(studentTags)
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
interface DeleteStudentTagsRequest extends Request {
|
||||
body: {
|
||||
tagId: string
|
||||
studentnumbers: string[]
|
||||
studytrack: string
|
||||
combinedProgramme: string
|
||||
tagId: string
|
||||
studentNumbers: string[]
|
||||
studyTrack: string
|
||||
}
|
||||
}
|
||||
|
||||
router.delete('/studenttags', async (req: DeleteStudentTagsRequest, res: Response) => {
|
||||
const { tagId, studentnumbers, studytrack, combinedProgramme } = req.body
|
||||
const { combinedProgramme, tagId, studentNumbers, studyTrack } = req.body
|
||||
const { roles, programmeRights } = req.user
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) {
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studyTrack, combinedProgramme], roles)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
const studyTrackCode = getStudyTrackCode(studytrack, combinedProgramme)
|
||||
const studyTrackCode = getStudyTrackCode(studyTrack, combinedProgramme)
|
||||
const tags = await findTagsFromStudyTrackById(studyTrackCode, [tagId])
|
||||
if (tags.length === 0) {
|
||||
return res.status(403).json({ error: 'The tag does not exist' })
|
||||
}
|
||||
|
||||
await deleteMultipleStudentTags(tagId, studentnumbers)
|
||||
await deleteMultipleStudentTags(tagId, studentNumbers)
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
} from '../services/faculty/facultyService'
|
||||
import { combineFacultyStudentProgress, FacultyProgressData } from '../services/faculty/facultyStudentProgress'
|
||||
import { getMedian } from '../services/studyProgramme/studyProgrammeHelpers'
|
||||
import { NameWithCode } from '../shared/types'
|
||||
import { Graduated } from '../types'
|
||||
import { Graduated, NameWithCode } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
@@ -33,7 +32,7 @@ const getProgrammeNames = (faculties: Organization[]) => {
|
||||
|
||||
interface GetProgressStatsRequest extends Request {
|
||||
query: {
|
||||
specialsIncluded: 'true' | undefined
|
||||
specialsIncluded: 'true' | 'false'
|
||||
graduated: Graduated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { Role } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
@@ -14,7 +15,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)
|
||||
})
|
||||
|
||||
@@ -34,17 +35,17 @@ router.get('/:uid', auth.roles(['admin']), async (req: UidRequest, res: Response
|
||||
}
|
||||
})
|
||||
|
||||
interface ModifyAccessRequest extends Request {
|
||||
interface ModifyRolesRequest extends Request {
|
||||
body: {
|
||||
username: string
|
||||
accessgroups: Record<string, boolean>
|
||||
roles: Record<Role, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/modifyaccess', auth.roles(['admin']), async (req: ModifyAccessRequest, res: Response) => {
|
||||
const { username, accessgroups } = req.body
|
||||
router.post('/modify-roles', auth.roles(['admin']), async (req: ModifyRolesRequest, res: Response) => {
|
||||
const { username, roles } = req.body
|
||||
try {
|
||||
await userService.modifyAccess(username, accessgroups)
|
||||
await userService.modifyAccess(username, roles)
|
||||
res.status(204).end()
|
||||
} catch (error) {
|
||||
res.status(400).json(error)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { facultyCodes, ignoredFacultyCodes } from '../config/organizationConstants'
|
||||
import { Graduated, SpecialGroups, YearType } from '../types'
|
||||
import { Graduated, SpecialGroups, YearType } from '../shared/types'
|
||||
import { redisClient } from './redis'
|
||||
import { isRelevantProgramme } from './studyProgramme/studyProgrammeHelpers'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { col, fn, Op, where } from 'sequelize'
|
||||
|
||||
import { Course, Credit, Enrollment, Student } from '../models/index'
|
||||
import { Course, Credit, Enrollment, Student, Studyplan } from '../models/index'
|
||||
import { CreditTypeCode, EnrollmentState } from '../types'
|
||||
|
||||
type Courses = Array<Pick<Course, 'code' | 'name' | 'substitutions'>>
|
||||
@@ -23,9 +23,10 @@ interface StudentInfo {
|
||||
lastname: string
|
||||
email: string
|
||||
sis_person_id: string
|
||||
secondary_email: string | null
|
||||
coursesInStudyPlan: string[]
|
||||
secondaryEmail: string | null
|
||||
credits: StudentCredit[]
|
||||
enrollments: StudentEnrollment[]
|
||||
enrollments: Record<string, StudentEnrollment>
|
||||
}
|
||||
|
||||
type StudentCredits = Record<
|
||||
@@ -36,16 +37,18 @@ type StudentCredits = Record<
|
||||
}
|
||||
>
|
||||
|
||||
interface FormattedStudent {
|
||||
interface FormattedStudent extends StudentInfo {
|
||||
studentNumber: string
|
||||
sis_person_id: string
|
||||
credits: StudentCredit[]
|
||||
enrollments: Record<string, any>
|
||||
allEnrollments: StudentEnrollment[]
|
||||
firstnames: string
|
||||
lastname: string
|
||||
email: string
|
||||
secondaryEmail: string | null
|
||||
}
|
||||
|
||||
interface StudentWithStudyplanNested
|
||||
extends Pick<Student, 'studentnumber' | 'firstnames' | 'lastname' | 'email' | 'sis_person_id' | 'secondary_email'> {
|
||||
studyplans: { included_courses: string[] }[]
|
||||
}
|
||||
|
||||
interface StudentWithCourses extends Omit<StudentWithStudyplanNested, 'studyplans'> {
|
||||
coursesInStudyPlan: string[]
|
||||
}
|
||||
|
||||
const getCourses = async (courseCodes: string[]) => {
|
||||
@@ -122,19 +125,27 @@ 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 Student.findAll({
|
||||
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
|
||||
where: {
|
||||
studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
})
|
||||
).map(student => student.toJSON())
|
||||
return students
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Studyplan,
|
||||
as: 'studyplans',
|
||||
attributes: ['included_courses'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return students.map(student => {
|
||||
const { studyplans, ...rest } = student.toJSON() as StudentWithStudyplanNested
|
||||
const coursesInStudyPlan = studyplans.flatMap(studyplan => studyplan.included_courses)
|
||||
return { ...rest, coursesInStudyPlan } as StudentWithCourses
|
||||
})
|
||||
}
|
||||
|
||||
export const getCompletedCourses = async (studentNumbers: string[], courseCodes: string[]) => {
|
||||
@@ -159,7 +170,8 @@ export const getCompletedCourses = async (studentNumbers: string[], courseCodes:
|
||||
lastname: student.lastname,
|
||||
email: student.email,
|
||||
sis_person_id: student.sis_person_id,
|
||||
secondary_email: student.secondary_email,
|
||||
secondaryEmail: student.secondary_email,
|
||||
coursesInStudyPlan: student.coursesInStudyPlan,
|
||||
credits: [],
|
||||
enrollments: [],
|
||||
}
|
||||
@@ -214,7 +226,8 @@ export const getCompletedCourses = async (studentNumbers: string[], courseCodes:
|
||||
firstnames: student.firstnames,
|
||||
lastname: student.lastname,
|
||||
email: student.email,
|
||||
secondaryEmail: student.secondary_email,
|
||||
secondaryEmail: student.secondaryEmail,
|
||||
coursesInStudyPlan: student.coursesInStudyPlan,
|
||||
})
|
||||
return acc
|
||||
}, [] as FormattedStudent[])
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { orderBy } from 'lodash'
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Course } from '../../models'
|
||||
@@ -52,7 +53,17 @@ const getRawCourses = async (name: string, code: string) => {
|
||||
const getCourses = async (name: string, code: string) => {
|
||||
const rawCourses = await getRawCourses(name, code)
|
||||
const courses: CourseWithSubsId[] = rawCourses
|
||||
.map(course => ({ ...course.dataValues }))
|
||||
.map(course => ({
|
||||
...course.toJSON(),
|
||||
substitutions: orderBy(course.substitutions, [
|
||||
substitution => {
|
||||
if (/^A/.exec(substitution)) return 4 // open university codes come last
|
||||
if (/^\d/.exec(substitution)) return 2 // old numeric codes come second
|
||||
if (/^[A-Za-z]/.exec(substitution)) return 1 // new letter based codes come first
|
||||
return 3 // unknown, comes before open uni?
|
||||
},
|
||||
]),
|
||||
}))
|
||||
.sort((a, b) => getSortRank(b.code) - getSortRank(a.code))
|
||||
return courses
|
||||
}
|
||||
@@ -87,7 +98,7 @@ export const getCoursesByNameAndOrCode = async (name: string, code: string) => {
|
||||
}
|
||||
})
|
||||
|
||||
return { courses }
|
||||
return courses
|
||||
}
|
||||
|
||||
export const getCoursesByCodes = (codes: string[]) => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { max, min } from 'lodash'
|
||||
|
||||
import { Name } from '../../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,
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const getStudentNumberToSrElementsMap = async (studentNumbers: string[])
|
||||
const studyRightIds = Object.keys(studyRightMap)
|
||||
|
||||
const studyRightElements = await SISStudyRightElement.findAll({
|
||||
attributes: ['code', 'name', 'startDate', 'studyRightId'],
|
||||
attributes: ['code', 'name', 'startDate', 'endDate', 'studyRightId'],
|
||||
where: {
|
||||
studyRightId: {
|
||||
[Op.in]: studyRightIds,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import crypto from 'crypto'
|
||||
import moment from 'moment'
|
||||
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 { isOpenUniCourseCode } from '../../util'
|
||||
import logger from '../../util/logger'
|
||||
import { getSortRank } from '../../util/sortRank'
|
||||
import { CourseYearlyStatsCounter } from './courseYearlyStatsCounter'
|
||||
import {
|
||||
@@ -67,11 +66,22 @@ const parseCredit = (
|
||||
credits,
|
||||
attainment_date: attainmentDate,
|
||||
student_studentnumber: studentNumber,
|
||||
studyright_id: studyRightId,
|
||||
} = credit
|
||||
const { yearcode: yearCode, yearname: yearName, semestercode: semesterCode, name: semesterName } = semester
|
||||
|
||||
const studyRightElements = studentNumberToSrElementsMap[studentNumber] || []
|
||||
const programmes = studyRightElements.map(formatStudyRightElement)
|
||||
let programmeOfCredit: SISStudyRightElement | undefined
|
||||
programmeOfCredit = studyRightElements.find(studyRightElement => studyRightElement.studyRightId === studyRightId)
|
||||
if (!programmeOfCredit) {
|
||||
programmeOfCredit = studyRightElements
|
||||
.filter(studyRightElement =>
|
||||
moment(attainmentDate).isBetween(moment(studyRightElement.startDate), moment(studyRightElement.endDate))
|
||||
)
|
||||
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())[0] // The newest studyRightElement
|
||||
}
|
||||
|
||||
const programmes = programmeOfCredit ? [programmeOfCredit].map(formatStudyRightElement) : []
|
||||
|
||||
const formattedCredit: FormattedCredit = {
|
||||
yearCode,
|
||||
@@ -149,10 +159,6 @@ const getYearlyStatsOfNew = async (
|
||||
combineSubstitutions: boolean,
|
||||
studentNumberToSrElementsMap: Record<string, SISStudyRightElement[]>
|
||||
) => {
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s.: entered getYearlyStatsOfNew with courseCode ${courseCode} and unification ${unification}`
|
||||
)
|
||||
const courseForSubs = await Course.findOne({
|
||||
where: { code: courseCode },
|
||||
})
|
||||
@@ -176,11 +182,6 @@ const getYearlyStatsOfNew = async (
|
||||
}),
|
||||
])
|
||||
|
||||
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,17 +239,8 @@ 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)
|
||||
|
||||
if (serviceProvider === 'fd')
|
||||
logger.info(
|
||||
`Debugging c.y.s., getYearlyStatsOfNew, code ${courseCode} unification ${unification}: got final statistics.`
|
||||
)
|
||||
const statistics = await counter.getFinalStatistics(anonymizationSalt)
|
||||
|
||||
let substitutionCourses: Course[] | undefined
|
||||
if (combineSubstitutions && courseForSubs?.substitutions && courseForSubs.substitutions.length > 0) {
|
||||
@@ -262,11 +254,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,
|
||||
@@ -351,9 +338,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 +349,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,8 +380,6 @@ export const getCourseYearlyStats = async (
|
||||
})
|
||||
)
|
||||
|
||||
if (serviceProvider === 'fd') logger.info('Debugging c.y.s.: finished creating statsRegular')
|
||||
|
||||
return statsRegular
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -22,16 +22,21 @@ const mapCurriculumPeriodIdToYear = (curriculumPeriodId: string, curriculumPerio
|
||||
|
||||
type ProgrammeModuleWithRelevantAttributes = Pick<
|
||||
InferAttributes<ProgrammeModule>,
|
||||
'code' | 'name' | 'degreeProgrammeType' | 'curriculum_period_ids'
|
||||
> & { progId: string }
|
||||
'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 +70,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 +78,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 +90,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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
import { NameWithCode } from '../../shared/types'
|
||||
import { DegreeProgrammeType } from '../../types'
|
||||
import { getBasicStats, setBasicStats } from '../analyticsService'
|
||||
import { getBasicStatsForStudytrack } from '../studyProgramme/studyProgrammeBasics'
|
||||
import type { ProgrammesOfOrganization } from './faculty'
|
||||
|
||||
type ProgrammeName = { code: string; en?: string; fi?: string; sv?: string }
|
||||
|
||||
type StudyTrackBasicStats = Awaited<ReturnType<typeof getBasicStatsForStudytrack>>
|
||||
|
||||
const calculateCombinedStats = async (
|
||||
@@ -19,12 +18,12 @@ const calculateCombinedStats = async (
|
||||
graphStats: [] as StudyTrackBasicStats['graphStats'],
|
||||
programmeTableStats: {} as Record<string, StudyTrackBasicStats['tableStats']>,
|
||||
tableStats: [] as StudyTrackBasicStats['tableStats'],
|
||||
titles: ['', 'Started\nstudying', 'Accepted', 'Graduated'],
|
||||
titles: ['Code', 'Started\nstudying', 'Accepted', 'Graduated'],
|
||||
}
|
||||
if (includeAllSpecials) {
|
||||
studentInfo.titles.push('Transferred out\nof programme', 'Transferred into\nprogramme')
|
||||
}
|
||||
const programmeNames: Record<string, ProgrammeName> = {}
|
||||
const programmeNames: Record<string, NameWithCode> = {}
|
||||
let years: Array<string | number> = []
|
||||
|
||||
for (const programme of facultyProgrammes) {
|
||||
@@ -143,7 +142,7 @@ const calculateCombinedStats = async (
|
||||
graphStats: combinedGraphStats,
|
||||
programmeTableStats: combinedProgrammeTableStats,
|
||||
tableStats: combinedTableStats,
|
||||
titles: ['', 'All', 'Bachelors', 'Masters', 'Doctors', 'Others'],
|
||||
titles: ['Code', 'All', 'Bachelors', 'Masters', 'Doctors', 'Others'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,21 +171,38 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
|
||||
classSizes.programmes[code] = classSizesByYear
|
||||
|
||||
if (!medians[level]) {
|
||||
medians[level] = cloneDeep(basicStats).map(year => {
|
||||
if (!classSizes[level]) {
|
||||
classSizes[level] = {}
|
||||
}
|
||||
classSizes[level][year.name] = year.classSize
|
||||
return { ...omit(year, ['y', 'classSize']), median: year.y }
|
||||
})
|
||||
} else {
|
||||
for (const statsForYear of basicStats) {
|
||||
const correctYear = medians[level].find(entry => entry.name === statsForYear.name)
|
||||
medians[level] = cloneDeep(basicStats).map(year => ({ ...omit(year, ['y', 'classSize']), median: year.y }))
|
||||
}
|
||||
classSizes[level] ??= {}
|
||||
for (const statsForYear of basicStats) {
|
||||
classSizes[level][statsForYear.name] ??= 0
|
||||
;(classSizes[level][statsForYear.name] as number) += statsForYear.classSize
|
||||
const correctYear = medians[level].find(entry => entry.name === statsForYear.name)
|
||||
if (!correctYear) {
|
||||
medians[level].push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
|
||||
continue
|
||||
}
|
||||
correctYear.times.push(...statsForYear.times)
|
||||
correctYear.median = getMedian(correctYear.times)
|
||||
correctYear.amount += statsForYear.amount
|
||||
correctYear.statistics.onTime += statsForYear.statistics.onTime
|
||||
correctYear.statistics.yearOver += statsForYear.statistics.yearOver
|
||||
correctYear.statistics.wayOver += statsForYear.statistics.wayOver
|
||||
}
|
||||
|
||||
if (level === 'master' && comboStats.length) {
|
||||
if (!medians.bcMsCombo) {
|
||||
medians.bcMsCombo = cloneDeep(comboStats).map(year => ({ ...omit(year, ['y', 'classSize']), median: year.y }))
|
||||
}
|
||||
classSizes.bcMsCombo ??= {}
|
||||
for (const statsForYear of comboStats) {
|
||||
classSizes.bcMsCombo[statsForYear.name] ??= 0
|
||||
;(classSizes.bcMsCombo[statsForYear.name] as number) += statsForYear.classSize
|
||||
const correctYear = medians.bcMsCombo.find(entry => entry.name === statsForYear.name)
|
||||
if (!correctYear) {
|
||||
medians[level].push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
|
||||
medians.bcMsCombo.push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
|
||||
continue
|
||||
}
|
||||
;(classSizes[level][statsForYear.name] as number) += statsForYear.classSize
|
||||
correctYear.times.push(...statsForYear.times)
|
||||
correctYear.median = getMedian(correctYear.times)
|
||||
correctYear.amount += statsForYear.amount
|
||||
@@ -195,33 +212,6 @@ const getStatsByStartYear = async (facultyProgrammes: ProgrammesOfOrganization)
|
||||
}
|
||||
}
|
||||
|
||||
if (level === 'master' && comboStats.length) {
|
||||
if (!medians.bcMsCombo) {
|
||||
medians.bcMsCombo = cloneDeep(comboStats).map(year => {
|
||||
if (!classSizes.bcMsCombo) {
|
||||
classSizes.bcMsCombo = {}
|
||||
}
|
||||
classSizes.bcMsCombo[year.name] = year.classSize
|
||||
return { ...omit(year, ['y', 'classSize']), median: year.y }
|
||||
})
|
||||
} else {
|
||||
for (const statsForYear of comboStats) {
|
||||
const correctYear = medians.bcMsCombo.find(entry => entry.name === statsForYear.name)
|
||||
if (!correctYear) {
|
||||
medians.bcMsCombo.push({ ...omit(cloneDeep(statsForYear), ['y', 'classSize']), median: statsForYear.y })
|
||||
continue
|
||||
}
|
||||
;(classSizes.bcMsCombo[statsForYear.name] as number) += statsForYear.classSize
|
||||
correctYear.times.push(...statsForYear.times)
|
||||
correctYear.median = getMedian(correctYear.times)
|
||||
correctYear.amount += statsForYear.amount
|
||||
correctYear.statistics.onTime += statsForYear.statistics.onTime
|
||||
correctYear.statistics.yearOver += statsForYear.statistics.yearOver
|
||||
correctYear.statistics.wayOver += statsForYear.statistics.wayOver
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!programmes.medians[level]) {
|
||||
programmes.medians[level] = {}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { ignoredFacultyCodes } from '../../config/organizationConstants'
|
||||
import { Organization } from '../../models'
|
||||
import { getOrganizations } from '../organizations'
|
||||
|
||||
export const getFaculties = async (): Promise<Organization[]> => {
|
||||
export const getFaculties = async () => {
|
||||
const faculties = (await getOrganizations()).filter(faculty => !ignoredFacultyCodes.includes(faculty.code))
|
||||
return faculties
|
||||
}
|
||||
|
||||
export const getSortedFaculties = async (): Promise<Organization[]> => {
|
||||
export const getSortedFaculties = async () => {
|
||||
const faculties = await getFaculties()
|
||||
return faculties.sort((a, b) => {
|
||||
const nameA = a.name.fi ?? ''
|
||||
@@ -16,8 +15,11 @@ export const getSortedFaculties = async (): Promise<Organization[]> => {
|
||||
})
|
||||
}
|
||||
|
||||
const commissionedProgrammes = ['KH50_009', 'MH50_015', 'T923103-N']
|
||||
|
||||
export const checkCommissioned = (studyRight: any): boolean => {
|
||||
return studyRight.studyrightElements.some(element => commissionedProgrammes.includes(element.code))
|
||||
export const getFacultiesForFacultyList = async () => {
|
||||
const faculties = await getSortedFaculties()
|
||||
return faculties.map(faculty => ({
|
||||
code: faculty.code,
|
||||
id: faculty.id,
|
||||
name: faculty.name,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { Name } from '../../shared/types'
|
||||
import { DegreeProgrammeType, Graduated, ProgrammeFilter, SpecialGroups, YearType } from '../../types'
|
||||
import { Graduated, Name, ProgrammeFilter, SpecialGroups, YearType } from '../../shared/types'
|
||||
import { DegreeProgrammeType } from '../../types'
|
||||
import { redisClient } from '../redis'
|
||||
import { FacultyProgressData } from './facultyStudentProgress'
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { cloneDeep } from 'lodash'
|
||||
import moment from 'moment'
|
||||
|
||||
import { rootOrgId } from '../../config'
|
||||
import { Graduated, SpecialGroups, Unarray } from '../../types'
|
||||
import { Graduated, SpecialGroups } from '../../shared/types'
|
||||
import { Unarray } from '../../types'
|
||||
import { getStudyTrackStats, setStudyTrackStats } from '../analyticsService'
|
||||
import { getYearsArray } from '../studyProgramme/studyProgrammeHelpers'
|
||||
import { getStudyRightsInProgramme } from '../studyProgramme/studyRightFinders'
|
||||
|
||||
@@ -133,7 +133,7 @@ export const combineFacultyThesisWriters = async (
|
||||
tableStats,
|
||||
graphStats,
|
||||
programmeTableStats,
|
||||
titles: ['', 'All', 'Bachelors', 'Masters'],
|
||||
titles: ['Code', 'All', 'Bachelors', 'Masters'],
|
||||
programmeNames,
|
||||
status: 'Done',
|
||||
lastUpdated: '',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { StatsType } from '../../types'
|
||||
import { StatsType } from '../../shared/types'
|
||||
import { setCreditStats } from '../analyticsService'
|
||||
import { computeCreditsProduced } from '../providerCredits'
|
||||
import { getDegreeProgrammesOfFaculty } from './faculty'
|
||||
|
||||
@@ -22,39 +22,34 @@ const getStudentsAndCourses = async (
|
||||
studentNumbers: string[] | null,
|
||||
courseCodes: string[] | undefined
|
||||
): Promise<[number, CoursesQueryResult, EnrollmentsQueryResult]> => {
|
||||
if (!studentNumbers) {
|
||||
const { months, studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents } = params
|
||||
const studentnumbers =
|
||||
selectedStudents ??
|
||||
(await getStudentNumbersWithAllStudyRightElements({
|
||||
studyRights,
|
||||
startDate,
|
||||
endDate,
|
||||
exchangeStudents,
|
||||
nondegreeStudents,
|
||||
transferredOutStudents: transferredStudents,
|
||||
}))
|
||||
const { months, studyRights, startDate, endDate, exchangeStudents, nondegreeStudents, transferredStudents } = params
|
||||
|
||||
const allStudents = studentnumbers.length
|
||||
const courses = await findCourses(studentnumbers, dateMonthsFromNow(startDate, months), courseCodes)
|
||||
const foundCourseCodes = Object.keys(keyBy(courses, 'code'))
|
||||
const filteredCourseCodes = courseCodes?.filter(code => !foundCourseCodes.includes(code))
|
||||
const courseEnrollments = await findCourseEnrollments(
|
||||
studentnumbers,
|
||||
dateMonthsFromNow(startDate, months),
|
||||
filteredCourseCodes
|
||||
)
|
||||
const studentnumbers =
|
||||
studentNumbers ??
|
||||
selectedStudents ??
|
||||
(await getStudentNumbersWithAllStudyRightElements({
|
||||
studyRights,
|
||||
startDate,
|
||||
endDate,
|
||||
exchangeStudents,
|
||||
nondegreeStudents,
|
||||
transferredOutStudents: transferredStudents,
|
||||
}))
|
||||
|
||||
return [allStudents, courses, courseEnrollments]
|
||||
}
|
||||
const allStudents = studentnumbers.length
|
||||
|
||||
const { months, startDate } = params
|
||||
const beforeDate = months && startDate ? dateMonthsFromNow(startDate, months) : new Date()
|
||||
const allStudents = studentNumbers.length
|
||||
const courses = await findCourses(studentNumbers, beforeDate, courseCodes)
|
||||
const foundCourseCodes = Object.keys(keyBy(courses, 'code'))
|
||||
const courses = !studentNumbers
|
||||
? await findCourses(studentnumbers, dateMonthsFromNow(startDate, months), courseCodes)
|
||||
: await findCourses(studentnumbers, beforeDate, courseCodes)
|
||||
|
||||
const foundCourseCodes = [...new Set(courses.map(({ code }) => code))].toSorted()
|
||||
const filteredCourseCodes = courseCodes?.filter(code => !foundCourseCodes.includes(code))
|
||||
const courseEnrollments = await findCourseEnrollments(studentNumbers, beforeDate, filteredCourseCodes)
|
||||
const courseEnrollments = filteredCourseCodes?.length
|
||||
? !studentNumbers
|
||||
? await findCourseEnrollments(studentnumbers, dateMonthsFromNow(startDate, months), filteredCourseCodes)
|
||||
: await findCourseEnrollments(studentnumbers, beforeDate, filteredCourseCodes)
|
||||
: []
|
||||
|
||||
return [allStudents, courses, courseEnrollments]
|
||||
}
|
||||
@@ -149,7 +144,7 @@ export const bottlenecksOf = async (query: Query, studentNumbers: string[] | nul
|
||||
})
|
||||
}
|
||||
if ('credits' in course) {
|
||||
course.credits.forEach(credit => {
|
||||
course.credits?.forEach(credit => {
|
||||
const { studentnumber, passingGrade, improvedGrade, failingGrade, grade, date } = parseCreditInfo(credit)
|
||||
if (query?.selectedStudents?.includes(studentnumber) || !query?.selectedStudents) {
|
||||
const semester = getPassingSemester(startYear, date)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Name } from '../../shared/types'
|
||||
import { CreditTypeCode, DegreeProgrammeType, EnrollmentType, ExtentCode, SemesterEnrollment } from '../../types'
|
||||
import { redisClient } from '../redis'
|
||||
import { getCurrentSemester } from '../semesters'
|
||||
import { getCurriculumVersion } from './shared'
|
||||
|
||||
export const CLOSE_TO_GRADUATION_REDIS_KEY = 'CLOSE_TO_GRADUATION_DATA'
|
||||
@@ -31,6 +32,7 @@ type AccumulatorType = {
|
||||
email: string
|
||||
phoneNumber: string
|
||||
secondaryEmail: string
|
||||
preferredLanguage: string
|
||||
}
|
||||
studyright: {
|
||||
startDate: Date
|
||||
@@ -52,6 +54,7 @@ type AccumulatorType = {
|
||||
faculty: Name
|
||||
attainmentDates: AttainmentDates
|
||||
numberOfAbsentSemesters: number
|
||||
numberOfUsedSemesters: number
|
||||
curriculumPeriod: string | null
|
||||
credits: {
|
||||
hops: number
|
||||
@@ -98,13 +101,18 @@ const findThesisAndLatestAndEarliestAttainments = (
|
||||
return { attainmentDates, thesisData }
|
||||
}
|
||||
|
||||
const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<string, Name>) => {
|
||||
const formatStudent = (
|
||||
student: InferAttributes<Student>,
|
||||
facultyMap: Record<string, Name>,
|
||||
currentSemesterCode: number
|
||||
) => {
|
||||
const {
|
||||
studentnumber: studentNumber,
|
||||
abbreviatedname: name,
|
||||
sis_person_id,
|
||||
email,
|
||||
phone_number: phoneNumber,
|
||||
preferredLanguage,
|
||||
secondary_email: secondaryEmail,
|
||||
} = student
|
||||
return student.studyplans.reduce<AccumulatorType[]>((acc, studyPlan) => {
|
||||
@@ -117,10 +125,20 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
|
||||
semesterEnrollments,
|
||||
} = studyRight
|
||||
|
||||
const numberOfAbsentSemesters = (semesterEnrollments ?? []).reduce(
|
||||
(acc, enrollment) => (enrollment.type === EnrollmentType.ABSENT ? acc + 1 : acc),
|
||||
0
|
||||
)
|
||||
let numberOfAbsentSemesters = 0
|
||||
let numberOfUsedSemesters = 0
|
||||
|
||||
if (semesterEnrollments != null) {
|
||||
for (const enrollment of semesterEnrollments) {
|
||||
if (enrollment.semester > currentSemesterCode) continue
|
||||
|
||||
if (enrollment.type === EnrollmentType.ABSENT) {
|
||||
numberOfAbsentSemesters += 1
|
||||
} else {
|
||||
numberOfUsedSemesters += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
code: programmeCode,
|
||||
@@ -137,7 +155,7 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
|
||||
)
|
||||
|
||||
acc.push({
|
||||
student: { studentNumber, name, sis_person_id, email, phoneNumber, secondaryEmail },
|
||||
student: { studentNumber, name, sis_person_id, email, phoneNumber, secondaryEmail, preferredLanguage },
|
||||
studyright: {
|
||||
startDate: startOfStudyright,
|
||||
semesterEnrollments,
|
||||
@@ -160,6 +178,7 @@ const formatStudent = (student: InferAttributes<Student>, facultyMap: Record<str
|
||||
faculty: facultyMap[programmeCode],
|
||||
attainmentDates,
|
||||
numberOfAbsentSemesters,
|
||||
numberOfUsedSemesters,
|
||||
curriculumPeriod: getCurriculumVersion(studyPlan.curriculum_period_id),
|
||||
credits: {
|
||||
hops: studyPlan.completed_credits,
|
||||
@@ -178,6 +197,7 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
|
||||
'creditcount',
|
||||
'email',
|
||||
'phone_number',
|
||||
'preferredLanguage',
|
||||
'secondary_email',
|
||||
'sis_person_id',
|
||||
'studentnumber',
|
||||
@@ -294,8 +314,10 @@ export const findStudentsCloseToGraduation = async (studentNumbers?: string[]) =
|
||||
facultyMap[programme.code] = programme['organization.name']
|
||||
}
|
||||
|
||||
const { semestercode: currentSemesterCode } = await getCurrentSemester()
|
||||
|
||||
return students
|
||||
.flatMap(student => formatStudent(student, facultyMap))
|
||||
.flatMap(student => formatStudent(student, facultyMap, currentSemesterCode))
|
||||
.reduce(
|
||||
(acc, student) => {
|
||||
if (student.programme.degreeProgrammeType === DegreeProgrammeType.BACHELOR) {
|
||||
|
||||
@@ -97,9 +97,7 @@ const getStudents = async (studentNumbers: string[]) => {
|
||||
'firstnames',
|
||||
'lastname',
|
||||
'studentnumber',
|
||||
'home_country_en',
|
||||
'home_country_fi',
|
||||
'home_country_sv',
|
||||
'citizenships',
|
||||
'dateofuniversityenrollment',
|
||||
'creditcount',
|
||||
'abbreviatedname',
|
||||
@@ -146,6 +144,7 @@ const getStudents = async (studentNumbers: string[]) => {
|
||||
attributes: [
|
||||
'included_courses',
|
||||
'programme_code',
|
||||
'includedModules',
|
||||
'completed_credits',
|
||||
'curriculum_period_id',
|
||||
'sis_study_right_id',
|
||||
|
||||
@@ -5,11 +5,11 @@ import { getDegreeProgrammeType } from '../../util'
|
||||
import { getCriteria } from '../studyProgramme/studyProgrammeCriteria'
|
||||
import { getStudentsIncludeCoursesBetween } from './getStudentsIncludeCoursesBetween'
|
||||
import {
|
||||
Query as FormattedQueryParams,
|
||||
dateMonthsFromNow,
|
||||
formatStudentsForApi,
|
||||
getOptionsForStudents,
|
||||
parseQueryParams,
|
||||
Query as FormattedQueryParams,
|
||||
} from './shared'
|
||||
import { getStudentNumbersWithAllStudyRightElements } from './studentNumbersWithAllElements'
|
||||
|
||||
@@ -54,6 +54,7 @@ export const optimizedStatisticsOf = async (query: Query, studentNumberList?: st
|
||||
let criteria = {} as Criteria
|
||||
|
||||
const degreeProgrammeType = await getDegreeProgrammeType(code)
|
||||
|
||||
if (degreeProgrammeType === DegreeProgrammeType.MASTER || degreeProgrammeType === DegreeProgrammeType.BACHELOR) {
|
||||
optionData = await getOptionsForStudents(studentNumbers, code, degreeProgrammeType)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { orderBy } from 'lodash'
|
||||
import moment from 'moment'
|
||||
import { Op, QueryTypes } from 'sequelize'
|
||||
|
||||
import { dbConnections } from '../../database/connection'
|
||||
@@ -34,43 +33,93 @@ const createEmptyCriteriaYear = (criteria: Criteria, year: string): CriteriaYear
|
||||
}
|
||||
}
|
||||
|
||||
const getCreditAmount = (
|
||||
course: ParsedCourse,
|
||||
hops: Studyplan[],
|
||||
courseCode: string,
|
||||
startDate: string,
|
||||
addition: number
|
||||
) => {
|
||||
return moment(course.date).isBetween(moment(startDate), moment(startDate).add(addition, 'year')) &&
|
||||
hops.length > 0 &&
|
||||
(hops[0].included_courses.includes(courseCode) || hops[0].included_courses.includes(course.course_code))
|
||||
? course.credits
|
||||
: 0
|
||||
/**
|
||||
* @returns A new date with year incremented by the given amount
|
||||
*/
|
||||
const dateYearsFromNow = (date: Date, years: number) => {
|
||||
const newDate = new Date(date)
|
||||
newDate.setFullYear(newDate.getFullYear() + years)
|
||||
return newDate
|
||||
}
|
||||
|
||||
/**
|
||||
* @param date The initial Date as ISOString
|
||||
* @param months Number of months to add to the start date
|
||||
* @returns A new date with months incremented by the given amount
|
||||
*/
|
||||
export const dateMonthsFromNow = (date: string, months: number) => {
|
||||
const initialDate = new Date(date)
|
||||
return new Date(initialDate.setMonth(initialDate.getMonth() + months))
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns A new Date with amount of days added to the start date
|
||||
*/
|
||||
const dateDaysFromNow = (date: Date, days: number) => new Date(date.getTime() + 24 * 60 * 60 * 1000 * days)
|
||||
|
||||
const getCreditAmount = (course: ParsedCourse, hops: Studyplan[], courseCode: string, startDate: string) => {
|
||||
const courseDate = new Date(course.date)
|
||||
const startDateFromString = new Date(startDate)
|
||||
|
||||
const creditAmounts = [0, 0, 0, 0, 0, 0]
|
||||
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
if (
|
||||
courseDate > startDateFromString &&
|
||||
courseDate < dateYearsFromNow(startDateFromString, i) &&
|
||||
hops.length > 0 &&
|
||||
(hops[0].included_courses.includes(courseCode) || hops[0].included_courses.includes(course.course_code))
|
||||
) {
|
||||
creditAmounts[i - 1] = course.credits
|
||||
}
|
||||
}
|
||||
return creditAmounts
|
||||
}
|
||||
|
||||
const updateCourseByYear = (
|
||||
criteria: Criteria,
|
||||
criteriaYear: string,
|
||||
course: ParsedCourse,
|
||||
criteriaChecked: Record<string, CriteriaYear>,
|
||||
yearToAdd: string,
|
||||
correctCode: string
|
||||
) => {
|
||||
// TODO: Clean up this mess
|
||||
if (
|
||||
criteria?.courses?.[criteriaYear] &&
|
||||
(criteria.courses[criteriaYear].includes(course.course_code) ||
|
||||
criteria.courses[criteriaYear].some((criteriaCourse: string) =>
|
||||
criteria.allCourses[criteriaCourse]?.includes(course.course_code)
|
||||
))
|
||||
) {
|
||||
if (
|
||||
!criteriaChecked[yearToAdd].coursesSatisfied[correctCode] ||
|
||||
new Date(criteriaChecked[yearToAdd].coursesSatisfied[correctCode]) > new Date(course.date)
|
||||
) {
|
||||
criteriaChecked[yearToAdd].coursesSatisfied[correctCode] = course.date
|
||||
const yearMap = [
|
||||
{ criteriaYear: 'yearOne', yearToAdd: 'year1' },
|
||||
{ criteriaYear: 'yearTwo', yearToAdd: 'year2' },
|
||||
{ criteriaYear: 'yearThree', yearToAdd: 'year3' },
|
||||
{ criteriaYear: 'yearFour', yearToAdd: 'year4' },
|
||||
{ criteriaYear: 'yearFive', yearToAdd: 'year5' },
|
||||
{ criteriaYear: 'yearSix', yearToAdd: 'year6' },
|
||||
]
|
||||
|
||||
const courseSets = yearMap.reduce(
|
||||
(acc, { criteriaYear }) => {
|
||||
const yearCourses = criteria?.courses?.[criteriaYear] || []
|
||||
const expandedCodes = new Set<string>()
|
||||
|
||||
yearCourses.forEach((mainCode: string) => {
|
||||
expandedCodes.add(mainCode)
|
||||
|
||||
criteria.allCourses[mainCode]?.forEach((subCode: string) => {
|
||||
expandedCodes.add(subCode)
|
||||
})
|
||||
})
|
||||
|
||||
acc[criteriaYear] = expandedCodes
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Set<string>>
|
||||
)
|
||||
|
||||
yearMap.forEach(({ criteriaYear, yearToAdd }) => {
|
||||
const yearSet = courseSets[criteriaYear]
|
||||
|
||||
if (yearSet?.has(course.course_code)) {
|
||||
const currentDate = criteriaChecked[yearToAdd].coursesSatisfied[correctCode]
|
||||
if (!currentDate || new Date(currentDate) > new Date(course.date)) {
|
||||
criteriaChecked[yearToAdd].coursesSatisfied[correctCode] = course.date
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateCreditCriteriaInfo = (
|
||||
@@ -113,16 +162,18 @@ const formatStudentForPopulationStatistics = (
|
||||
enrollments: Record<string, Enrollment[]>,
|
||||
credits: Record<string, Credit[]>,
|
||||
startDate: string,
|
||||
startDateMoment: moment.Moment,
|
||||
endDateMoment: moment.Moment,
|
||||
endDate: string,
|
||||
criteria: Criteria,
|
||||
code: string,
|
||||
optionData: Record<string, { name: Name }>
|
||||
) => {
|
||||
const startDateFromISO = new Date(startDate)
|
||||
const endDateFromISO = new Date(endDate)
|
||||
|
||||
const toCourse = (credit: Credit, normalizeDate: boolean): ParsedCourse => {
|
||||
const attainmentDateNormalized =
|
||||
normalizeDate && credit.attainment_date < new Date(startDate)
|
||||
? startDateMoment.clone().add(1, 'day').toISOString()
|
||||
normalizeDate && credit.attainment_date < startDateFromISO
|
||||
? dateDaysFromNow(startDateFromISO, 1).toISOString()
|
||||
: credit.attainment_date.toISOString()
|
||||
const passed =
|
||||
Credit.passed({ credittypecode: credit.credittypecode }) ||
|
||||
@@ -159,9 +210,7 @@ const formatStudentForPopulationStatistics = (
|
||||
tags,
|
||||
birthdate,
|
||||
sis_person_id,
|
||||
home_country_fi,
|
||||
home_country_sv,
|
||||
home_country_en,
|
||||
citizenships,
|
||||
} = student
|
||||
|
||||
const criteriaCoursesBySubstitutions = criteria?.allCourses
|
||||
@@ -195,9 +244,16 @@ const formatStudentForPopulationStatistics = (
|
||||
correctStudyRightElement!
|
||||
)
|
||||
if (hasTransferredToProgramme) {
|
||||
const transferredFromProgramme = correctStudyRight.studyRightElements.find(element =>
|
||||
moment(element.endDate).isSame(moment(correctStudyRightElement?.startDate).subtract(1, 'd'), 'day')
|
||||
)?.code
|
||||
const transferredFromProgramme = correctStudyRight.studyRightElements.find(element => {
|
||||
const studyRightStart = new Date(correctStudyRightElement?.startDate ?? 0)
|
||||
const studyRightEndDate = new Date(element.endDate)
|
||||
const studyRightStartDate = dateDaysFromNow(studyRightStart, -1)
|
||||
return (
|
||||
studyRightStartDate.getFullYear() === studyRightEndDate.getFullYear() &&
|
||||
studyRightStartDate.getMonth() === studyRightEndDate.getMonth() &&
|
||||
studyRightStartDate.getDate() === studyRightEndDate.getDate()
|
||||
)
|
||||
})?.code
|
||||
if (transferredFromProgramme) {
|
||||
transferredStudyright = true
|
||||
transferSource = transferredFromProgramme
|
||||
@@ -217,23 +273,21 @@ const formatStudentForPopulationStatistics = (
|
||||
}
|
||||
|
||||
const academicYears = { first: 0, second: 0, third: 0, fourth: 0, fifth: 0, sixth: 0 }
|
||||
|
||||
if (criteria.courses || criteria.credits) {
|
||||
const courses = credits[studentnumber] ? credits[studentnumber].map(credit => toCourse(credit, true)) : []
|
||||
|
||||
courses.forEach(course => {
|
||||
if (course.passed) {
|
||||
const correctCode = criteriaCoursesBySubstitutions[course.course_code]
|
||||
updateCourseByYear(criteria, 'yearOne', course, criteriaChecked, 'year1', correctCode)
|
||||
updateCourseByYear(criteria, 'yearTwo', course, criteriaChecked, 'year2', correctCode)
|
||||
updateCourseByYear(criteria, 'yearThree', course, criteriaChecked, 'year3', correctCode)
|
||||
updateCourseByYear(criteria, 'yearFour', course, criteriaChecked, 'year4', correctCode)
|
||||
updateCourseByYear(criteria, 'yearFive', course, criteriaChecked, 'year5', correctCode)
|
||||
updateCourseByYear(criteria, 'yearSix', course, criteriaChecked, 'year6', correctCode)
|
||||
academicYears.first += getCreditAmount(course, correctStudyplan, correctCode, startDate, 1)
|
||||
academicYears.second += getCreditAmount(course, correctStudyplan, correctCode, startDate, 2)
|
||||
academicYears.third += getCreditAmount(course, correctStudyplan, correctCode, startDate, 3)
|
||||
academicYears.fourth += getCreditAmount(course, correctStudyplan, correctCode, startDate, 4)
|
||||
academicYears.fifth += getCreditAmount(course, correctStudyplan, correctCode, startDate, 5)
|
||||
academicYears.sixth += getCreditAmount(course, correctStudyplan, correctCode, startDate, 6)
|
||||
|
||||
updateCourseByYear(criteria, course, criteriaChecked, correctCode)
|
||||
|
||||
const creditAmounts = getCreditAmount(course, correctStudyplan, correctCode, startDate)
|
||||
|
||||
Object.keys(academicYears).forEach((year, index) => {
|
||||
academicYears[year] += creditAmounts[index]
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -248,6 +302,7 @@ const formatStudentForPopulationStatistics = (
|
||||
}
|
||||
|
||||
const started = dateofuniversityenrollment
|
||||
const starting = +startDateFromISO <= +started && +started <= +endDateFromISO
|
||||
|
||||
return {
|
||||
firstnames,
|
||||
@@ -266,14 +321,12 @@ const formatStudentForPopulationStatistics = (
|
||||
updatedAt: updatedAt || createdAt,
|
||||
tags: tags ?? [],
|
||||
studyrightStart: startDate,
|
||||
starting: moment(started).isBetween(startDateMoment, endDateMoment, null, '[]'),
|
||||
starting,
|
||||
option,
|
||||
birthdate,
|
||||
studyplans,
|
||||
sis_person_id,
|
||||
home_country_en,
|
||||
home_country_fi,
|
||||
home_country_sv,
|
||||
citizenships,
|
||||
criteriaProgress: toProgressCriteria(),
|
||||
curriculumVersion: getCurriculumVersion(correctStudyplan[0]?.curriculum_period_id),
|
||||
transferredStudyright,
|
||||
@@ -281,10 +334,6 @@ const formatStudentForPopulationStatistics = (
|
||||
}
|
||||
}
|
||||
|
||||
export const dateMonthsFromNow = (date: string, months: number) => {
|
||||
return new Date(moment(date).add(months, 'months').format('YYYY-MM-DD'))
|
||||
}
|
||||
|
||||
export type Query = {
|
||||
year: string
|
||||
months: number
|
||||
@@ -414,9 +463,6 @@ export const formatStudentsForApi = (
|
||||
criteria: Criteria,
|
||||
code: string
|
||||
) => {
|
||||
const startDateMoment = moment(startDate)
|
||||
const endDateMoment = moment(endDate)
|
||||
|
||||
const creditsByStudent = credits.reduce(
|
||||
(acc, credit) => {
|
||||
acc[credit.student_studentnumber] = acc[credit.student_studentnumber] || []
|
||||
@@ -441,8 +487,7 @@ export const formatStudentsForApi = (
|
||||
enrollmentsByStudent,
|
||||
creditsByStudent,
|
||||
startDate,
|
||||
startDateMoment,
|
||||
endDateMoment,
|
||||
endDate,
|
||||
criteria,
|
||||
code,
|
||||
optionData
|
||||
@@ -452,24 +497,6 @@ export const formatStudentsForApi = (
|
||||
}
|
||||
}
|
||||
|
||||
const getSubstitutions = async (codes: string[]) => {
|
||||
const courses = await Course.findAll({
|
||||
attributes: ['code', 'substitutions'],
|
||||
where: {
|
||||
code: {
|
||||
[Op.iLike]: { [Op.any]: codes },
|
||||
},
|
||||
},
|
||||
raw: true,
|
||||
})
|
||||
|
||||
const substitutions = [
|
||||
...new Set(courses.flatMap(({ code, substitutions }) => [code, ...Object.values(substitutions).flat()])),
|
||||
]
|
||||
|
||||
return substitutions
|
||||
}
|
||||
|
||||
export type EnrollmentsQueryResult = Array<{
|
||||
code: string
|
||||
name: Name
|
||||
@@ -486,12 +513,10 @@ export type CoursesQueryResult = Array<
|
||||
}
|
||||
>
|
||||
|
||||
// This duplicate code is added here to ensure that we get the enrollments in cases no credits found for the selected students
|
||||
export const findCourseEnrollments = async (studentNumbers: string[], beforeDate: Date, courses: string[] = []) => {
|
||||
const courseCodes = courses.length === 0 ? ['DUMMY'] : await getSubstitutions(courses)
|
||||
const result: EnrollmentsQueryResult = await sequelize.query(
|
||||
`
|
||||
SELECT DISTINCT ON (course.id)
|
||||
SELECT DISTINCT
|
||||
course.code,
|
||||
course.name,
|
||||
course.substitutions,
|
||||
@@ -500,24 +525,32 @@ export const findCourseEnrollments = async (studentNumbers: string[], beforeDate
|
||||
FROM course
|
||||
INNER JOIN (
|
||||
SELECT
|
||||
course_id,
|
||||
ARRAY_AGG(JSON_BUILD_OBJECT(
|
||||
course_code,
|
||||
ARRAY_AGG(JSONB_BUILD_OBJECT(
|
||||
'studentnumber', studentnumber,
|
||||
'state', state,
|
||||
'enrollment_date_time', enrollment_date_time
|
||||
)) AS data
|
||||
FROM enrollment
|
||||
WHERE enrollment.studentnumber IN (:studentnumbers) AND enrollment.enrollment_date_time < :beforeDate AND enrollment.state = :enrollmentState
|
||||
GROUP BY enrollment.course_id
|
||||
) enrollment ON enrollment.course_id = course.id
|
||||
WHERE :skipCourseCodeFilter OR course.code IN (:courseCodes)
|
||||
WHERE studentnumber IN (:studentnumbers)
|
||||
AND enrollment_date_time < :beforeDate
|
||||
AND state = :enrollmentState
|
||||
GROUP BY course_code
|
||||
) AS enrollment
|
||||
ON enrollment.course_code = course.code
|
||||
WHERE course.code IN (:courseCodes)
|
||||
OR (
|
||||
SELECT
|
||||
JSONB_AGG(DISTINCT alt_code)
|
||||
FROM course, LATERAL JSONB_ARRAY_ELEMENTS(substitutions) as alt_code
|
||||
WHERE code IN (:courseCodes)
|
||||
) ? course.code
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
studentnumbers: studentNumbers.length > 0 ? studentNumbers : ['DUMMY'],
|
||||
beforeDate,
|
||||
courseCodes,
|
||||
skipCourseCodeFilter: courses.length === 0,
|
||||
courseCodes: courses.length ? courses : ['DUMMY'],
|
||||
enrollmentState: EnrollmentState.ENROLLED,
|
||||
},
|
||||
type: QueryTypes.SELECT,
|
||||
@@ -527,21 +560,36 @@ export const findCourseEnrollments = async (studentNumbers: string[], beforeDate
|
||||
}
|
||||
|
||||
export const findCourses = async (studentNumbers: string[], beforeDate: Date, courses: string[] = []) => {
|
||||
const courseCodes = courses.length === 0 ? ['DUMMY'] : await getSubstitutions(courses)
|
||||
const result: CoursesQueryResult = await sequelize.query(
|
||||
`
|
||||
SELECT DISTINCT ON (course.id)
|
||||
return sequelize.query(
|
||||
courses.length
|
||||
? `
|
||||
SELECT
|
||||
course.code,
|
||||
course.name,
|
||||
course.substitutions,
|
||||
course.main_course_code,
|
||||
credit.data AS credits,
|
||||
enrollment.data AS enrollments
|
||||
enrollment.data AS enrollments,
|
||||
credit.data AS credits
|
||||
FROM course
|
||||
INNER JOIN (
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
course_code,
|
||||
ARRAY_AGG(JSON_BUILD_OBJECT(
|
||||
ARRAY_AGG(JSONB_BUILD_OBJECT(
|
||||
'studentnumber', studentnumber,
|
||||
'state', state,
|
||||
'enrollment_date_time', enrollment_date_time
|
||||
)) AS data
|
||||
FROM enrollment
|
||||
WHERE studentnumber IN (:studentnumbers)
|
||||
AND enrollment_date_time < :beforeDate
|
||||
AND state = :enrollmentState
|
||||
GROUP BY course_code
|
||||
) AS enrollment
|
||||
ON enrollment.course_code = course.code
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
course_code,
|
||||
ARRAY_AGG(JSONB_BUILD_OBJECT(
|
||||
'grade', grade,
|
||||
'student_studentnumber', student_studentnumber,
|
||||
'attainment_date', attainment_date,
|
||||
@@ -549,35 +597,75 @@ export const findCourses = async (studentNumbers: string[], beforeDate: Date, co
|
||||
'course_code', course_code
|
||||
)) AS data
|
||||
FROM credit
|
||||
WHERE student_studentnumber IN (:studentnumbers) AND attainment_date < :beforeDate
|
||||
GROUP BY credit.course_code
|
||||
) credit ON credit.course_code = course.code
|
||||
WHERE student_studentnumber IN (:studentnumbers)
|
||||
AND attainment_date < :beforeDate
|
||||
GROUP BY course_code
|
||||
) AS credit
|
||||
ON credit.course_code = course.code
|
||||
WHERE
|
||||
(
|
||||
course.code IN (:courseCodes)
|
||||
OR (
|
||||
SELECT
|
||||
JSONB_AGG(DISTINCT alt_code)
|
||||
FROM course, LATERAL JSONB_ARRAY_ELEMENTS(substitutions) as alt_code
|
||||
WHERE code IN (:courseCodes)
|
||||
) ? course.code
|
||||
) AND (enrollment.data IS NOT NULL OR credit.data IS NOT NULL)
|
||||
`
|
||||
: `
|
||||
SELECT
|
||||
course.code,
|
||||
course.name,
|
||||
course.substitutions,
|
||||
course.main_course_code,
|
||||
enrollment.data AS enrollments,
|
||||
credit.data AS credits
|
||||
FROM course
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
course_id,
|
||||
ARRAY_AGG(JSON_BUILD_OBJECT(
|
||||
course_code,
|
||||
ARRAY_AGG(JSONB_BUILD_OBJECT(
|
||||
'studentnumber', studentnumber,
|
||||
'state', state,
|
||||
'enrollment_date_time', enrollment_date_time
|
||||
)) AS data
|
||||
FROM enrollment
|
||||
WHERE enrollment.studentnumber IN (:studentnumbers) AND enrollment.enrollment_date_time < :beforeDate AND enrollment.state = :enrollmentState
|
||||
GROUP BY enrollment.course_id
|
||||
) enrollment ON enrollment.course_id = course.id
|
||||
WHERE :skipCourseCodeFilter OR course.code IN (:courseCodes)
|
||||
WHERE studentnumber IN (:studentnumbers)
|
||||
AND enrollment_date_time < :beforeDate
|
||||
AND state = :enrollmentState
|
||||
GROUP BY course_code
|
||||
) AS enrollment
|
||||
ON enrollment.course_code = course.code
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
course_code,
|
||||
ARRAY_AGG(JSONB_BUILD_OBJECT(
|
||||
'grade', grade,
|
||||
'student_studentnumber', student_studentnumber,
|
||||
'attainment_date', attainment_date,
|
||||
'credittypecode', credittypecode,
|
||||
'course_code', course_code
|
||||
)) AS data
|
||||
FROM credit
|
||||
WHERE student_studentnumber IN (:studentnumbers)
|
||||
AND attainment_date < :beforeDate
|
||||
GROUP BY course_code
|
||||
) AS credit
|
||||
ON credit.course_code = course.code
|
||||
WHERE
|
||||
enrollment.data IS NOT NULL OR credit.data IS NOT NULL
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
studentnumbers: studentNumbers.length > 0 ? studentNumbers : ['DUMMY'],
|
||||
beforeDate,
|
||||
courseCodes,
|
||||
skipCourseCodeFilter: courses.length === 0,
|
||||
courseCodes: courses.length ? courses : ['DUMMY'],
|
||||
enrollmentState: EnrollmentState.ENROLLED,
|
||||
},
|
||||
type: QueryTypes.SELECT,
|
||||
}
|
||||
)
|
||||
return result
|
||||
) as Promise<CoursesQueryResult>
|
||||
}
|
||||
|
||||
export const parseCreditInfo = (credit: CreditPick) => {
|
||||
|
||||
@@ -13,6 +13,16 @@ type SemestersAndYears = {
|
||||
}
|
||||
}
|
||||
|
||||
export const getSemesterNameByCode = async (semesterCode: number) => {
|
||||
const semester = await Semester.findOne({
|
||||
attributes: ['name'],
|
||||
where: {
|
||||
semestercode: semesterCode,
|
||||
},
|
||||
})
|
||||
return semester!
|
||||
}
|
||||
|
||||
export const getCurrentSemester = async () => {
|
||||
const today = new Date()
|
||||
const currentSemester = await Semester.findOne({
|
||||
|
||||
@@ -9,7 +9,7 @@ const params = { params: { token: SECRET_TOKEN } }
|
||||
|
||||
export const abortUpdate = async () => {
|
||||
const response = await client.get('/v1/abort', params)
|
||||
return response.data
|
||||
return response.data?.message
|
||||
}
|
||||
|
||||
export const updateSISCoursesByCourseCode = async (coursecodes: string[]) => {
|
||||
|
||||
@@ -303,7 +303,7 @@ const nameLike = (terms: string[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
const studentnumberLike = (terms: string[]) => {
|
||||
const studentNumberLike = (terms: string[]) => {
|
||||
return {
|
||||
studentnumber: {
|
||||
[Op.iLike]: likefy(terms[0]),
|
||||
@@ -311,8 +311,26 @@ const studentnumberLike = (terms: string[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const bySearchTermAndStudentNumbers = async (searchterm: string, studentNumbers?: string[]) => {
|
||||
const terms = splitByEmptySpace(searchterm)
|
||||
const formatStudentForSearch = ({
|
||||
creditcount,
|
||||
dateofuniversityenrollment,
|
||||
firstnames,
|
||||
lastname,
|
||||
studentnumber,
|
||||
studyRights,
|
||||
}: InferAttributes<Student>) => {
|
||||
return {
|
||||
activeStudyRights: studyRights || [],
|
||||
credits: creditcount || 0,
|
||||
firstNames: firstnames,
|
||||
lastName: lastname,
|
||||
studentNumber: studentnumber,
|
||||
started: dateofuniversityenrollment,
|
||||
}
|
||||
}
|
||||
|
||||
export const bySearchTermAndStudentNumbers = async (searchTerm: string, studentNumbers?: string[]) => {
|
||||
const terms = splitByEmptySpace(searchTerm)
|
||||
return (
|
||||
await Student.findAll({
|
||||
attributes: ['studentnumber', 'firstnames', 'lastname', 'creditcount', 'dateofuniversityenrollment'],
|
||||
@@ -337,18 +355,19 @@ export const bySearchTermAndStudentNumbers = async (searchterm: string, studentN
|
||||
where: studentNumbers
|
||||
? {
|
||||
[Op.and]: {
|
||||
[Op.or]: [nameLike(terms), studentnumberLike(terms)],
|
||||
[Op.or]: [nameLike(terms), studentNumberLike(terms)],
|
||||
studentnumber: {
|
||||
[Op.in]: studentNumbers,
|
||||
},
|
||||
},
|
||||
}
|
||||
: { [Op.or]: [nameLike(terms), studentnumberLike(terms)] },
|
||||
: { [Op.or]: [nameLike(terms), studentNumberLike(terms)] },
|
||||
order: [['dateofuniversityenrollment', 'DESC NULLS LAST']],
|
||||
})
|
||||
).map(formatSharedStudentData)
|
||||
).map(formatStudentForSearch)
|
||||
}
|
||||
|
||||
export const filterStudentnumbersByAccessrights = async (studentnumbers: string[], codes: string[]) =>
|
||||
export const filterStudentNumbersByAccessRights = async (studentnumbers: string[], codes: string[]) =>
|
||||
(
|
||||
await Student.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
|
||||
@@ -41,7 +41,7 @@ type StudyGuidanceGroup = {
|
||||
}
|
||||
|
||||
const getGroupsFromImporter = async (sisPersonId: string) => {
|
||||
if (!importerClient) {
|
||||
if (!importerClient || !sisPersonId) {
|
||||
return []
|
||||
}
|
||||
const answerTimeout = new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
@@ -114,7 +114,30 @@ export const getTransferredCredits = async (provider: string, since: Date) =>
|
||||
},
|
||||
})
|
||||
|
||||
export const getThesisCredits = async (provider: string, thesisType: string[], studentnumbers: string[]) =>
|
||||
export const getStudyRightThesisCredits = async (thesisType: string[], studyRightIds: string[]) =>
|
||||
await Credit.findAll({
|
||||
attributes: ['attainment_date', 'student_studentnumber'],
|
||||
include: {
|
||||
model: Course,
|
||||
attributes: [],
|
||||
where: {
|
||||
course_unit_type: {
|
||||
[Op.in]: thesisType,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
credittypecode: CreditTypeCode.PASSED,
|
||||
isStudyModule: {
|
||||
[Op.not]: true,
|
||||
},
|
||||
studyright_id: {
|
||||
[Op.in]: studyRightIds,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const getOrganizationThesisCredits = async (provider: string, thesisType: string[], studentnumbers: string[]) =>
|
||||
await Credit.findAll({
|
||||
attributes: ['attainment_date', 'student_studentnumber'],
|
||||
include: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DegreeProgrammeType, ExtentCode, Phase, SemesterEnrollment, StudyTrack
|
||||
import { getDegreeProgrammeType, getMinimumCreditsOfProgramme, sortByProgrammeCode } from '../../util'
|
||||
import { countTimeCategories } from '../graduationHelpers'
|
||||
import { getSemestersAndYears } from '../semesters'
|
||||
import { getThesisCredits } from './creditGetters'
|
||||
import { getStudyRightThesisCredits, getOrganizationThesisCredits } from './creditGetters'
|
||||
import { getGraduatedStats } from './studyProgrammeBasics'
|
||||
import {
|
||||
defineYear,
|
||||
@@ -101,19 +101,35 @@ const getGraduationTimeAndThesisWriterStats = async ({
|
||||
thesisTableStats: tableStats,
|
||||
}
|
||||
}
|
||||
|
||||
const doCombo = await shouldIncludeComboStats(studyProgramme)
|
||||
const graduationTimes: Record<string, number[]> = getYearsObject({ years, emptyArrays: true })
|
||||
const graduationTimesCombo: Record<string, number[]> = getYearsObject({ years, emptyArrays: true })
|
||||
const { semesters } = await getSemestersAndYears()
|
||||
|
||||
const studyRights = await getStudyRightsInProgramme(studyProgramme, false)
|
||||
const studentNumbers = studyRights.map(studyRight => studyRight.studentNumber)
|
||||
const thesisType = await getThesisType(studyProgramme)
|
||||
const thesisCredits = await getThesisCredits(mapToProviders([studyProgramme])[0], thesisType, studentNumbers)
|
||||
const thesisWriterMap = thesisCredits.reduce<Record<string, Date>>((acc, credit) => {
|
||||
acc[credit.student_studentnumber] = credit.attainment_date
|
||||
return acc
|
||||
}, {})
|
||||
const studyRights = await getStudyRightsInProgramme(studyProgramme, false)
|
||||
const studyRightIds = studyRights.map(studyRight => studyRight.id)
|
||||
const studentNumbers = studyRights.map(studyRight => studyRight.studentNumber)
|
||||
|
||||
const studyRightThesisCredits = await getStudyRightThesisCredits(thesisType, studyRightIds)
|
||||
|
||||
const studyRightThesisStudentNumbers = studyRightThesisCredits.map(item => item.student_studentnumber)
|
||||
const organizationStudentNumbers = studentNumbers.filter(student => !studyRightThesisStudentNumbers.includes(student))
|
||||
|
||||
const organizationThesisCredits = await getOrganizationThesisCredits(
|
||||
mapToProviders([studyProgramme])[0],
|
||||
thesisType,
|
||||
organizationStudentNumbers
|
||||
)
|
||||
|
||||
const thesisWriterMap = [...studyRightThesisCredits, ...organizationThesisCredits].reduce<Record<string, Date>>(
|
||||
(acc, credit) => {
|
||||
acc[credit.student_studentnumber] = credit.attainment_date
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
for (const studyRight of studyRights) {
|
||||
const correctStudyRightElement = studyRight.studyRightElements.find(element => element.code === studyProgramme)
|
||||
|
||||
@@ -35,7 +35,7 @@ export const getStudyRightsInProgramme = async (
|
||||
if (includeStudentsAndCredits) {
|
||||
include.push({
|
||||
model: Student,
|
||||
attributes: ['gender_code', 'home_country_en'],
|
||||
attributes: ['gender_code', 'citizenships'],
|
||||
include: [
|
||||
{
|
||||
model: Credit,
|
||||
|
||||
@@ -267,16 +267,20 @@ const getMainStatsByTrackAndYear = async (
|
||||
}
|
||||
}
|
||||
|
||||
const studentHomeCountry = studyRight.student.home_country_en
|
||||
const studentCitizenships = studyRight.student.citizenships
|
||||
|
||||
if (studentHomeCountry === 'Finland') {
|
||||
yearlyStats[year][programmeOrStudyTrack].finnish += 1
|
||||
} else {
|
||||
yearlyStats[year][programmeOrStudyTrack].otherCountries += 1
|
||||
if (!yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry]) {
|
||||
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry] = 0
|
||||
for (const citizenship of studentCitizenships) {
|
||||
const country = citizenship.en
|
||||
if (!country) {
|
||||
continue
|
||||
}
|
||||
if (country === 'Finland') {
|
||||
yearlyStats[year][programmeOrStudyTrack].finnish += 1
|
||||
} else {
|
||||
yearlyStats[year][programmeOrStudyTrack].otherCountries += 1
|
||||
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[country] ??= 0
|
||||
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[country] += 1
|
||||
}
|
||||
yearlyStats[year][programmeOrStudyTrack].otherCountriesCounts[studentHomeCountry] += 1
|
||||
}
|
||||
|
||||
if (!hasGraduated) return
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
import { Op } from 'sequelize'
|
||||
|
||||
import { Tag, TagStudent } from '../models/kone'
|
||||
import { Tag as TagModel, TagStudent as TagStudentModel } from '../models/kone'
|
||||
import { NewTag, StudentTag, Tag } from '../shared/types'
|
||||
|
||||
const formatTag = (tag: TagModel): Tag => {
|
||||
return {
|
||||
id: tag.tag_id,
|
||||
name: tag.tagname,
|
||||
studyTrack: tag.studytrack,
|
||||
personalUserId: tag.personal_user_id,
|
||||
year: tag.year,
|
||||
}
|
||||
}
|
||||
|
||||
const formatStudentTag = (studentTag: TagStudentModel): StudentTag => {
|
||||
return {
|
||||
studentNumber: studentTag.studentnumber,
|
||||
tag: studentTag.tag ? formatTag(studentTag.tag) : null,
|
||||
tagId: studentTag.tag_id,
|
||||
}
|
||||
}
|
||||
|
||||
export const findTagsByStudyTrack = async (studyTrack: string) => {
|
||||
return Tag.findAll({
|
||||
const tags = await TagModel.findAll({
|
||||
where: {
|
||||
studytrack: {
|
||||
[Op.eq]: studyTrack,
|
||||
},
|
||||
},
|
||||
})
|
||||
return tags.map(tag => formatTag(tag))
|
||||
}
|
||||
|
||||
export const findTagsFromStudyTrackById = async (studyTrack: string, tagIds: string[]) => {
|
||||
return Tag.findAll({
|
||||
return TagModel.findAll({
|
||||
where: {
|
||||
studytrack: {
|
||||
[Op.eq]: studyTrack,
|
||||
@@ -25,26 +45,17 @@ export const findTagsFromStudyTrackById = async (studyTrack: string, tagIds: str
|
||||
})
|
||||
}
|
||||
|
||||
export type TagFromFrontend = {
|
||||
studytrack: string
|
||||
tagname: string
|
||||
year: string
|
||||
personal_user_id: string | null
|
||||
}
|
||||
|
||||
export const createNewTag = async (tag: TagFromFrontend) => {
|
||||
if (Number.isNaN(tag.year)) {
|
||||
const newTag = {
|
||||
...tag,
|
||||
year: null,
|
||||
}
|
||||
return Tag.create(newTag)
|
||||
}
|
||||
return Tag.create(tag)
|
||||
export const createNewTag = async (tag: NewTag) => {
|
||||
return TagModel.create({
|
||||
studytrack: tag.studyTrack,
|
||||
tagname: tag.name,
|
||||
personal_user_id: tag.personalUserId,
|
||||
year: Number.isNaN(tag.year) ? null : tag.year,
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteTag = async (tagId: string) => {
|
||||
return Tag.destroy({
|
||||
return TagModel.destroy({
|
||||
where: {
|
||||
tag_id: {
|
||||
[Op.eq]: tagId,
|
||||
@@ -53,10 +64,10 @@ export const deleteTag = async (tagId: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const getStudentTagsByStudyTrack = (studyTrack: string) => {
|
||||
return TagStudent.findAll({
|
||||
export const getStudentTagsByStudyTrack = async (studyTrack: string) => {
|
||||
const studentTags = await TagStudentModel.findAll({
|
||||
include: {
|
||||
model: Tag,
|
||||
model: TagModel,
|
||||
attributes: ['tag_id', 'tagname', 'personal_user_id'],
|
||||
where: {
|
||||
studytrack: {
|
||||
@@ -65,19 +76,21 @@ export const getStudentTagsByStudyTrack = (studyTrack: string) => {
|
||||
},
|
||||
},
|
||||
})
|
||||
return studentTags.map(studentTag => formatStudentTag(studentTag))
|
||||
}
|
||||
|
||||
export type StudentTagFromFrontend = {
|
||||
tag_id: string
|
||||
studentnumber: string
|
||||
}
|
||||
|
||||
export const createMultipleStudentTags = async (tags: StudentTagFromFrontend[]) => {
|
||||
return TagStudent.bulkCreate(tags, { ignoreDuplicates: true })
|
||||
export const createMultipleStudentTags = async (studentTags: StudentTag[]) => {
|
||||
return TagStudentModel.bulkCreate(
|
||||
studentTags.map(studentTag => ({
|
||||
tag_id: studentTag.tagId,
|
||||
studentnumber: studentTag.studentNumber,
|
||||
})),
|
||||
{ ignoreDuplicates: true }
|
||||
)
|
||||
}
|
||||
|
||||
export const deleteMultipleStudentTags = async (tagId: string, studentNumbers: string[]) => {
|
||||
return TagStudent.destroy({
|
||||
return TagStudentModel.destroy({
|
||||
where: {
|
||||
tag_id: {
|
||||
[Op.eq]: tagId,
|
||||
|
||||
@@ -6,8 +6,8 @@ import { serviceProvider } from '../config'
|
||||
import { roles } from '../config/roles'
|
||||
import { sequelizeUser } from '../database/connection'
|
||||
import { User } from '../models/user'
|
||||
import { Role } from '../shared/types'
|
||||
import { DetailedProgrammeRights, ExpandedUser, FormattedUser, IamAccess } from '../types'
|
||||
import { DetailedProgrammeRights, Role } from '../shared/types'
|
||||
import { ExpandedUser, FormattedUser, IamAccess } from '../types'
|
||||
|
||||
import { createLocaleComparator, getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
import * as jami from '../util/jami'
|
||||
@@ -34,7 +34,7 @@ const isRole = (role: string) => {
|
||||
return (roles as readonly string[]).includes(role)
|
||||
}
|
||||
|
||||
export const modifyAccess = async (username: string, rolesOfUser: Record<string, boolean>) => {
|
||||
export const modifyAccess = async (username: string, rolesOfUser: Record<Role, boolean>) => {
|
||||
const user = await User.findOne({ where: { username } })
|
||||
if (!user) {
|
||||
throw new Error(`User ${username} not found`)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export type Graduated = 'GRADUATED_INCLUDED' | 'GRADUATED_EXCLUDED'
|
||||
export type SpecialGroups = 'SPECIAL_INCLUDED' | 'SPECIAL_EXCLUDED'
|
||||
export type ProgrammeFilter = 'NEW_STUDY_PROGRAMMES' | 'ALL_PROGRAMMES'
|
||||
export type YearType = 'ACADEMIC_YEAR' | 'CALENDAR_YEAR'
|
||||
export type StatsType = 'ALL' | 'CREDITS' | 'STUDENT' | 'THESIS'
|
||||
@@ -1,12 +1,10 @@
|
||||
export { CourseWithSubsId, ParsedCourse } from './course'
|
||||
export { CreditTypeCode } from './creditTypeCode'
|
||||
export { Criteria } from './criteria'
|
||||
export { DetailedProgrammeRights } from './detailedProgrammeRights'
|
||||
export { DegreeProgrammeType } from './degreeProgrammeType'
|
||||
export { EnrollmentState } from './enrollmentState'
|
||||
export { EnrollmentType } from './enrollmentType'
|
||||
export { ExtentCode } from './extentCode'
|
||||
export { Graduated, SpecialGroups, StatsType, ProgrammeFilter, YearType } from './faculty'
|
||||
export { GenderCode } from './genderCode'
|
||||
export { IamAccess } from './iamAccess'
|
||||
export { Phase } from './phase'
|
||||
|
||||
@@ -2,8 +2,7 @@ import { InferAttributes } from 'sequelize'
|
||||
|
||||
import { User } from '../models/user'
|
||||
import { Language } from '../shared/language'
|
||||
import { Role } from '../shared/types'
|
||||
import { DetailedProgrammeRights } from './detailedProgrammeRights'
|
||||
import { DetailedProgrammeRights, Role } from '../shared/types'
|
||||
|
||||
export type ExpandedUser = InferAttributes<User> & {
|
||||
iamGroups: string[]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ProgrammeModule } from '../models'
|
||||
import { Role } from '../shared/types'
|
||||
import { DetailedProgrammeRights } from '../types'
|
||||
import { DetailedProgrammeRights, Role } from '../shared/types'
|
||||
|
||||
const isObjectWithKey = (obj: unknown, key: string): obj is Record<string, unknown> => {
|
||||
return typeof obj === 'object' && obj !== null && key in obj
|
||||
|
||||
@@ -26,10 +26,11 @@ export const getUserIamAccess = async (
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const getSisuAccess = !isStaging && sisPersonId != null && sisPersonId !== ''
|
||||
const { data: iamAccess } = await jamiClient.post('/', {
|
||||
userId: sisPersonId,
|
||||
iamGroups,
|
||||
getSisuAccess: !isStaging,
|
||||
getSisuAccess,
|
||||
})
|
||||
const { specialGroup } = iamAccess as { specialGroup: Record<string, boolean> }
|
||||
delete iamAccess.specialGroup
|
||||
|
||||
@@ -5,7 +5,7 @@ const moment = require('moment')
|
||||
|
||||
const { redis, concurrentWorkers } = require('../config')
|
||||
const logger = require('../util/logger')
|
||||
const { defaultJobOptions, queueName } = require('./queue')
|
||||
const { queueName } = require('./queue')
|
||||
|
||||
const connection = {
|
||||
host: redis,
|
||||
@@ -43,6 +43,6 @@ worker.on('failed', (job, error) => {
|
||||
})
|
||||
|
||||
worker.on('active', job => {
|
||||
const attemptInfo = job.attemptsMade > 1 ? `(attempt ${job.attemptsMade}/${defaultJobOptions.attempts})` : ''
|
||||
const attemptInfo = job.attemptsStarted > 1 ? `(attempt ${job.attemptsStarted}/${job.opts.attempts})` : ''
|
||||
logger.info(`Started job: ${job.id} ${attemptInfo}`)
|
||||
})
|
||||
|
||||
@@ -32,6 +32,9 @@ ENV REACT_APP_SERVICE_PROVIDER=$SERVICE_PROVIDER
|
||||
ARG LANGUAGE_CENTER_VIEW_ENABLED
|
||||
ENV REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED=$LANGUAGE_CENTER_VIEW_ENABLED
|
||||
|
||||
ARG SIS_URL
|
||||
ENV REACT_APP_SIS_URL=$SIS_URL
|
||||
|
||||
COPY ./frontend/package* ./
|
||||
RUN npm ci --include=dev
|
||||
COPY ./frontend .
|
||||
|
||||
Generated
+766
-992
File diff suppressed because it is too large
Load Diff
@@ -4,35 +4,35 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build": "tsc --noEmit && NODE_OPTIONS=\"--max-old-space-size=4096\" vite build",
|
||||
"preview": "vite preview",
|
||||
"tsc": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^6.1.6",
|
||||
"@mui/material": "^6.1.6",
|
||||
"@mui/x-date-pickers": "^7.22.3",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"@sentry/browser": "^8.37.1",
|
||||
"axios": "^0.28.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^6.4.7",
|
||||
"@mui/material": "^6.4.7",
|
||||
"@mui/x-date-pickers": "^7.27.1",
|
||||
"@reduxjs/toolkit": "^2.6.0",
|
||||
"@sentry/browser": "^8.55.0",
|
||||
"axios": "^1.8.1",
|
||||
"highcharts": "^11.4.8",
|
||||
"immer": "^10.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"material-react-table": "^3.0.1",
|
||||
"material-react-table": "^3.2.1",
|
||||
"moment": "^2.30.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"query-string": "^9.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-datetime": "^3.2.0",
|
||||
"react-datetime": "^3.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-highcharts": "^16.1.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-markdown": "^9.1.0",
|
||||
"react-range": "^1.10.0",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"scheduler": "^0.23.2",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.2.0",
|
||||
"scheduler": "^0.25.0",
|
||||
"semantic-ui-css": "^2.5.0",
|
||||
"semantic-ui-react": "^3.0.0-beta.2",
|
||||
"unfuck-spa-shibboleth-session": "^4.1.0",
|
||||
@@ -40,13 +40,14 @@
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22.13.9",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-highcharts": "^16.0.6",
|
||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.1"
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
},
|
||||
"overrides": {
|
||||
"react-highcharts": {
|
||||
|
||||
@@ -110,6 +110,7 @@ export const handleRequest = store => next => async action => {
|
||||
export const RTKApi = createApi({
|
||||
reducerPath: 'api',
|
||||
tagTypes: [
|
||||
'CompletedCoursesSearchList',
|
||||
'CustomPopulationSearches',
|
||||
'ProgressCriteria',
|
||||
'Semester',
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
export const closeToGraduationToolTips = `
|
||||
You can filter students by choosing a degree programme and/or a faculty.
|
||||
You can also select multiple programmes or faculties.
|
||||
Please note that if you select a faculty, only the degree programmes of that faculty will be shown.
|
||||
You can use the dropdowns in the ’Faculty’ and ’Programme’ column headers to filter students. You can also select multiple programmes or faculties. Please note that if you select a faculty, only the programmes of that faculty will be shown.
|
||||
|
||||
Students are included in this list based on the number of credits they have completed in their primary study plan.
|
||||
The limits for each study programme are as follows:
|
||||
Students are included in this list based on the number of credits they have completed in their primary study plan. The limits for each study programme are as follows:
|
||||
|
||||
- All bachelor’s programmes: 140 credits
|
||||
- Degree Programme in Veterinary Medicine (eläinlääketieteen lisensiaatin koulutusohjelma): 150 credits
|
||||
@@ -13,6 +10,5 @@ export const closeToGraduationToolTips = `
|
||||
- Master’s Programme in Psychology (psykologian maisteriohjelma): 115 credits
|
||||
- Other master’s programmes: 70 credits
|
||||
|
||||
If you have access to all student data in Oodikone, you can also see all students here.
|
||||
Otherwise you will only see the students in your study guidance groups.
|
||||
If you have access to all student data in Oodikone, you can also see all students here. Otherwise you will only see the students in your study guidance groups.
|
||||
`
|
||||
|
||||
@@ -73,6 +73,9 @@ export const facultyToolTips: Record<string, string> = {
|
||||
start date of the bachelor programme and at the moment, they do not include any transferred credits.
|
||||
Thus, in these statistics some students have fewer credits than in reality.
|
||||
`,
|
||||
interactiveDataTable: `
|
||||
Sort bars in the yearly charts by programme code or other column values. Sorting is done inside the degree group.
|
||||
`,
|
||||
}
|
||||
|
||||
facultyToolTips.studentsStatsOfTheFaculty = studyProgrammeToolTips.studyTrackOverview.replace(
|
||||
@@ -85,5 +88,5 @@ facultyToolTips.studentsOfTheFaculty =
|
||||
.replace('Transferred away', 'Transferred out of programme')
|
||||
.replace('Transferred to', 'Transferred into programme')
|
||||
facultyToolTips.graduatedToggle = studyProgrammeToolTips.graduatedToggle
|
||||
facultyToolTips.studentToggle = studyProgrammeToolTips.studentToggle
|
||||
facultyToolTips.studyRightToggle = studyProgrammeToolTips.studyRightToggle
|
||||
facultyToolTips.yearToggle = studyProgrammeToolTips.yearToggle
|
||||
|
||||
@@ -1,40 +1,23 @@
|
||||
export const filterToolTips = {
|
||||
enrollmentStatus: {
|
||||
label: 'Filter students present, absent or passive',
|
||||
short: `
|
||||
Suodata opiskelijoita sen mukaan ovatko he joko ilmoittautuneet läsnä- tai poissaoleviksi tai jääneet passiivisiksi kyseiselle lukukaudelle. Huom. osa passiivisista opiskelijoista on jo valmistunut ko. ohjelmasta.
|
||||
`,
|
||||
},
|
||||
studyRightStatus: {
|
||||
label: 'Rajaa opiskelijoita opiskeluoikeuden statuksen mukaan',
|
||||
short: `
|
||||
Opiskeluoikeus on **aktiivinen** (active), jos opiskelija on ilmoittautunut läsnä- tai poissaolevaksi käynnissä olevalle lukukaudelle.
|
||||
citizenship:
|
||||
'Rajaa opiskelijoita kansalaisuuden perusteella. Tulokseen sisältyvät opiskelijat, joilla on valitun valtion kansalaisuus, riippumatta siitä, onko heillä myös muita kansalaisuuksia.',
|
||||
courseCredits: 'Rajaa kurssisuorituksia tietyn ajanjakson mukaan. Tämä rajaus ei muuta opiskelijoiden määrää',
|
||||
enrollmentStatus: `
|
||||
Suodata opiskelijoita sen mukaan, ovatko he ilmoittautuneet **läsnäolevaksi** (present), **poissaolevaksi** (absent) vai **laiminlyöneet ilmoittautumisen** (passive) valituille lukukausille. Jos lukukausia on valittuna enemmän kuin yksi, opiskelijan ilmoittautumisen tulee täsmätä jokaisella valitulla lukukaudella.
|
||||
|
||||
Opiskeluoikeus on **passiivinen** (inactive), jos opiskelija ei ole ilmoittautunut läsnä- eikä poissaolevaksi käynnissä olevalle lukukaudelle.
|
||||
Jos opiskelijan opinto-oikeus ei ole ollut voimassa valitun lukukauden aikana, häntä ei lasketa mukaan mihinkään kategoriaan. Huomioi myös, että osa ilmoittautumisen laiminlyöneistä opiskelijoista on saattanut valmistua valitusta ohjelmasta.
|
||||
`,
|
||||
startingYear:
|
||||
'Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.',
|
||||
studentNumber:
|
||||
'Rajaa yksittäisiä opiskelijoita pois tilastoista (blocklist) tai näytä vain määritellyt opiskelijat (allowlist). Vain täsmälliset opiskelijanumerot toimivat.',
|
||||
studyRightStatus: `
|
||||
Opiskeluoikeus on **aktiivinen** (active), jos opiskelija on ilmoittautunut läsnä- tai poissaolevaksi käynnissä olevalle lukukaudelle.
|
||||
|
||||
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Valmistuneet opiskelijat on **suodatettu pois** molemmista kategorioista.
|
||||
`,
|
||||
},
|
||||
transferred: {
|
||||
label: 'Include and exclude students from this program',
|
||||
short: `
|
||||
Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet nykyiseen 2017 alkaneeseen tutkinto-ohjelmaan vanhasta tutkinto-ohjelmasta
|
||||
`,
|
||||
},
|
||||
courseCredits: {
|
||||
label: null,
|
||||
short: 'Rajaa kurssisuorituksia tietyn ajanjakson mukaan. Tämä rajaus ei muuta opiskelijoiden määrää',
|
||||
},
|
||||
studentNumber: {
|
||||
label: null,
|
||||
short: `
|
||||
Rajaa yksittäisiä opiskelijoita pois tilastoista (blocklist) tai näytä vain määritellyt opiskelijat (allowlist). Vain täsmälliset opiskelijanumerot toimivat.
|
||||
`,
|
||||
},
|
||||
startingYear: {
|
||||
label: null,
|
||||
short: `
|
||||
Valitse opiskelijoita sen mukaan, minä vuonna heidän ensimmäinen opinto-oikeutensa yliopistolla alkoi (ei siis ainoastaan tarkasteltavan tutkinnon oikeus). Avoimia opinto-oikeuksia ei oteta huomioon.
|
||||
`,
|
||||
},
|
||||
Opiskeluoikeus on **passiivinen** (inactive), jos opiskelija ei ole ilmoittautunut läsnä- eikä poissaolevaksi käynnissä olevalle lukukaudelle.
|
||||
|
||||
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Valmistuneet opiskelijat on **suodatettu pois** molemmista kategorioista.
|
||||
`,
|
||||
transferred:
|
||||
'Mahdollisuus suodattaa opiskelijat, jotka ovat siirtyneet nykyiseen 2017 alkaneeseen tutkinto-ohjelmaan vanhasta tutkinto-ohjelmasta',
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ export { filterToolTips } from './filters'
|
||||
export { populationStatisticsToolTips } from './populationStatistics'
|
||||
export { studentToolTips } from './students'
|
||||
export { studyProgrammeToolTips } from './studyProgramme'
|
||||
export { updaterToolTips } from './updater'
|
||||
export { userToolTips } from './users'
|
||||
|
||||
@@ -172,6 +172,11 @@ export const populationStatisticsToolTips = {
|
||||
- ✓ Harmaa tarkastusmerkki = Opiskelija on suorittanut kurssin vastaavalla kurssikoodilla
|
||||
- — Keltainen viiva = Opiskelijalla on alle 6kk vanha ilmoittautuminen kurssille
|
||||
- — Harmaa viiva = Opiskelijalla on yli 6kk vanha ilmoittautuminen kurssille
|
||||
- □ Harmaa laatikko = Opiskelija on lisännyt kurssin opintosuunitelmaansa, mutta ei ole ilmoittautunut kurssille tai suorittanut sitä
|
||||
|
||||
**Modules**
|
||||
|
||||
Tällä välilehdellä näkyy, mitä opintokokonaisuuksia opiskelija on valinnut opintosuunnitelmaansa. Näkymä ei huomioi valinnaisia opintokokonaisuuksia.
|
||||
|
||||
**Tags**
|
||||
|
||||
@@ -208,7 +213,7 @@ export const populationStatisticsToolTips = {
|
||||
3. Jos suoritukseen tai ilmoittautumiseen ei liity opiskeluoikeutta eikä kurssia ole sijoitettu opintosuunnitelmaan, käytetään viimeisintä koulutusohjelmaa suorituksen tai ilmoittautumisen hetkellä
|
||||
`,
|
||||
programmeDistributionCustomPopulation: `
|
||||
'Taulukossa näytetään opiskelijoiden jakauma koulutusohjelmittain. Käytettävä koulutusohjelma on opiskelijan uusin koulutusohjelma.
|
||||
Taulukossa näytetään opiskelijoiden jakauma koulutusohjelmittain. Käytettävä koulutusohjelma on opiskelijan uusin koulutusohjelma.
|
||||
`,
|
||||
gradeDistributionCoursePopulation: 'Näyttää korkeimman arvosanan, jonka opiskelija on saanut valitulla aikavälillä.',
|
||||
creditDistributionCoursePopulation: 'Näyttää tiedekunta-/ohjelmankohtaisen opintopistejakauman.',
|
||||
|
||||
@@ -3,7 +3,7 @@ export const studyProgrammeToolTips: Record<string, string> = {
|
||||
**Calendar year**: Näyttää tilastot kalenterivuosille jaoteltuna (esim. 1.1.–31.12.2024)
|
||||
**Academic year**: Näyttää tilastot lukuvuosille jaoteltuna (esim. 1.8.2023–31.7.2024)
|
||||
`,
|
||||
studentToggle: `
|
||||
studyRightToggle: `
|
||||
**All study rights**: Valinnassa ovat mukana vaihto-opiskelijat, erillisoikeudella opiskelevat ja ohjelmaan siirtyneet opiskelijat sekä ohjelmasta pois siirtyneet opiskelijat.
|
||||
**Special study rights excluded**: Valinnasta ja kaikista luvuista on poissuljettu kaikki edellä mainitut erikoisryhmät.
|
||||
`,
|
||||
@@ -117,7 +117,7 @@ export const studyProgrammeToolTips: Record<string, string> = {
|
||||
|
||||
Lukukausi-ilmoittautumisissa huomioidaan vain se opiskeluoikeus, joka liittyy tarkasteltavaan koulutusohjelmaan. Vaikka opiskelija olisi ilmoittautunut toiseen opiskeluoikeuteen läsnäolevaksi, mutta hän on laiminlyönyt ilmoittautumisen tarkasteltavaan koulutusohjelmaan liittyvään opiskeluoikeuteen, hänet lasketaan ryhmään ”Inactive”.
|
||||
|
||||
Paksummilla pystyviivoilla eroteltujen kategorioiden (**Current status**, **Gender**, **Countries**) sarakkeet tuottavat yhteenlaskettuna kentän **All** arvon.
|
||||
Paksummilla pystyviivoilla eroteltujen kategorioiden (**Current status**, **Gender**) sarakkeet tuottavat yhteenlaskettuna kentän **All** arvon. **Citizenships**-kategoriat (**Finland** ja **Other**) voivat tuottaa yhteenlaskettuna suuremman tuloksen, koska opiskelijoilla voi olla useampi kuin yksi kansalaisuus.
|
||||
|
||||
Jos sivun yläosassa on valittuna ”All study rights” (oletus), mukana ovat myös ohjelmaan siirtyneet ja ohjelmasta pois siirtyneet opiskelijat.
|
||||
|
||||
@@ -138,6 +138,11 @@ export const studyProgrammeToolTips: Record<string, string> = {
|
||||
notCompleted: `
|
||||
Sisältää opiskelijat, jotka ovat ilmoittautuneet kurssille, mutta eivät ole suorittaneet sitä ja opiskelijat, jotka ovat saaneet hylätyn arvosanan.
|
||||
`,
|
||||
tags: `
|
||||
Here you can create tags for study programme. You can either create public tags or *personal* tags. Tags can be used to combine students from other starting years.
|
||||
'Associated start year' means what year you want to use as a start year for the students in that tag. For example with this you can move student from earlier starting year to next year if the student was absent during first year.
|
||||
However this is optional and you can create a new tag without selecting year.
|
||||
`,
|
||||
}
|
||||
|
||||
studyProgrammeToolTips.studyTrackOverviewCombinedProgramme = studyProgrammeToolTips.studyTrackOverview
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export const updaterToolTips = {
|
||||
updaterSection: `
|
||||
**Update meta**: Updates organisations, study modules, course units, study levels, education types, credit types
|
||||
**Update students**: Updates 1000 students at one click in development and all in production environment. Takes about 5 hours in production.
|
||||
**Update curriculums**: Updates all study programmes and their curriculums. This takes a few minutes, and breaks the curriculum features for that time, so do not run in production unnecessarily.
|
||||
**Refresh updater redis**: Refreshes maps (COUNTRIES, EDUCATION_TYPES, ORGANISATION_ID_TO_CODE, etc.) stored in updater redis. Check \`loadMapsOnDemand\` function in updater for more information.
|
||||
`,
|
||||
refreshDataSection: `
|
||||
**Refresh all teacher leaderboards**: Refresh all teacher leaderboard statistics from 1951 until today. Might take some time.
|
||||
**Refresh teacher leaderboards of current and previous year**: Refresh teacher leaderboard statistics for current and previous academic year.
|
||||
**Refresh faculties**: Refresh data for all faculties for all tabs (time consuming).
|
||||
**Refresh study programmes**: Refresh data for new study programmes for basic and studytrack tabs (time consuming).
|
||||
**Refresh language center data**: Refresh data for language center view.
|
||||
**Refresh close to graduation data**: Refresh data for close to graduation view.
|
||||
`,
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
export const userToolTips = {
|
||||
iamGroupBasedAccess: `
|
||||
*Currently this description is more like how things should be, not how they are. In particular, full rights based on IAM groups are not implemented.*
|
||||
|
||||
The exclamation mark means that the user has **limited rights** to the study programme. The check mark means that the user has **full rights** to the study programme. Full rights behave exactly like manually added rights.
|
||||
|
||||
Both of these icons are displayed: the relevant one is green, the irrelevant one is greyed out.
|
||||
The orange exclamation mark means that the user has **limited rights** to the study programme.
|
||||
The green check mark means that the user has **full rights** to the study programme. Full rights behave exactly like manually added rights.
|
||||
|
||||
**Limited rights** are granted to the following people:
|
||||
- member of the steering group (johtoryhmä) of the study programme.
|
||||
- dean or vice dean (dekaani tai varadekaani) of the corresponding faculty.
|
||||
- The following people get limited rights to **all** study programmes:
|
||||
- member of the steering group (johtoryhmä) of the study programme
|
||||
- dean or vice dean (dekaani tai varadekaani) of the corresponding faculty
|
||||
- the following people get limited rights to **all** study programmes:
|
||||
- heads of academic affairs (opintoasiainpäälliköt)
|
||||
- employees of ospa (opetuksen strategiset palvelut)
|
||||
- rector, vice-rectors and director of administration (rehtori, vararehtorit ja hallintojohtaja)
|
||||
- members of the academic affairs council (opintoasiainneuvosto)
|
||||
|
||||
**Full rights** are granted to the following people:
|
||||
- education planning officer (koulutussuunnittelija) of the corresponding faculty.
|
||||
- vice dean responsible for education (opetusvaradekaani) of the corresponding faculty.
|
||||
- head **and** member of the steering group of the study programme (johtoryhmän jäsen ja koulutusohjelman johtaja).
|
||||
- vice dean responsible for education (opetusvaradekaani) of the corresponding faculty
|
||||
- head **and** member of the steering group of the study programme (johtoryhmän jäsen ja koulutusohjelman johtaja)
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import { isEqual } from 'lodash'
|
||||
import qs from 'query-string'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { SEARCH_HISTORY_VERSION } from '@/constants'
|
||||
import { useGetCurriculumPeriodsQuery } from '@/redux/curriculumPeriods'
|
||||
import { useGetProgrammesQuery } from '@/redux/populations'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
|
||||
export const useTabChangeAnalytics = () => {
|
||||
const previousTabIndex = useRef(0)
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(_, data) => {
|
||||
const { activeIndex } = data
|
||||
|
||||
if (previousTabIndex.current !== activeIndex) {
|
||||
previousTabIndex.current = activeIndex
|
||||
}
|
||||
},
|
||||
[previousTabIndex]
|
||||
)
|
||||
|
||||
return { handleTabChange }
|
||||
}
|
||||
|
||||
export const useTabs = (id, initialTab, { location, replace }) => {
|
||||
const [tab, setTab] = useState(-1)
|
||||
const [didMount, setDidMount] = useState(false)
|
||||
|
||||
const pushToUrl = newTab => {
|
||||
replace({
|
||||
pathname: location.pathname,
|
||||
search: qs.stringify({ ...qs.parse(location.search), [id]: newTab }),
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const params = qs.parse(location.search)
|
||||
const queryTab = params[id]
|
||||
setTab(queryTab === undefined ? initialTab : JSON.parse(queryTab))
|
||||
setDidMount(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== undefined && didMount) pushToUrl(tab)
|
||||
}, [tab])
|
||||
|
||||
return [
|
||||
tab,
|
||||
(_, { activeIndex }) => {
|
||||
setTab(activeIndex)
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const useSearchHistory = (id, capacity = 5) => {
|
||||
const [searchHistory, setSearchHistory] = useState([])
|
||||
const [didMount, setDidMount] = useState(false)
|
||||
|
||||
const getSearchHistoryStore = () => JSON.parse(localStorage.getItem('searchHistoryStore')) || {}
|
||||
|
||||
const saveSearchHistoryStore = newStore => localStorage.setItem('searchHistoryStore', JSON.stringify(newStore))
|
||||
|
||||
const saveSearchHistory = () => {
|
||||
const searchHistoryStore = getSearchHistoryStore()
|
||||
searchHistoryStore[id] = searchHistory
|
||||
saveSearchHistoryStore(searchHistoryStore)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem('searchHistoryVersion') !== SEARCH_HISTORY_VERSION) {
|
||||
saveSearchHistoryStore({})
|
||||
localStorage.setItem('searchHistoryVersion', SEARCH_HISTORY_VERSION)
|
||||
}
|
||||
|
||||
setSearchHistory(getSearchHistoryStore()[id] || [])
|
||||
setDidMount(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (didMount) {
|
||||
saveSearchHistory()
|
||||
}
|
||||
}, [searchHistory])
|
||||
|
||||
const addItem = item => {
|
||||
const filteredSearchHistory = searchHistory.filter(sh => sh.text !== item.text)
|
||||
setSearchHistory(
|
||||
filteredSearchHistory.concat({ ...item, timestamp: new Date(), id: new Date().getTime() }).slice(-capacity)
|
||||
)
|
||||
}
|
||||
|
||||
const updateItem = item => {
|
||||
const updatedSearchHistory = [{ ...item, timestamp: new Date() }].concat(
|
||||
searchHistory.filter(s => s.id !== item.id)
|
||||
)
|
||||
setSearchHistory(updatedSearchHistory)
|
||||
}
|
||||
|
||||
return [searchHistory, addItem, updateItem]
|
||||
}
|
||||
|
||||
const useInterval = (callback, delay) => {
|
||||
const savedCallback = useRef()
|
||||
const savedId = useRef()
|
||||
|
||||
const clear = () => {
|
||||
if (savedId.current) {
|
||||
clearInterval(savedId.current)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
savedCallback.current = callback
|
||||
}, [callback])
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => savedCallback.current()
|
||||
clear()
|
||||
if (delay !== null) {
|
||||
savedId.current = setInterval(tick, delay)
|
||||
}
|
||||
return clear
|
||||
}, [delay])
|
||||
}
|
||||
|
||||
export const useDidMount = () => {
|
||||
const [didMount, setDidMount] = useState(false)
|
||||
useEffect(() => {
|
||||
setDidMount(true)
|
||||
}, [])
|
||||
return didMount
|
||||
}
|
||||
|
||||
export const useProgress = loading => {
|
||||
const didMount = useDidMount()
|
||||
const [progress, setProgress] = useState(100)
|
||||
const [delay, setDelay] = useState(null)
|
||||
const amountToProgress = delay ? Math.ceil(Math.random() * 4) : 0
|
||||
|
||||
useInterval(() => {
|
||||
setProgress(progress + amountToProgress > 50 ? 50 : progress + amountToProgress)
|
||||
}, delay)
|
||||
|
||||
useEffect(() => {
|
||||
if (delay && progress >= 50) {
|
||||
setDelay(null)
|
||||
}
|
||||
}, [progress])
|
||||
|
||||
const restartProgress = () => {
|
||||
setProgress(0)
|
||||
setDelay(500)
|
||||
}
|
||||
|
||||
const finishProgress = () => {
|
||||
setDelay(null)
|
||||
setTimeout(() => setProgress(100), 0)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) restartProgress()
|
||||
else if (didMount) finishProgress()
|
||||
}, [loading])
|
||||
|
||||
const onProgress = progress => {
|
||||
if (progress > 0) {
|
||||
setProgress(50 + Math.floor(progress / 2))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
progress,
|
||||
onProgress,
|
||||
}
|
||||
}
|
||||
|
||||
export const useTitle = title => {
|
||||
useEffect(() => {
|
||||
document.title = title ? `${title} - Oodikone` : 'Oodikone'
|
||||
}, [title])
|
||||
}
|
||||
|
||||
export const useLocalStorage = (key, initialValue) => {
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : initialValue
|
||||
} catch (error) {
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
|
||||
const setValue = useCallback(
|
||||
value => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value
|
||||
setStoredValue(valueToStore)
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||
} catch (error) {
|
||||
console.log(error) // eslint-disable-line no-console
|
||||
}
|
||||
},
|
||||
[key]
|
||||
)
|
||||
|
||||
return [storedValue, setValue]
|
||||
}
|
||||
|
||||
export const useDebounce = (value, timeout, onChange) => {
|
||||
const [innerValue, setInnerValue] = useState(value)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
const timeoutRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEqual(value, innerValue)) {
|
||||
setInnerValue(value)
|
||||
setDirty(false)
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const setValue = useCallback(
|
||||
value => {
|
||||
setInnerValue(value)
|
||||
setDirty(true)
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
onChange(value)
|
||||
setDirty(false)
|
||||
}, timeout)
|
||||
},
|
||||
[innerValue, setInnerValue, timeoutRef, onChange, timeout]
|
||||
)
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
|
||||
onChange(innerValue)
|
||||
setDirty(false)
|
||||
}, [timeoutRef, onChange, innerValue])
|
||||
|
||||
return [innerValue, setValue, flush, dirty]
|
||||
}
|
||||
|
||||
export const useToggle = (initialValue = false) => {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const toggle = useCallback(() => {
|
||||
setValue(value => !value)
|
||||
}, [])
|
||||
return [value, toggle]
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook is similar to useMemo, but it does a deep comparison of the dependencies (using `isEqual` from Lodash).
|
||||
*/
|
||||
export const useDeepMemo = (factory, dependencies) => {
|
||||
const ref = useRef()
|
||||
|
||||
if (!ref.current || !isEqual(dependencies, ref.current.deps)) {
|
||||
ref.current = { deps: dependencies, result: factory() }
|
||||
}
|
||||
|
||||
return ref.current.result
|
||||
}
|
||||
|
||||
export const useCurrentSemester = () => {
|
||||
const { data: semesterData } = useGetSemestersQuery()
|
||||
if (!semesterData) return null
|
||||
return Object.values(semesterData.semesters).find(
|
||||
semester => new Date(semester.startdate) <= new Date() && new Date(semester.enddate) >= new Date()
|
||||
)
|
||||
}
|
||||
|
||||
export const useCurrentCurriculumPeriod = () => {
|
||||
const { data: curriculumPeriods = [] } = useGetCurriculumPeriodsQuery()
|
||||
return curriculumPeriods.find(
|
||||
curriculumPeriod =>
|
||||
new Date(curriculumPeriod.startDate) <= new Date() && new Date(curriculumPeriod.endDate) >= new Date()
|
||||
)
|
||||
}
|
||||
|
||||
export const useDegreeProgrammeTypes = programmeCodes => {
|
||||
const { data: degreeProgrammes } = useGetProgrammesQuery()
|
||||
if (!degreeProgrammes) return {}
|
||||
return programmeCodes.reduce((acc, programmeCode) => {
|
||||
acc[programmeCode] = degreeProgrammes[programmeCode]?.degreeProgrammeType ?? null
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { filter, intersection, maxBy, orderBy, range } from 'lodash'
|
||||
import { filter, maxBy, orderBy, range } from 'lodash'
|
||||
import moment from 'moment'
|
||||
|
||||
import irtomikko from '@/assets/irtomikko.png'
|
||||
@@ -17,30 +17,6 @@ export const images = {
|
||||
irtomikko,
|
||||
}
|
||||
|
||||
export const checkUserAccess = (requiredRoles, roles) => {
|
||||
return intersection(requiredRoles, roles).length > 0
|
||||
}
|
||||
|
||||
export const getGraduationGraphTitle = (studyProgramme, doCombo = false) => {
|
||||
if (!studyProgramme) return ''
|
||||
if (['MH30_001', 'MH30_003'].includes(studyProgramme)) return 'Licenciate study right'
|
||||
if (doCombo && studyProgramme === 'MH90_001') return 'Bachelor + licentiate study right'
|
||||
if (doCombo && studyProgramme.includes('MH')) return 'Bachelor + master study right'
|
||||
if (studyProgramme.includes('KH')) return 'Bachelor study right'
|
||||
if (studyProgramme.includes('MH')) return 'Master study right'
|
||||
return 'Doctoral study right'
|
||||
}
|
||||
|
||||
export const getUnifiedProgrammeName = (bachelor, masterLicentiate, language) => {
|
||||
if (language === 'fi')
|
||||
return `${bachelor} ja ${
|
||||
masterLicentiate?.includes('lisensiaatin') ? 'lisensiaatin koulutusohjelma' : 'maisterin koulutusohjelma'
|
||||
}`
|
||||
if (language === 'en') return `${bachelor?.split(' ')[0]} and ${masterLicentiate}`
|
||||
if (language === 'sv') return `${bachelor?.split('programmet')[0]}- och ${masterLicentiate}`
|
||||
return bachelor
|
||||
}
|
||||
|
||||
export const isFall = semester => semester % 2 === 1
|
||||
|
||||
export const getStudentTotalCredits = (student, includeTransferredCredits = true) => {
|
||||
@@ -288,7 +264,7 @@ export const getCreditCategories = (
|
||||
return range(0, limitBreaks.length + 1).map(i => [limitBreaks[i - 1], limitBreaks[i]])
|
||||
}
|
||||
|
||||
export const validateInputLength = (input, minLength) => input && input.trim().length >= minLength
|
||||
export const validateInputLength = (input, minLength) => input?.trim().length >= minLength
|
||||
|
||||
export const splitByEmptySpace = str => str.replace(/\s\s+/g, ' ').split(' ')
|
||||
|
||||
@@ -299,33 +275,6 @@ export const getCurrentSemester = allSemesters => {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a sorting function that can be used to sort strings so that Finnish alphabetical order is respected.
|
||||
*
|
||||
* @param {string} field - The field to sort by (optional: if not given, the function will sort by the strings themselves)
|
||||
*/
|
||||
export const createLocaleComparator = (field = null) => {
|
||||
if (!field) {
|
||||
return (val1, val2) => val1.localeCompare(val2, 'fi', { sensitivity: 'accent' })
|
||||
}
|
||||
return (val1, val2) => val1[field]?.localeCompare(val2[field], 'fi', { sensitivity: 'accent' })
|
||||
}
|
||||
|
||||
export const createPinnedFirstComparator = pinnedProgrammes => {
|
||||
const localeComparator = createLocaleComparator('code')
|
||||
return (programmeA, programmeB) => {
|
||||
const pinnedA = pinnedProgrammes.includes(programmeA.code)
|
||||
const pinnedB = pinnedProgrammes.includes(programmeB.code)
|
||||
if (pinnedA && !pinnedB) {
|
||||
return -1
|
||||
}
|
||||
if (!pinnedA && pinnedB) {
|
||||
return 1
|
||||
}
|
||||
return localeComparator(programmeA, programmeB)
|
||||
}
|
||||
}
|
||||
|
||||
const interpolateColor = (color1, color2, factor) =>
|
||||
color1.map((component, index) => Math.round(component + factor * (color2[index] - component)))
|
||||
|
||||
@@ -352,9 +301,6 @@ export const generateGradientColors = steps => {
|
||||
return gradientColors
|
||||
}
|
||||
|
||||
export const getFullStudyProgrammeRights = programmeRights =>
|
||||
programmeRights ? programmeRights.filter(({ limited }) => !limited).map(({ code }) => code) : []
|
||||
|
||||
export const isNewStudyProgramme = programmeCode => ['MH', 'KH', 'T9'].includes(programmeCode.slice(0, 2))
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { random } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ReactHighchart from 'react-highcharts'
|
||||
import { Button, Container, Dimmer, Header, Image, Transition } from 'semantic-ui-react'
|
||||
|
||||
import { images } from '@/common'
|
||||
import { useLogoutMutation } from '@/redux/auth'
|
||||
|
||||
const names = [
|
||||
'mluukkai',
|
||||
'jakousa',
|
||||
'totutotu',
|
||||
'sasumaki',
|
||||
'ikuisma',
|
||||
'eero3',
|
||||
'mitiaine',
|
||||
'rimi',
|
||||
'esakemp',
|
||||
'woltsu',
|
||||
'cxcorp',
|
||||
'ajhaa',
|
||||
'joonashak',
|
||||
'vaahtokarkki',
|
||||
'otahontas',
|
||||
'saarasat',
|
||||
'popalmu',
|
||||
'ollikehy',
|
||||
]
|
||||
|
||||
const dummyData = names.map(name => ({
|
||||
name,
|
||||
data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].reduce((acc, i) => {
|
||||
acc[i] = (acc[i - 1] || 0) + (random(0, 3) === 0 ? 0 : random(10.0, 100.0))
|
||||
return acc
|
||||
}, []),
|
||||
}))
|
||||
|
||||
export const AccessDenied = ({ notEnabled }) => {
|
||||
const header = notEnabled ? 'Welcome to Oodikone!' : 'Something broke'
|
||||
const subheader = notEnabled
|
||||
? "You're currently not allowed to enter but you will get an email when you're authorized"
|
||||
: 'If this was not intended try refreshing your browser window, pressing log out or contacting grp-toska@helsinki.fi'
|
||||
|
||||
const [easterEgg, setEasterEgg] = useState(false)
|
||||
const [logout] = useLogoutMutation()
|
||||
|
||||
useEffect(() => {
|
||||
if (notEnabled) {
|
||||
setTimeout(() => setEasterEgg(true), Math.floor(Math.random() * 1800000) + 600000)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Container style={{ alignItems: 'stretch', display: 'flex', height: '100vh', justifyContent: 'space-evenly' }}>
|
||||
<ReactHighchart
|
||||
config={{
|
||||
credits: { enabled: false },
|
||||
plotOptions: { series: { label: { connectorAllowed: false }, pointStart: 2010 } },
|
||||
series: dummyData,
|
||||
title: { text: 'Students of Computer Science 2018-2020' },
|
||||
yAxis: { title: { text: 'Cumulative credits' } },
|
||||
}}
|
||||
/>
|
||||
<ReactHighchart
|
||||
config={{
|
||||
chart: { type: 'column' },
|
||||
credits: { enabled: false },
|
||||
series: dummyData.map(element => ({ name: element.name, data: [Math.max(...element.data)] })),
|
||||
title: { text: "Your students' future" },
|
||||
xAxis: { categories: ['2018'] },
|
||||
yAxis: { title: { text: 'Cumulative credits' } },
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
<Dimmer active page>
|
||||
<Image centered size="medium" src={images.toskaLogo} style={{ paddingTop: '2%' }} />
|
||||
<Header as="h2" inverted>
|
||||
<p>{header}</p>
|
||||
<Header.Subheader>{subheader}</Header.Subheader>
|
||||
<br />
|
||||
<Button color="pink" onClick={() => logout()}>
|
||||
Log out
|
||||
</Button>
|
||||
</Header>
|
||||
</Dimmer>
|
||||
<Transition animation="fly up" duration={10000} visible={easterEgg}>
|
||||
<Image
|
||||
inline
|
||||
size="huge"
|
||||
src={images.irtomikko}
|
||||
style={{ position: 'absolute', right: '10px', top: '350px' }}
|
||||
verticalAlign="top"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,46 @@
|
||||
import { CssBaseline } from '@mui/material'
|
||||
import { ThemeProvider } from '@mui/material/styles'
|
||||
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import HighCharts from 'highcharts'
|
||||
import moment from 'moment'
|
||||
import { useEffect } from 'react'
|
||||
import { initShibbolethPinger } from 'unfuck-spa-shibboleth-session'
|
||||
|
||||
import { AccessDenied } from '@/components/AccessDenied'
|
||||
import { LanguageProvider } from '@/components/LanguagePicker/useLanguage'
|
||||
import { AccessDenied } from '@/components/material/AccessDenied'
|
||||
import { Footer } from '@/components/material/Footer'
|
||||
import { NavigationBar } from '@/components/material/NavigationBar'
|
||||
import { StatusNotification } from '@/components/material/StatusNotification'
|
||||
import { StatusNotificationProvider } from '@/components/material/StatusNotificationContext'
|
||||
import { Routes } from '@/components/Routes'
|
||||
import { SegmentDimmer } from '@/components/SegmentDimmer'
|
||||
import { isProduction } from '@/conf'
|
||||
import { useGetAuthorizedUserQuery } from '@/redux/auth'
|
||||
import { theme } from '@/theme'
|
||||
|
||||
// TODO: What other options should be set globally?
|
||||
HighCharts.setOptions({
|
||||
credits: {
|
||||
enabled: false,
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
animation: false,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: '',
|
||||
},
|
||||
})
|
||||
|
||||
moment.updateLocale('en', {
|
||||
week: {
|
||||
dow: 1, // First day of week is Monday
|
||||
},
|
||||
})
|
||||
|
||||
const addUserDetailsToLoggers = ({ id, username, mockedBy }) => {
|
||||
if (!isProduction || !id || !username) {
|
||||
return
|
||||
@@ -28,12 +56,19 @@ const Layout = ({ children }) => (
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<NavigationBar />
|
||||
<main style={{ flex: 1 }}>{children}</main>
|
||||
<Footer />
|
||||
</ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<LocalizationProvider dateAdapter={AdapterMoment}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<StatusNotificationProvider>
|
||||
<CssBaseline />
|
||||
<NavigationBar />
|
||||
<main style={{ flex: 1 }}>{children}</main>
|
||||
<StatusNotification />
|
||||
<Footer />
|
||||
</StatusNotificationProvider>
|
||||
</ThemeProvider>
|
||||
</LocalizationProvider>
|
||||
</LanguageProvider>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
import { range } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { Divider, Dropdown, Form, Icon, Loader, Message, Radio, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { createLocaleComparator, getCurrentSemester, getEnrollmentTypeTextForExcel, isFall } from '@/common'
|
||||
import { useTitle } from '@/common/hooks'
|
||||
import { closeToGraduationToolTips } from '@/common/InfoToolTips'
|
||||
import { StudentInfoItem } from '@/components/common/StudentInfoItem'
|
||||
import { InfoBox } from '@/components/InfoBox'
|
||||
import { useLanguage } from '@/components/LanguagePicker/useLanguage'
|
||||
import { getSemestersPresentFunctions } from '@/components/PopulationStudents/StudentTable/GeneralTab/columnHelpers/semestersPresent'
|
||||
import { PaginatedSortableTable } from '@/components/SortableTable/PaginatedSortableTable'
|
||||
import { StudentNameVisibilityToggle, useStudentNameVisibility } from '@/components/StudentNameVisibilityToggle'
|
||||
import { ISO_DATE_FORMAT, LONG_DATE_TIME_FORMAT } from '@/constants/date'
|
||||
import { useGetStudentsCloseToGraduationQuery } from '@/redux/closeToGraduation'
|
||||
import { useGetFacultiesQuery } from '@/redux/facultyStats'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { useFilteredAndFormattedStudyProgrammes } from '@/redux/studyProgramme'
|
||||
import { reformatDate } from '@/util/timeAndDate'
|
||||
|
||||
const NUMBER_OF_DISPLAYED_SEMESTERS = 6
|
||||
|
||||
const getColumns = (
|
||||
getTextIn,
|
||||
namesVisible,
|
||||
semesterEnrollmentsVisible,
|
||||
studyTrackVisible,
|
||||
allSemestersMap,
|
||||
bachelorStudentsAreDisplayed,
|
||||
semesterEnrollmentFunctions
|
||||
) => {
|
||||
const { getSemesterEnrollmentsContent, getSemesterEnrollmentsVal } = semesterEnrollmentFunctions
|
||||
const currentSemesterCode = getCurrentSemester(allSemestersMap)?.semestercode
|
||||
const semestersToInclude = range(
|
||||
isFall(currentSemesterCode)
|
||||
? currentSemesterCode - NUMBER_OF_DISPLAYED_SEMESTERS + 2
|
||||
: currentSemesterCode - NUMBER_OF_DISPLAYED_SEMESTERS + 1,
|
||||
isFall(currentSemesterCode) ? currentSemesterCode + 2 : currentSemesterCode + 1
|
||||
)
|
||||
return [
|
||||
{
|
||||
key: 'studentNumber',
|
||||
title: 'Student number',
|
||||
getRowVal: row => row.student.studentNumber,
|
||||
getRowContent: row => <StudentInfoItem showSisuLink student={row.student} />,
|
||||
filterable: false,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
getRowVal: row => row.student.name,
|
||||
filterable: false,
|
||||
displayColumn: namesVisible,
|
||||
},
|
||||
{
|
||||
key: 'phoneNumber',
|
||||
title: 'Phone number',
|
||||
getRowVal: row => row.student.phoneNumber,
|
||||
displayColumn: false,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
title: 'Email',
|
||||
getRowVal: row => row.student.email,
|
||||
displayColumn: false,
|
||||
},
|
||||
{
|
||||
key: 'secondaryEmail',
|
||||
title: 'Secondary email',
|
||||
getRowVal: row => row.student.secondaryEmail,
|
||||
displayColumn: false,
|
||||
},
|
||||
{
|
||||
key: 'programme',
|
||||
title: 'Programme',
|
||||
getRowVal: row => getTextIn(row.programme.name),
|
||||
filterable: false,
|
||||
},
|
||||
{
|
||||
key: 'studytrack',
|
||||
title: 'Study track',
|
||||
getRowVal: row => getTextIn(row.programme.studyTrack),
|
||||
displayColumn: studyTrackVisible,
|
||||
},
|
||||
{
|
||||
key: 'startOfStudyright',
|
||||
title: 'Start of\nstudy right',
|
||||
getRowVal: row => row.studyright.startDate,
|
||||
formatValue: date => reformatDate(date, ISO_DATE_FORMAT),
|
||||
filterType: 'date',
|
||||
},
|
||||
{
|
||||
key: 'startedInProgramme',
|
||||
title: 'Started in programme',
|
||||
getRowVal: row => row.programme.startedAt,
|
||||
formatValue: date => reformatDate(date, ISO_DATE_FORMAT),
|
||||
filterType: 'date',
|
||||
displayColumn: !bachelorStudentsAreDisplayed,
|
||||
helpText:
|
||||
"For students with only a study right in the master’s programme, this date is the same as 'Start of study right'. For students with study rights in both the bachelor’s and master’s programmes, this date represents when they started in the master’s programme (i.e. one day after graduating from the bachelor’s programme).",
|
||||
},
|
||||
{
|
||||
key: 'credits',
|
||||
title: 'Completed credits',
|
||||
children: [
|
||||
{
|
||||
key: 'creditsHops',
|
||||
title: 'HOPS',
|
||||
getRowVal: row => row.credits.hops,
|
||||
filterType: 'range',
|
||||
helpText: "The credits earned from courses in the student's primary study plan",
|
||||
},
|
||||
{
|
||||
key: 'creditsTotal',
|
||||
title: 'Total',
|
||||
getRowVal: row => row.credits.all,
|
||||
filterType: 'range',
|
||||
helpText: 'The total number of credits the student has earned at the university',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'isBaMa',
|
||||
title: 'Continued\nfrom\nbachelor’s',
|
||||
getRowVal: row => (row.studyright.isBaMa ? 'Continued from bachelor’s' : 'Not continued from bachelor’s'),
|
||||
getRowContent: row => (row.studyright.isBaMa ? <Icon color="green" name="check" /> : null),
|
||||
getRowExportVal: row => (row.studyright.isBaMa ? '1' : null),
|
||||
cellProps: () => ({ style: { textAlign: 'center' } }),
|
||||
displayColumn: !bachelorStudentsAreDisplayed,
|
||||
helpText: 'Indicates whether the student has continued their studies from a bachelor’s degree',
|
||||
},
|
||||
{
|
||||
key: 'curriculumPeriod',
|
||||
title: 'Curriculum\nperiod',
|
||||
getRowVal: row => row.curriculumPeriod,
|
||||
helpText: 'The curriculum period the student has chosen for their primary study plan',
|
||||
},
|
||||
{
|
||||
key: 'semesterEnrollments',
|
||||
title: 'Semesters\npresent',
|
||||
filterType: 'range',
|
||||
displayColumn: semesterEnrollmentsVisible,
|
||||
export: false,
|
||||
getRowContent: row => getSemesterEnrollmentsContent(row.student, row.studyright),
|
||||
getRowVal: row => getSemesterEnrollmentsVal(row.student, row.studyright),
|
||||
},
|
||||
{
|
||||
key: 'semestersAbsent',
|
||||
title: 'Semesters\nabsent',
|
||||
getRowVal: row => row.numberOfAbsentSemesters,
|
||||
helpText:
|
||||
'The number of semesters the student has been absent (both statutory and non-statutory absences) during their study right',
|
||||
},
|
||||
{
|
||||
key: 'semesterEnrollmentsForExcel',
|
||||
title: 'Enrollment status',
|
||||
displayColumn: false,
|
||||
children: semestersToInclude.map(semester => ({
|
||||
key: `${semester}`,
|
||||
title: getTextIn(allSemestersMap[`${semester}`]?.name),
|
||||
displayColumn: false,
|
||||
getRowVal: student => {
|
||||
if (!student.studyright.semesterEnrollments) {
|
||||
return 'Not enrolled'
|
||||
}
|
||||
const enrollment = student.studyright.semesterEnrollments.find(enrollment => enrollment.semester === semester)
|
||||
return getEnrollmentTypeTextForExcel(enrollment?.type, enrollment?.statutoryAbsence)
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'thesisCompleted',
|
||||
title: 'Thesis\ncompleted',
|
||||
getRowVal: row => (row.thesisInfo ? 'Thesis completed' : 'Thesis not completed'),
|
||||
getRowContent: row => (row.thesisInfo ? <Icon color="green" name="check" /> : null),
|
||||
getRowExportVal: row => (row.thesisInfo ? '1' : null),
|
||||
cellProps: row =>
|
||||
row.thesisInfo
|
||||
? {
|
||||
style: { textAlign: 'center' },
|
||||
title: [
|
||||
`Attainment date: ${reformatDate(row.thesisInfo.attainmentDate, ISO_DATE_FORMAT)}`,
|
||||
`Course code: ${row.thesisInfo.courseCode}`,
|
||||
`Grade: ${row.thesisInfo.grade}`,
|
||||
].join('\n'),
|
||||
}
|
||||
: {},
|
||||
helpText:
|
||||
'The thesis attainment must be linked to the correct study right. You can see the attainment date, course code, and grade by hovering over the check mark.',
|
||||
},
|
||||
{
|
||||
key: 'latestAttainmentDates',
|
||||
title: 'Latest attainment date',
|
||||
children: [
|
||||
{
|
||||
key: 'latestAttainmentHops',
|
||||
title: 'HOPS',
|
||||
getRowVal: row => reformatDate(row.attainmentDates.latestHops, ISO_DATE_FORMAT),
|
||||
filterType: 'date',
|
||||
helpText: 'The date when the student last completed a course in their primary study plan',
|
||||
},
|
||||
{
|
||||
key: 'latestAttainmentTotal',
|
||||
title: 'Total',
|
||||
getRowVal: row => reformatDate(row.attainmentDates.latestTotal, ISO_DATE_FORMAT),
|
||||
filterType: 'date',
|
||||
helpText: 'The date when the student last completed any course at the university',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'earliestAttainmentDates',
|
||||
title: 'Earliest\nattainment date',
|
||||
children: [
|
||||
{
|
||||
key: 'earliestAttainmentHops',
|
||||
title: 'HOPS',
|
||||
getRowVal: row => reformatDate(row.attainmentDates.earliestHops, ISO_DATE_FORMAT),
|
||||
filterType: 'date',
|
||||
helpText: 'The date when the student first completed a course in their primary study plan',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const CloseToGraduation = () => {
|
||||
useTitle('Students close to graduation')
|
||||
|
||||
const { data: students = {}, isError, isLoading } = useGetStudentsCloseToGraduationQuery()
|
||||
const { data: faculties = [] } = useGetFacultiesQuery()
|
||||
const { data: semesterData = [] } = useGetSemestersQuery()
|
||||
const studyProgrammes = useFilteredAndFormattedStudyProgrammes()
|
||||
const { getTextIn } = useLanguage()
|
||||
const { visible: namesVisible } = useStudentNameVisibility()
|
||||
const [chosenFaculties, setChosenFaculties] = useState([])
|
||||
const [chosenProgrammes, setChosenProgrammes] = useState([])
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0)
|
||||
const [rowCount, setRowCount] = useState(0)
|
||||
const [semesterEnrollmentsVisible, setSemesterEnrollmentsVisible] = useState(false)
|
||||
const allSemesters = Object.entries(semesterData?.semesters || {}).map(item => item[1])
|
||||
const allSemestersMap = allSemesters.reduce((obj, cur, index) => {
|
||||
obj[index + 1] = cur
|
||||
return obj
|
||||
}, {})
|
||||
const { getSemesterEnrollmentsContent, getSemesterEnrollmentsVal } = getSemestersPresentFunctions({
|
||||
getTextIn,
|
||||
allSemesters,
|
||||
allSemestersMap,
|
||||
filteredStudents: students,
|
||||
year: `${new Date().getFullYear() - Math.floor(NUMBER_OF_DISPLAYED_SEMESTERS / 2)}`,
|
||||
})
|
||||
const { bachelor = [], masterAndLicentiate = [] } = students
|
||||
|
||||
const bachelorStudentsAreDisplayed = activeTabIndex === 0
|
||||
const displayedStudents = bachelorStudentsAreDisplayed ? bachelor : masterAndLicentiate
|
||||
|
||||
const handleDisplayedDataChange = students => setRowCount(students.length)
|
||||
|
||||
const filteredStudents = displayedStudents.filter(s => {
|
||||
if (chosenProgrammes.length > 0) return chosenProgrammes.includes(s.programme.code)
|
||||
return chosenFaculties.length === 0 || chosenFaculties.includes(s.programme.code.slice(1, 4))
|
||||
})
|
||||
|
||||
const studyTrackVisible =
|
||||
chosenProgrammes.length === 1 && filteredStudents.some(student => student.programme.studyTrack != null)
|
||||
|
||||
const columns = getColumns(
|
||||
getTextIn,
|
||||
namesVisible,
|
||||
semesterEnrollmentsVisible,
|
||||
studyTrackVisible,
|
||||
allSemestersMap,
|
||||
bachelorStudentsAreDisplayed,
|
||||
{
|
||||
getSemesterEnrollmentsContent,
|
||||
getSemesterEnrollmentsVal,
|
||||
}
|
||||
)
|
||||
|
||||
const onTabChange = (_event, { activeIndex }) => {
|
||||
setActiveTabIndex(activeIndex)
|
||||
setChosenFaculties([])
|
||||
setChosenProgrammes([])
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (isError) {
|
||||
return (
|
||||
<Message content="Please try reloading the page." header="There was an error" icon="ban" negative size="big" />
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) return <Loader active inline="centered" />
|
||||
|
||||
const facultyOptions = faculties.map(faculty => ({
|
||||
key: faculty.code,
|
||||
text: getTextIn(faculty.name),
|
||||
value: faculty.code,
|
||||
description: faculty.code,
|
||||
}))
|
||||
const programmeOptions = studyProgrammes
|
||||
.filter(
|
||||
programme =>
|
||||
!programme.value.includes('+') && programme.value.startsWith(bachelorStudentsAreDisplayed ? 'KH' : 'MH')
|
||||
)
|
||||
.filter(programme => (chosenFaculties.length > 0 ? chosenFaculties.includes(programme.value.slice(1, 4)) : true))
|
||||
.sort(createLocaleComparator('text'))
|
||||
|
||||
return (
|
||||
<Tab.Pane>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<InfoBox content={closeToGraduationToolTips} />
|
||||
<Form style={{ width: '100%', marginBottom: '1em' }}>
|
||||
<Form.Field>
|
||||
<label>Faculties</label>
|
||||
<Dropdown
|
||||
multiple
|
||||
onChange={(_, { value }) => {
|
||||
setChosenFaculties([...value])
|
||||
setChosenProgrammes(chosenProgrammes.filter(p => value.includes(p.slice(1, 4))))
|
||||
}}
|
||||
options={facultyOptions}
|
||||
placeholder="Choose faculties"
|
||||
search
|
||||
selection
|
||||
value={chosenFaculties}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>Degree programmes</label>
|
||||
<Dropdown
|
||||
multiple
|
||||
onChange={(_, { value }) => setChosenProgrammes([...value])}
|
||||
options={programmeOptions}
|
||||
placeholder="Choose degree programmes"
|
||||
search
|
||||
selection
|
||||
value={chosenProgrammes}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
<Message info style={{ width: '100%' }}>
|
||||
{`Last updated: ${reformatDate(students.lastUpdated, LONG_DATE_TIME_FORMAT)}`}
|
||||
</Message>
|
||||
<PaginatedSortableTable
|
||||
columns={columns}
|
||||
data={filteredStudents}
|
||||
defaultSort={['programme', 'asc']}
|
||||
featureName="students_close_to_graduation"
|
||||
handleDisplayedDataChange={handleDisplayedDataChange}
|
||||
rowCount={rowCount}
|
||||
rowsPerPage={200}
|
||||
title={`Students close to graduation (${rowCount} out of ${displayedStudents.length} students selected)`}
|
||||
/>
|
||||
</div>
|
||||
</Tab.Pane>
|
||||
)
|
||||
}
|
||||
|
||||
const panes = [
|
||||
{
|
||||
menuItem: "Bachelor's programmes",
|
||||
render: renderContent,
|
||||
},
|
||||
{
|
||||
menuItem: "Master's and licentiate's programmes",
|
||||
render: renderContent,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<Divider horizontal style={{ margin: '3em' }}>
|
||||
Students close to graduation
|
||||
</Divider>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: '2em',
|
||||
padding: '2em',
|
||||
gap: '2em',
|
||||
}}
|
||||
>
|
||||
<StudentNameVisibilityToggle style={{ marginTop: '0', marginBottom: '0' }} />
|
||||
<Radio
|
||||
checked={semesterEnrollmentsVisible}
|
||||
label="Show semester enrollments"
|
||||
onChange={() => setSemesterEnrollmentsVisible(!semesterEnrollmentsVisible)}
|
||||
toggle
|
||||
/>
|
||||
</div>
|
||||
<Tab activeIndex={activeTabIndex} onTabChange={onTabChange} panes={panes} style={{ maxWidth: '80%' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Divider, Loader, Tab } from 'semantic-ui-react'
|
||||
|
||||
import { getCurrentSemester } from '@/common'
|
||||
import { useTitle } from '@/common/hooks'
|
||||
import { useTitle } from '@/hooks/title'
|
||||
import { useGetSemestersQuery } from '@/redux/semesters'
|
||||
import { ColorizedCoursesTableContext } from './common'
|
||||
import { FacultiesTab } from './FacultiesTab'
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import qs from 'query-string'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLocation, useHistory } from 'react-router-dom'
|
||||
import { Button, Form, Loader, Modal, TextArea } from 'semantic-ui-react'
|
||||
|
||||
import { SearchHistory } from '@/components/SearchHistory'
|
||||
import {
|
||||
useCreateCourseListMutation,
|
||||
useDeleteCourseListMutation,
|
||||
useGetSavedCourseListsQuery,
|
||||
useUpdateCourseListMutation,
|
||||
} from '@/redux/completedCoursesSearch'
|
||||
|
||||
export const CompletedCoursesSearch = ({ setValues }) => {
|
||||
const location = useLocation()
|
||||
const history = useHistory()
|
||||
const [modal, setModal] = useState(false)
|
||||
const [courseInput, setCourseInput] = useState('')
|
||||
const [studentInput, setStudentInput] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const courseLists = useGetSavedCourseListsQuery()
|
||||
const [searchList, setSearches] = useState(null)
|
||||
const [selectedSearchId, setSelectedSearchId] = useState('')
|
||||
const isFetchingOrLoading = courseLists.isLoading || courseLists.isFetching
|
||||
const isError = courseLists.isError || (courseLists.isSuccess && !courseLists.data)
|
||||
const [updateCourseList, { isLoading: updateIsLoading, data: updatedData }] = useUpdateCourseListMutation()
|
||||
const [createCourseList, { isLoading: createIsLoading, data: createdData }] = useCreateCourseListMutation()
|
||||
const [deleteCourseList, { isLoading: deleteIsLoading, data: deletedData }] = useDeleteCourseListMutation()
|
||||
|
||||
const parseQueryFromUrl = () => {
|
||||
const query = qs.parse(location.search)
|
||||
if (!Array.isArray(query.courseList)) query.courseList = [query.courseList]
|
||||
if (!Array.isArray(query.studentList)) query.studentList = [query.studentList]
|
||||
return query
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (courseLists.isSuccess && courseLists.data) {
|
||||
setSearches(courseLists.data)
|
||||
}
|
||||
}, [courseLists])
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedData) {
|
||||
const updatedSearches = searchList.map(s => (s.id === updatedData.id ? updatedData : s))
|
||||
setSearches(updatedSearches)
|
||||
}
|
||||
}, [updatedData])
|
||||
|
||||
useEffect(() => {
|
||||
if (createdData && !createdData.error) {
|
||||
const newList = searchList.concat(createdData)
|
||||
setSearches(newList)
|
||||
}
|
||||
}, [createdData])
|
||||
|
||||
useEffect(() => {
|
||||
if (deletedData) {
|
||||
const filteredSearches = searchList.filter(s => s.id !== deletedData)
|
||||
setSearches(filteredSearches)
|
||||
}
|
||||
}, [deletedData])
|
||||
|
||||
useEffect(() => {
|
||||
if (location.search) {
|
||||
const query = parseQueryFromUrl()
|
||||
setValues(query)
|
||||
}
|
||||
}, [location.search])
|
||||
|
||||
const clearForm = () => {
|
||||
setCourseInput('')
|
||||
setStudentInput('')
|
||||
setSelectedSearchId('')
|
||||
}
|
||||
|
||||
const pushQueryToUrl = query => {
|
||||
const searchString = qs.stringify(query)
|
||||
history.push({ search: searchString })
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setModal(false)
|
||||
clearForm()
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
deleteCourseList({ id: selectedSearchId })
|
||||
clearForm()
|
||||
}
|
||||
|
||||
const onSave = () => {
|
||||
const courseList = courseInput
|
||||
.split(/[\s,]+/)
|
||||
.map(code => code.trim())
|
||||
.filter(code => code !== '')
|
||||
if (selectedSearchId !== '') {
|
||||
updateCourseList({ id: selectedSearchId, courseList })
|
||||
} else {
|
||||
createCourseList({ courseList, name })
|
||||
}
|
||||
}
|
||||
const onSelectSearch = selectedId => {
|
||||
if (!selectedId) {
|
||||
clearForm()
|
||||
return
|
||||
}
|
||||
const { id: selectedSearchId } = selectedId
|
||||
const selectedSearch = searchList?.find(search => search.id === selectedSearchId)
|
||||
if (selectedSearch) {
|
||||
setCourseInput(selectedSearch.courseList.join(', '))
|
||||
setName(selectedSearch.name)
|
||||
setSelectedSearchId(selectedSearch.id)
|
||||
}
|
||||
}
|
||||
|
||||
const onClicker = event => {
|
||||
event.preventDefault()
|
||||
|
||||
const courseList = courseInput
|
||||
.split(/[\s,]+/)
|
||||
.map(code => code.trim())
|
||||
.filter(code => code !== '')
|
||||
const studentList = studentInput
|
||||
.split(/[;\s,]+/)
|
||||
.map(code => code.trim())
|
||||
.filter(s => s !== '')
|
||||
.map(s => (s.length === 8 ? `0${s}` : s))
|
||||
|
||||
if (courseList.length === 0 || studentList.length === 0 || studentList[0] === null) {
|
||||
return
|
||||
}
|
||||
|
||||
setValues({
|
||||
studentList,
|
||||
courseList,
|
||||
})
|
||||
|
||||
pushQueryToUrl({ courseList, studentList })
|
||||
|
||||
handleClose()
|
||||
}
|
||||
|
||||
if (isError) return <h3>Something went wrong, please try refreshing the page.</h3>
|
||||
if (isFetchingOrLoading) return <Loader active style={{ marginTop: '15em' }} />
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
open={modal}
|
||||
size="small"
|
||||
trigger={
|
||||
<Button color="blue" data-cy="open-completed-courses-modal-button" onClick={() => setModal(true)} size="small">
|
||||
Search completed courses of students
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Modal.Content>
|
||||
<Form>
|
||||
<h2>Search completed courses of students</h2>
|
||||
<Form.Field>
|
||||
<em>Insert one or more student numbers, separated by a space, a newline, a comma, or a semicolon.</em>
|
||||
<TextArea
|
||||
data-cy="student-no-input"
|
||||
onChange={event => setStudentInput(event.target.value)}
|
||||
placeholder="012345678, 12345678"
|
||||
value={studentInput}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<em>Insert one or more courses, separated by a space, a newline, or a comma.</em>
|
||||
<TextArea
|
||||
data-cy="course-list-input"
|
||||
onChange={event => setCourseInput(event.target.value)}
|
||||
placeholder="TKT12345, PSYK-123"
|
||||
value={courseInput}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Form.Field data-cy="search-name">
|
||||
<em> Insert name for this course list if you wish to save it </em>
|
||||
<Form.Input
|
||||
disabled={selectedSearchId !== ''}
|
||||
onChange={event => setName(event.target.value)}
|
||||
placeholder="name"
|
||||
value={name}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form.Field>
|
||||
<SearchHistory
|
||||
handleSearch={onSelectSearch}
|
||||
header="Saved courselists"
|
||||
items={
|
||||
searchList
|
||||
? searchList.map(list => ({
|
||||
...list,
|
||||
text: list.name,
|
||||
timestamp: new Date(list.updatedAt),
|
||||
params: { id: list.id },
|
||||
}))
|
||||
: []
|
||||
}
|
||||
updateItem={() => null}
|
||||
/>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
content="Save"
|
||||
data-cy="save-courselist"
|
||||
disabled={!name || updateIsLoading || createIsLoading}
|
||||
floated="left"
|
||||
icon="save"
|
||||
loading={updateIsLoading || createIsLoading}
|
||||
onClick={onSave}
|
||||
/>
|
||||
<Button
|
||||
content="Delete"
|
||||
disabled={!selectedSearchId || deleteIsLoading}
|
||||
floated="left"
|
||||
icon="trash"
|
||||
negative
|
||||
onClick={onDelete}
|
||||
/>
|
||||
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button data-cy="completed-courses-search-button" onClick={event => onClicker(event)} positive>
|
||||
Search
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
</Form>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user