mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81da962991 | ||
|
|
d3d2c2aaf2 | ||
|
|
8fc2019358 | ||
|
|
7f253257b1 | ||
|
|
43c866fa74 | ||
|
|
a5c842222f | ||
|
|
2e948c802c | ||
|
|
da64f63d48 | ||
|
|
800012ef05 | ||
|
|
b29d478c86 | ||
|
|
d4506fe70f | ||
|
|
80159e46f0 | ||
|
|
3a834eb5f4 | ||
|
|
e56e553f25 | ||
|
|
d6e32d2b19 | ||
|
|
1413745a3a | ||
|
|
e174dc5403 | ||
|
|
45cd8d82d9 | ||
|
|
a10e3dbaf8 | ||
|
|
afa652db30 | ||
|
|
a1165c76dc | ||
|
|
affea21c5a | ||
|
|
3e68dcd494 | ||
|
|
eb6837a079 | ||
|
|
a29e01b6d9 | ||
|
|
6916824d26 | ||
|
|
21127a7d6c | ||
|
|
af9f7c06e6 | ||
|
|
f882a22263 | ||
|
|
89ae035cf3 | ||
|
|
1767790adb | ||
|
|
a46ff9b570 | ||
|
|
25b6de49c0 | ||
|
|
c6903ebe25 | ||
|
|
2d2c25b763 | ||
|
|
d232312365 | ||
|
|
f26f114b1d | ||
|
|
7f8a1bdb0d | ||
|
|
8d0dcc5e1a | ||
|
|
c7173b34ec | ||
|
|
361af09dc7 | ||
|
|
f6db93ee67 | ||
|
|
dee45df9b1 | ||
|
|
9d53e3343e | ||
|
|
86083ecc0b | ||
|
|
693de4a505 | ||
|
|
8ad4be048d | ||
|
|
293c104f49 | ||
|
|
6e6fa1bad0 | ||
|
|
59772a0dad | ||
|
|
15a64c8727 | ||
|
|
fc6bd6f866 | ||
|
|
11566d95c3 | ||
|
|
f155979cb8 | ||
|
|
667a403c35 | ||
|
|
34aaba9444 | ||
|
|
38c84a0d6c | ||
|
|
130da21892 | ||
|
|
c160aa85ad | ||
|
|
8247c55545 | ||
|
|
3c6cb9aaac | ||
|
|
313a050688 | ||
|
|
d12de74d1b | ||
|
|
1d10107e0d | ||
|
|
da5cee8e66 | ||
|
|
d0ff477d13 | ||
|
|
5da6949a8a | ||
|
|
025f0bc954 | ||
|
|
8208f1ade5 | ||
|
|
648f11ea88 | ||
|
|
f1435aac7f | ||
|
|
f356d5cb7d | ||
|
|
3bb2c8e88c | ||
|
|
e38b0bb0ae | ||
|
|
77a84f479c | ||
|
|
9b55cf5455 | ||
|
|
47dc180145 | ||
|
|
f0e161359e | ||
|
|
f4bf29c717 | ||
|
|
50a21143e8 | ||
|
|
d208012f07 | ||
|
|
40c3cf7ca1 | ||
|
|
b432e1b359 | ||
|
|
7762b81cb8 | ||
|
|
55c2d94b3f | ||
|
|
b7d3d6580d | ||
|
|
f9525d7757 | ||
|
|
bf28e8e013 | ||
|
|
5bd0d291a6 | ||
|
|
499b459807 | ||
|
|
811641e0c3 | ||
|
|
add0074205 | ||
|
|
586c297ada | ||
|
|
99984f5aa0 | ||
|
|
262c5b253c | ||
|
|
329899b089 | ||
|
|
c9774a80d4 | ||
|
|
40e675b147 | ||
|
|
4c73a1dbed | ||
|
|
21d6187c38 | ||
|
|
8c18bbc942 | ||
|
|
c02fe5af2a | ||
|
|
32b0c8a3db | ||
|
|
ca4bb11d12 | ||
|
|
fed71d4435 | ||
|
|
d23a0e67a4 | ||
|
|
6d37b116cf | ||
|
|
774b883513 | ||
|
|
18aa9ded2d | ||
|
|
f423b48a7b | ||
|
|
97a0725740 | ||
|
|
9789fe79e4 | ||
|
|
3d6cce2593 | ||
|
|
4b9e1850b1 | ||
|
|
9ad7452334 | ||
|
|
2c9bf88270 | ||
|
|
bc36f0da91 | ||
|
|
a3187dcc53 | ||
|
|
757d2ad730 | ||
|
|
1c16621124 | ||
|
|
45cbcd58ad | ||
|
|
92f394b230 | ||
|
|
ac71464055 | ||
|
|
94af09d0d4 | ||
|
|
99684923dc | ||
|
|
ab63820736 | ||
|
|
7cd61602df | ||
|
|
dacce9764e | ||
|
|
1c6176830c | ||
|
|
23a600477f | ||
|
|
0127d41381 | ||
|
|
4a4a6efb0f | ||
|
|
efc773141b | ||
|
|
89aa2b6931 | ||
|
|
7f01fb688b | ||
|
|
ccca69ce82 | ||
|
|
10f09577a0 | ||
|
|
bf38fe33ab | ||
|
|
427d2cdd4d | ||
|
|
a61876657f | ||
|
|
f74f319c89 | ||
|
|
6ebbf32e1f | ||
|
|
395eab9b58 | ||
|
|
d99ff8ee50 | ||
|
|
94042a7ea6 | ||
|
|
d9c869e13f | ||
|
|
8a718554ea | ||
|
|
c8036892c6 | ||
|
|
d664a19c10 | ||
|
|
28e2316f04 | ||
|
|
0e48735748 | ||
|
|
67787daccf | ||
|
|
d7829a0cfc | ||
|
|
2424b115f5 | ||
|
|
3ebe68998b | ||
|
|
6947d1b858 | ||
|
|
629cec7560 | ||
|
|
094f625eb5 | ||
|
|
b7720f620c | ||
|
|
9e5acd3145 | ||
|
|
704265963c | ||
|
|
b4bfcd1292 | ||
|
|
302f0b3b33 | ||
|
|
2ba108a34e | ||
|
|
86d656dc4c | ||
|
|
2e8c9c2a7e | ||
|
|
730ac94c20 | ||
|
|
44584d1f7d | ||
|
|
d93b708029 | ||
|
|
dcbc7a916e | ||
|
|
a7e3e45690 | ||
|
|
2404d9a42c | ||
|
|
340912b5a1 | ||
|
|
7aaf584eaa | ||
|
|
57de5435bd | ||
|
|
1804bccefb | ||
|
|
6461aaff4e | ||
|
|
b32a0fb3fe | ||
|
|
ad0ccbc98b | ||
|
|
a1f3cb8fa8 | ||
|
|
b074b90553 | ||
|
|
ce7a34e1d7 | ||
|
|
314dcc7215 | ||
|
|
7c9c5508d0 | ||
|
|
d6b289eef2 | ||
|
|
eaaa55bba6 | ||
|
|
60fb3a8ae0 | ||
|
|
208bd3511d | ||
|
|
3a9a5a69e4 | ||
|
|
3b8aa5bf56 | ||
|
|
4e373d4ef2 | ||
|
|
219dc7ce47 | ||
|
|
9bc087818a | ||
|
|
ad1998bcd0 | ||
|
|
fb118094a9 | ||
|
|
dc9a7b3920 | ||
|
|
5b5ec30458 | ||
|
|
a7d30769b8 | ||
|
|
442f2685e5 | ||
|
|
ae097d04b6 | ||
|
|
e934157821 | ||
|
|
db6cb155a0 | ||
|
|
8c6ea09791 | ||
|
|
34ee583425 | ||
|
|
592ea364f4 | ||
|
|
2212af8d7e | ||
|
|
27bba9f135 | ||
|
|
42112bb36e | ||
|
|
b1aed16f99 | ||
|
|
238e6ea9ce | ||
|
|
0682d90dce | ||
|
|
bc26f1cf7b | ||
|
|
5b2c3d7fff | ||
|
|
ffd7da54cc | ||
|
|
3b6b387c0d | ||
|
|
a68d1f13c1 | ||
|
|
dbf838f5a5 | ||
|
|
c37c9d0b99 | ||
|
|
24e478e7b4 | ||
|
|
2481713a0a | ||
|
|
843a61d74a | ||
|
|
783c46b62e | ||
|
|
ec55cc101f | ||
|
|
9698329ae2 | ||
|
|
191b8b4c3a | ||
|
|
b0d439c1be | ||
|
|
0f21a53381 | ||
|
|
b2ff61f85b | ||
|
|
ac7e3f2423 | ||
|
|
46f316c82f | ||
|
|
926a0d67d1 | ||
|
|
4e319e801b | ||
|
|
3182fa89d4 | ||
|
|
012686c49b | ||
|
|
9d5a927d86 | ||
|
|
05f6a12871 | ||
|
|
1ed659f93c | ||
|
|
215002a86c | ||
|
|
dea0d29053 | ||
|
|
daac389968 | ||
|
|
3e0ca1827a | ||
|
|
fe2f73bd5c | ||
|
|
d27a931c4e | ||
|
|
7f02d194d4 | ||
|
|
72e7519cb7 | ||
|
|
a39b80946c | ||
|
|
9b7b7c7e2d | ||
|
|
73e69b5f57 | ||
|
|
6a4a7b539b | ||
|
|
a2dab9dbbe | ||
|
|
b06931b403 | ||
|
|
902af27091 | ||
|
|
6d1d8e90c1 | ||
|
|
003ff2665e | ||
|
|
56b87ba3a1 | ||
|
|
ea94c510b0 | ||
|
|
97b82c71bc | ||
|
|
37710702a3 | ||
|
|
06dff4c3bc | ||
|
|
47d8fef876 | ||
|
|
267b79324c | ||
|
|
88fc069110 | ||
|
|
3ea74621db | ||
|
|
e88c544bb4 | ||
|
|
14d63f7009 | ||
|
|
613aa14fd4 | ||
|
|
d35e4309be | ||
|
|
89e261c5f2 | ||
|
|
df8a257030 | ||
|
|
db391fb15d | ||
|
|
7f96288849 | ||
|
|
c64ba8ce29 | ||
|
|
1a1d55bf93 | ||
|
|
44b47331bb | ||
|
|
bcaa0f498f | ||
|
|
59827d440a | ||
|
|
7f9b472e30 | ||
|
|
95141e652e | ||
|
|
cf2a7e56e0 | ||
|
|
e50a6d5577 | ||
|
|
d1c41972f7 | ||
|
|
cebee9d8cf | ||
|
|
3f7b62015e | ||
|
|
e4e5a9d075 | ||
|
|
435209b742 | ||
|
|
866c5a64e4 | ||
|
|
5f20d7a344 | ||
|
|
6dcfadb68e | ||
|
|
192171f270 | ||
|
|
0538329d60 | ||
|
|
5b458933d2 | ||
|
|
fdbed2f1cf | ||
|
|
cc73d5983e | ||
|
|
e69e8dbe02 | ||
|
|
ab460b0e9f | ||
|
|
b1d8e852df | ||
|
|
d5dfed6942 | ||
|
|
5d6c0500a3 | ||
|
|
2b58b9ba91 | ||
|
|
9bb72b2f0e | ||
|
|
ad73cd7d0f | ||
|
|
83551edacf | ||
|
|
a0ee324fe7 | ||
|
|
fb221edd87 | ||
|
|
64c61d1be2 | ||
|
|
216083a0d4 | ||
|
|
a04789094e | ||
|
|
13db9cdb6d | ||
|
|
3b78e7ade3 | ||
|
|
7cbc1dfb26 | ||
|
|
ab37b967ad | ||
|
|
5b15a2516f | ||
|
|
56e6e88a9e | ||
|
|
3ed5aed710 | ||
|
|
a0294b7750 | ||
|
|
9539d6b97b | ||
|
|
578fef70a8 | ||
|
|
31fac70a31 | ||
|
|
9e631d119f | ||
|
|
ef0d95f83f | ||
|
|
e2774bfdc4 | ||
|
|
dc65bb4571 | ||
|
|
48cb58deb7 | ||
|
|
c65e4dadad | ||
|
|
8388914406 | ||
|
|
3e87dd0c79 | ||
|
|
81dda57671 | ||
|
|
7670562b90 | ||
|
|
df632591bb | ||
|
|
ece01ad9d2 | ||
|
|
be1be25d00 | ||
|
|
4c553124bf | ||
|
|
a0e26d361f | ||
|
|
4bf5982062 | ||
|
|
d06d7b120b | ||
|
|
ad3ebe0464 | ||
|
|
76a7d40784 | ||
|
|
ba4808aaf7 | ||
|
|
fdc97a4537 | ||
|
|
73c3882751 | ||
|
|
d04c77e7d2 | ||
|
|
07fb9b7905 | ||
|
|
002d91613a | ||
|
|
0a5332664b | ||
|
|
d72f1a8478 | ||
|
|
246ad05031 | ||
|
|
83b79fc9ea | ||
|
|
e8df57f895 | ||
|
|
040f8cad8f | ||
|
|
3cde980950 | ||
|
|
a2c665a501 | ||
|
|
ee99dcb449 | ||
|
|
b4509f79cd | ||
|
|
c7121795c3 | ||
|
|
21e4e44713 | ||
|
|
37f93b328a | ||
|
|
ce0b42f88b | ||
|
|
dcd8ba77b3 | ||
|
|
6dc3bdb72a | ||
|
|
ef80ee436d | ||
|
|
6ad8981547 | ||
|
|
3169c34864 | ||
|
|
1d769c4fa2 | ||
|
|
11c6ab2db4 | ||
|
|
b48ca23e9d | ||
|
|
14da33f852 | ||
|
|
5e8beae9ed | ||
|
|
9676371823 | ||
|
|
5267cff110 | ||
|
|
647b2197fe | ||
|
|
9f8048c0fb | ||
|
|
3f9a245d16 | ||
|
|
ebfc16a07e | ||
|
|
56ffaa6dd8 | ||
|
|
e142fbb449 | ||
|
|
219608293c | ||
|
|
8faa244c56 | ||
|
|
dea230ecec | ||
|
|
196bea2a58 | ||
|
|
23dceab14c | ||
|
|
7af0f28c14 | ||
|
|
9433aef65a | ||
|
|
ad1455b03e | ||
|
|
4f698b542e | ||
|
|
4b8b042c2f | ||
|
|
f82ddf660c | ||
|
|
54fec454f0 | ||
|
|
610fab1c21 | ||
|
|
2bbc041f22 | ||
|
|
4efde9a997 | ||
|
|
be309972be | ||
|
|
2603ef507a | ||
|
|
6334076698 | ||
|
|
3579580029 | ||
|
|
bede2502f5 | ||
|
|
142f5c444f | ||
|
|
43d11e615b | ||
|
|
2dcdae9796 | ||
|
|
c0308f43fa | ||
|
|
9f6606daa2 | ||
|
|
5b7dda18ca | ||
|
|
47685ac459 | ||
|
|
54467fcdcf | ||
|
|
83673a77f4 | ||
|
|
7531a9def4 | ||
|
|
ce244e1cb2 | ||
|
|
8c20865e7a | ||
|
|
a0fdc68117 | ||
|
|
380c2f8154 | ||
|
|
8922d1065f | ||
|
|
4c7b52cfcb | ||
|
|
07bea28642 | ||
|
|
e13a4da9ea | ||
|
|
3f58a321a5 | ||
|
|
f5b81d73ac | ||
|
|
b5cfc051b7 | ||
|
|
eba3a77e2e | ||
|
|
a20bdb5723 | ||
|
|
4544d1cdec | ||
|
|
072360763f | ||
|
|
639858f874 | ||
|
|
92f3e67293 | ||
|
|
983390040e | ||
|
|
277b97327e | ||
|
|
3dab357396 | ||
|
|
a39648f380 | ||
|
|
ccb1865853 | ||
|
|
81da089798 | ||
|
|
785b7d5d74 | ||
|
|
aca70d5442 | ||
|
|
41d93c9b43 | ||
|
|
835c9e580b | ||
|
|
9c9f317ecb | ||
|
|
3ee6758c72 | ||
|
|
88764f83de | ||
|
|
e8908d28fc | ||
|
|
a6d7da315f | ||
|
|
e72e0bb5ad | ||
|
|
15b0286eda | ||
|
|
1104483cac | ||
|
|
604bcf4998 | ||
|
|
cc839a9e14 | ||
|
|
2a076ef6e8 | ||
|
|
b05e1c22e5 | ||
|
|
b9800c857f | ||
|
|
6d535bf4bb | ||
|
|
714f85d5c0 | ||
|
|
01af4cfd2c | ||
|
|
fe55b770e9 | ||
|
|
43e4e605d6 | ||
|
|
15a54f1e26 | ||
|
|
631b5be00a | ||
|
|
b8d9648b8c | ||
|
|
d52562330b | ||
|
|
f4c9137a05 | ||
|
|
9a1802b6b4 | ||
|
|
fd2d95a832 | ||
|
|
dfb557d4e1 | ||
|
|
22e2ccf562 | ||
|
|
68480a7211 | ||
|
|
bb4edef070 | ||
|
|
6877716468 | ||
|
|
a21e91fbaa | ||
|
|
62e7de5fef | ||
|
|
785b84b14c | ||
|
|
e488327fd4 | ||
|
|
2642e15479 | ||
|
|
547d1c7d35 | ||
|
|
d57ecb2aa9 | ||
|
|
ef76b0f350 | ||
|
|
3ba2bd8d5b | ||
|
|
12976a961b | ||
|
|
01c6159ffa | ||
|
|
a6c1e3891d | ||
|
|
6c4b42c98d | ||
|
|
10941259a2 | ||
|
|
b7563cd4ec | ||
|
|
8d261aae51 | ||
|
|
ccf70ed420 | ||
|
|
20f4bd32f8 | ||
|
|
d62140e97d | ||
|
|
0e510f039f | ||
|
|
893d3847e2 | ||
|
|
0c46fcdd09 | ||
|
|
4397a542c2 | ||
|
|
25a4975265 | ||
|
|
b89967fb17 | ||
|
|
36ce4f1405 | ||
|
|
71d5849360 | ||
|
|
6f335a3555 | ||
|
|
e7eab4741e | ||
|
|
64a67151d6 | ||
|
|
229d155b7c | ||
|
|
42ef2b0245 | ||
|
|
79bfb690b7 | ||
|
|
1b8b71075a | ||
|
|
a77bba0881 | ||
|
|
eda8dcac97 | ||
|
|
b0e0bae727 | ||
|
|
47d5faec81 | ||
|
|
8f90824d8b | ||
|
|
e16f7ddf85 | ||
|
|
0c02d25257 | ||
|
|
68484728d2 | ||
|
|
1d3fbdf869 | ||
|
|
c22b415b85 | ||
|
|
920c1aac5f | ||
|
|
31787e6df0 | ||
|
|
82a4ea9465 | ||
|
|
bb7f965299 | ||
|
|
ff5fac8f0d | ||
|
|
cede895135 | ||
|
|
0ebff9dd39 | ||
|
|
ce94943d54 | ||
|
|
98f388a8a3 | ||
|
|
9b366e2974 | ||
|
|
452fb93b2c | ||
|
|
b9cd6aab7f | ||
|
|
42e76e25e6 | ||
|
|
5ae4f80fab | ||
|
|
d441f3a6b3 | ||
|
|
d22e746e02 | ||
|
|
5b5292d53a | ||
|
|
12f90192c6 | ||
|
|
94d38fecc1 | ||
|
|
6a649b5f00 | ||
|
|
ab391472a6 | ||
|
|
267067e83a | ||
|
|
17f376a681 | ||
|
|
9d83a06377 | ||
|
|
0f5c2e3160 | ||
|
|
364ba0bca3 | ||
|
|
9d5f2a51e3 | ||
|
|
d9260c17f8 | ||
|
|
612cb08689 | ||
|
|
ae90dfa006 | ||
|
|
a4635d8d5b | ||
|
|
2a48dc5a77 | ||
|
|
778807ecd0 | ||
|
|
eca6b643bd | ||
|
|
cde59177ed | ||
|
|
c229e4f030 | ||
|
|
cff0cbef04 | ||
|
|
c0a4e9d8e5 | ||
|
|
3cd2bcefea | ||
|
|
4b40011f6b | ||
|
|
b8c147ceec | ||
|
|
5d169b3321 | ||
|
|
1b86aada0e | ||
|
|
f3b027a609 | ||
|
|
3797fed1a9 | ||
|
|
ec3f36a9e4 | ||
|
|
ed7677d126 | ||
|
|
bcbeeaceb5 | ||
|
|
f80200d481 | ||
|
|
e64e0d04fd | ||
|
|
85432092fa | ||
|
|
b6a39c829e | ||
|
|
0a6edf6638 | ||
|
|
2d44506c55 | ||
|
|
d5dc511b39 | ||
|
|
1e9c2857c5 | ||
|
|
58190f8757 | ||
|
|
caa715677b | ||
|
|
94c34a10eb | ||
|
|
f86c90ff8c | ||
|
|
617379e63f | ||
|
|
cb1b44d4a3 | ||
|
|
2ae9d662dd | ||
|
|
cd73900749 | ||
|
|
60a03b8643 | ||
|
|
35ffc21b91 | ||
|
|
e2886c9e35 | ||
|
|
c50d365dcb | ||
|
|
6843b587cd | ||
|
|
d1d4e3105e | ||
|
|
0fe018bb96 | ||
|
|
d2685b13d7 | ||
|
|
ec2bdcc8ea | ||
|
|
37518f554a | ||
|
|
5b33803d9f | ||
|
|
5dd534e4d9 | ||
|
|
589bcb6128 | ||
|
|
a5af6ebd47 | ||
|
|
08b3d29b91 | ||
|
|
8ee1cac66f | ||
|
|
32a9cdbea6 | ||
|
|
372dbd4769 | ||
|
|
49089975db | ||
|
|
e4ff103cb1 | ||
|
|
ef683f8fe1 | ||
|
|
6e3da7e1af | ||
|
|
c9fe222dd3 | ||
|
|
084be9df1b | ||
|
|
0b86f82eb7 | ||
|
|
993d5e842e | ||
|
|
6ab7b9bbc4 | ||
|
|
0ccb1e79c8 | ||
|
|
24ade5b222 | ||
|
|
ae9fbf182c | ||
|
|
4be53e7707 | ||
|
|
c980560f53 | ||
|
|
b1928a7843 | ||
|
|
2e60df5804 | ||
|
|
2ccd526805 | ||
|
|
92b6b3cb60 | ||
|
|
fbc842cac5 | ||
|
|
cb334e2865 | ||
|
|
24bd750763 | ||
|
|
eb07173f36 | ||
|
|
815d85d8e2 | ||
|
|
a4a3c0f2ca | ||
|
|
20c3632972 | ||
|
|
7af6374e7b | ||
|
|
9accbc299a | ||
|
|
7a03455c8e | ||
|
|
a72c5eeaea | ||
|
|
1f2d381039 | ||
|
|
a31ef9af07 | ||
|
|
fcd855a3e2 | ||
|
|
6a31bcf4d7 | ||
|
|
d3de30bdec | ||
|
|
1ee4fb8bcf | ||
|
|
3b58c13f4a | ||
|
|
9ca46021aa | ||
|
|
87ade36e4b | ||
|
|
32a670a8d1 | ||
|
|
f2478fbe24 | ||
|
|
f81a5ee1a4 | ||
|
|
bf780d09d7 | ||
|
|
3bbee5c0b3 | ||
|
|
880f5997c1 | ||
|
|
44e2bad801 | ||
|
|
18a5eadbcc | ||
|
|
e871b77192 | ||
|
|
4772ca99c2 | ||
|
|
6f6b059d83 | ||
|
|
2cc016e439 | ||
|
|
7670dbd50f | ||
|
|
86a3081a1c | ||
|
|
01b39926b6 | ||
|
|
790e3d3afd | ||
|
|
b4fbe1b47d | ||
|
|
0d2701269a | ||
|
|
8c6a634df0 | ||
|
|
a0117c0de1 | ||
|
|
3e12a4df14 | ||
|
|
5aaa343720 | ||
|
|
f2f8d5d6d0 | ||
|
|
ede3748c09 | ||
|
|
6cc230512c | ||
|
|
24e42c3b63 | ||
|
|
c3e222c155 | ||
|
|
1fb81f4bbf | ||
|
|
e3e0a24b9a | ||
|
|
66bed7f88a | ||
|
|
c59b0a3b58 | ||
|
|
37a5b17578 | ||
|
|
16d938a423 | ||
|
|
0132a98733 | ||
|
|
d99f3b326f | ||
|
|
61bc346923 | ||
|
|
358eac4de2 | ||
|
|
863a7ac157 | ||
|
|
0faef48179 | ||
|
|
b29eb0c8e6 | ||
|
|
2f8b38617b | ||
|
|
0489155da7 | ||
|
|
8bf1da6047 | ||
|
|
e0bb37a75a | ||
|
|
27d4962fcb | ||
|
|
6ca346a00a | ||
|
|
d1bbd9966e | ||
|
|
25e21ecb14 | ||
|
|
72cdbcb871 | ||
|
|
1a9759f77f | ||
|
|
b1e65803d3 | ||
|
|
e19697a9c0 | ||
|
|
a584581141 | ||
|
|
b7a98483e4 | ||
|
|
20c197f3a7 | ||
|
|
d07ba795df | ||
|
|
b17b714be2 | ||
|
|
22a5e58fe3 | ||
|
|
9a0fbd50bd | ||
|
|
51572ebeed | ||
|
|
139e253d6a | ||
|
|
0df918b47a | ||
|
|
781bc5da3d | ||
|
|
67297e536c | ||
|
|
dcdacc7187 | ||
|
|
684a90c60c | ||
|
|
0c1a1e81cb | ||
|
|
8c8eb34a05 | ||
|
|
f1ebbeb0e9 | ||
|
|
c176552326 | ||
|
|
7419cc169d | ||
|
|
c21ffe9a74 | ||
|
|
2b15168342 | ||
|
|
3e296e33bd | ||
|
|
37df5276f7 | ||
|
|
7a26501d72 | ||
|
|
561e41cab1 | ||
|
|
4409f60195 | ||
|
|
323aae2600 | ||
|
|
fa28d86638 | ||
|
|
1976d2914d | ||
|
|
52e6a49fd3 | ||
|
|
fc4e48aaa0 | ||
|
|
ae83dc3563 | ||
|
|
2109cfcf87 | ||
|
|
1a3facf680 | ||
|
|
d016cec63c | ||
|
|
74bfceaf72 | ||
|
|
a549ae5095 | ||
|
|
863cb9d134 | ||
|
|
eb2576bfad | ||
|
|
77831b3bcf | ||
|
|
99ff14b13f | ||
|
|
e3c2427885 | ||
|
|
a6e52a690f | ||
|
|
eac4a03ea8 | ||
|
|
def64d5612 | ||
|
|
9ddd42aced | ||
|
|
60aa43d520 | ||
|
|
49be4b4f54 | ||
|
|
2f5c68f439 | ||
|
|
54321f349c | ||
|
|
9aba7ab5c5 | ||
|
|
059ccbd42b | ||
|
|
733cfac1be | ||
|
|
bcf1bca28b | ||
|
|
025aca0c15 | ||
|
|
00c4a6b409 | ||
|
|
9be9509562 | ||
|
|
9e6a92c114 | ||
|
|
178ff11b48 | ||
|
|
e7a1cdf098 | ||
|
|
60f10faf9e | ||
|
|
b31b3543bd | ||
|
|
b4263f29c5 | ||
|
|
6a69a3b98b | ||
|
|
921fa2d1de | ||
|
|
c4724f8c07 | ||
|
|
44a7f8bcee | ||
|
|
a632f3757d | ||
|
|
a84ad67b76 | ||
|
|
29331e13d3 | ||
|
|
0358cb49a6 | ||
|
|
78719ee09a | ||
|
|
e66542fe89 | ||
|
|
ca9d43b4a9 | ||
|
|
286b4a90b4 | ||
|
|
cfa65da634 | ||
|
|
3c81a8cbcd | ||
|
|
444a261261 | ||
|
|
a5928b0dba | ||
|
|
27ab354a70 | ||
|
|
e293eb8471 | ||
|
|
4dcacbb2ce | ||
|
|
122eb29068 | ||
|
|
d5e6b52316 | ||
|
|
601d69cc3d | ||
|
|
e8f73adc81 | ||
|
|
c0187ad18e | ||
|
|
f877e1187d | ||
|
|
3d86be5b9a | ||
|
|
fe9aad8043 | ||
|
|
34d9855782 | ||
|
|
e1213bd768 | ||
|
|
5d6df13d8e | ||
|
|
bf08700807 | ||
|
|
c95b5a9cef | ||
|
|
cd7766982b | ||
|
|
02932036fe | ||
|
|
7b3bea1f9c | ||
|
|
09dec983aa | ||
|
|
3e8ac19af1 | ||
|
|
93aacd3e16 | ||
|
|
29108ca2b2 | ||
|
|
bfa14c9959 | ||
|
|
836f797da9 | ||
|
|
45ca6f0083 | ||
|
|
2b44529ed4 | ||
|
|
e27d162921 | ||
|
|
7ebddf076f | ||
|
|
d0283bbc34 | ||
|
|
facbb2da44 | ||
|
|
cafc09f805 | ||
|
|
c3a61ab7b3 | ||
|
|
2a0c39ab05 | ||
|
|
c83646512e | ||
|
|
82c6181cd5 | ||
|
|
cc5f5e01c6 | ||
|
|
7e04951e08 | ||
|
|
5cabca6311 | ||
|
|
96b181d3af | ||
|
|
72acdf9a3b | ||
|
|
0b29b6aa58 | ||
|
|
c272f1fde3 | ||
|
|
7174ed55c1 | ||
|
|
f958ba6701 | ||
|
|
93264fef4d | ||
|
|
a4baf8ae6d | ||
|
|
f3aa59fe1f | ||
|
|
322f19826a | ||
|
|
2a2388cbf6 | ||
|
|
d2c283c1b9 | ||
|
|
3d84fb6a74 | ||
|
|
6a53e30a94 | ||
|
|
f8616ba1c4 | ||
|
|
a87c35069d | ||
|
|
fe87b0b960 | ||
|
|
1a8b88dd0e | ||
|
|
b09cb8d574 | ||
|
|
630df92e9d | ||
|
|
948808ce11 | ||
|
|
30e822b3f6 | ||
|
|
2769d0999e | ||
|
|
86a1ef7b9f | ||
|
|
b4b6d45463 | ||
|
|
dc7ad404cb | ||
|
|
dec0087de0 | ||
|
|
61f91d1cfe | ||
|
|
c11f905a2e | ||
|
|
b2e433a79b | ||
|
|
1e7160dcf5 | ||
|
|
da973fe74a | ||
|
|
309d3128e4 | ||
|
|
4add189096 | ||
|
|
f5ddf6291b | ||
|
|
3989e0da94 | ||
|
|
3035b846a4 | ||
|
|
c88aa37b68 | ||
|
|
884191f352 | ||
|
|
35af223c32 | ||
|
|
71e639efa1 | ||
|
|
546c683c8b | ||
|
|
1c5c7781a5 | ||
|
|
99b08020db | ||
|
|
8c2efa9199 | ||
|
|
7c50c80b15 | ||
|
|
9290fbf32b | ||
|
|
ffaa3eb05b | ||
|
|
77b159739f | ||
|
|
0e63b970f7 | ||
|
|
7bedbe6451 | ||
|
|
dacf5cc51b | ||
|
|
de5fdb7eaf | ||
|
|
c35a632469 | ||
|
|
691b6d6cb3 | ||
|
|
c8a69a15b4 | ||
|
|
665b95a6f8 | ||
|
|
d6ab10bed2 | ||
|
|
a7fb7eda97 | ||
|
|
44ce8ac298 | ||
|
|
7312416e2b | ||
|
|
d967bce32b | ||
|
|
4716299771 | ||
|
|
1036b47523 | ||
|
|
317fb6f655 | ||
|
|
d6d804d288 | ||
|
|
8b3c408700 | ||
|
|
7a94c92181 | ||
|
|
2feeaddb8b | ||
|
|
6d9c4b0b0a | ||
|
|
c9abbf958a | ||
|
|
47692de1ce | ||
|
|
ad6260a313 | ||
|
|
298e43a89a | ||
|
|
055d3b4eac | ||
|
|
5a831cdc86 | ||
|
|
c23ee470d8 | ||
|
|
59b4aad1ab | ||
|
|
de3e0f5e6b | ||
|
|
7ede62760e | ||
|
|
d2fdeeb830 | ||
|
|
220952153f | ||
|
|
bace3c80af | ||
|
|
d9457bf855 | ||
|
|
2d6d12f95f | ||
|
|
93a3014209 | ||
|
|
3d6bc23658 | ||
|
|
1906827760 | ||
|
|
4b8f389dd3 | ||
|
|
5d69a9b53b | ||
|
|
5c0c267606 | ||
|
|
00c7e45672 | ||
|
|
b28d43c7f8 | ||
|
|
c0f15c9dd9 | ||
|
|
64240c9f6f | ||
|
|
cbbcdd0e40 | ||
|
|
dc7a6805a7 | ||
|
|
0321dbed62 | ||
|
|
88a46a8f23 | ||
|
|
5c26f04436 | ||
|
|
e80fa25fb3 | ||
|
|
12f11183cf | ||
|
|
abfefa8c3e | ||
|
|
eb82eee286 | ||
|
|
5ff9b3b788 | ||
|
|
f4d73b649f | ||
|
|
4136c0d3b5 | ||
|
|
2b8cf76053 | ||
|
|
e23c60cb38 | ||
|
|
676804b770 | ||
|
|
2785742934 | ||
|
|
f82638763f | ||
|
|
7253ba1e17 | ||
|
|
e2847a68ea | ||
|
|
687e231e65 | ||
|
|
0629614ed8 | ||
|
|
843e04f154 | ||
|
|
d7c3423a3f | ||
|
|
8be238d4cc | ||
|
|
ecbaa35802 | ||
|
|
732e14cb2d | ||
|
|
e98ef1b94f | ||
|
|
e254de4df3 | ||
|
|
1d169ee70a | ||
|
|
0380cab715 | ||
|
|
4391c06f00 | ||
|
|
36a3044b16 | ||
|
|
db08330cb9 | ||
|
|
0a071ee071 | ||
|
|
2354b696a5 | ||
|
|
f6db0f2fe8 | ||
|
|
e40c219e78 | ||
|
|
2e7ca4d960 | ||
|
|
c3fd874559 | ||
|
|
80768b4d15 | ||
|
|
7de955398c | ||
|
|
2510903d63 | ||
|
|
ef3aa1faf2 | ||
|
|
7444567d64 | ||
|
|
a559dab709 | ||
|
|
34feb51d7b | ||
|
|
a206a1a9b3 |
@@ -1,82 +0,0 @@
|
||||
version: '2'
|
||||
|
||||
checks:
|
||||
argument-count:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 4
|
||||
complex-logic:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 4
|
||||
file-lines:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 250
|
||||
method-complexity:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 10
|
||||
method-count:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 20
|
||||
method-lines:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 25
|
||||
nested-control-flow:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: 4
|
||||
return-statements:
|
||||
enabled: false
|
||||
similar-code:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: # Language-specific defaults. Overrides affect all languages.
|
||||
identical-code:
|
||||
enabled: true
|
||||
config:
|
||||
threshold: # Language-specific defaults. Overrides affect all languages.
|
||||
|
||||
exclude_patterns:
|
||||
- '.github/'
|
||||
- '.husky/'
|
||||
- '.vscode/'
|
||||
- 'adminer-plugins/'
|
||||
- 'anonyymioodi/'
|
||||
- 'cypress/'
|
||||
- 'documentation/'
|
||||
- 'scripts/'
|
||||
- 'services/backend/src/database/migrations_kone/'
|
||||
- 'services/backend/src/database/migrations_user/'
|
||||
- 'services/backend/src/models/'
|
||||
- 'updater/sis-updater-worker/src/db/migrations/'
|
||||
- 'updater/sis-updater-worker/src/db/models/'
|
||||
- '.codeclimate.yml'
|
||||
- '.env.template'
|
||||
- '.lintstagedrc.js'
|
||||
- '.nycrc'
|
||||
- '.prettierrc'
|
||||
- '.stylelintrc'
|
||||
- 'cypress.config.js'
|
||||
- 'LICENSE'
|
||||
- 'nginx.conf'
|
||||
- 'nyc-config.js'
|
||||
- '**/.dockerignore'
|
||||
- '**/.eslintrc.json'
|
||||
- '**/.gitignore'
|
||||
- '**/dev.Dockerfile'
|
||||
- '**/Dockerfile'
|
||||
- '**/*.css'
|
||||
- '**/*.csv'
|
||||
- '**/*.html'
|
||||
- '**/*.json'
|
||||
- '**/*.md'
|
||||
- '**/*.png'
|
||||
- '**/*.sh'
|
||||
- '**/*.sql'
|
||||
- '**/*.svg'
|
||||
- '**/*.txt'
|
||||
- '**/*.yml'
|
||||
+11
-4
@@ -1,6 +1,13 @@
|
||||
// ! If we don't have this file, running ESLint will fail because of the import/no-unused-modules rule
|
||||
// ! See: https://github.com/import-js/eslint-plugin-import/issues/3079
|
||||
// ! When this issue is resolved, this file can be removed
|
||||
{
|
||||
"ignorePatterns": ["**/node_modules/", "**/dist/", "**/build/"]
|
||||
"ignorePatterns": [
|
||||
"eslint.config.mjs",
|
||||
"**/build/**",
|
||||
"**/dist/**",
|
||||
"**/node_modules/**",
|
||||
"**/instrumented/**",
|
||||
"**/coverage/**",
|
||||
"nyc-config.cjs",
|
||||
".lintstagedrc.mjs",
|
||||
"cypress.config.js"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,13 +6,10 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: 'Install dependencies'
|
||||
shell: bash
|
||||
run: npm ci && cd services/frontend && npm ci && cd ../backend && npm ci
|
||||
- name: 'Create symlink for shared'
|
||||
shell: bash
|
||||
run: ln -s $PWD/services/shared $PWD/services/backend/src/shared && ln -s $PWD/services/shared $PWD/services/frontend/src/shared
|
||||
run: npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend
|
||||
- name: 'Run Stylelint for CSS files'
|
||||
shell: bash
|
||||
run: npm run stylelint "services/frontend/src/**/*.css"
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: 'Get current time'
|
||||
id: get_current_time
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
- release_backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
@@ -95,10 +95,10 @@ jobs:
|
||||
run: docker create --name frontend quay.io/toska/oodikone-frontend:production
|
||||
|
||||
- name: Copy assets from the frontend container
|
||||
run: docker cp frontend:/opt/app-root/src/build ./build
|
||||
run: docker cp frontend:/opt/app-root/frontend/build ./build
|
||||
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v1
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_URL: https://toska.cs.helsinki.fi/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
- release_backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
@@ -129,10 +129,10 @@ jobs:
|
||||
run: docker create --name backend quay.io/toska/oodikone-backend:production
|
||||
|
||||
- name: Copy assets from the backend container
|
||||
run: docker cp backend:/opt/app-root/src/dist ./dist
|
||||
run: docker cp backend:/opt/app-root/backend/dist ./dist
|
||||
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v1
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_URL: https://toska.cs.helsinki.fi/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
environment: production
|
||||
set_commits: 'skip'
|
||||
sourcemaps: './dist'
|
||||
url_prefix: '~/opt/app-root/src/dist/'
|
||||
url_prefix: '~/opt/app-root/backend/dist/'
|
||||
version: ${{ steps.extract_version.outputs.VERSION }}
|
||||
|
||||
report_success:
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
if: success()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
|
||||
with:
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
if: failure()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
|
||||
with:
|
||||
|
||||
@@ -13,18 +13,18 @@ on:
|
||||
- 'package*.json'
|
||||
|
||||
jobs:
|
||||
lint_backend_and_frontend:
|
||||
name: 'Lint backend and frontend'
|
||||
lint_packages:
|
||||
name: 'Lint packages'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Lint backend and frontend
|
||||
- uses: actions/checkout@v5
|
||||
- name: Lint backend, frontend and shared
|
||||
uses: ./.github/actions/lint
|
||||
|
||||
test_end_to_end:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- lint_backend_and_frontend
|
||||
- lint_packages
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -41,8 +41,8 @@ jobs:
|
||||
spec: cypress/e2e/Students.js
|
||||
- name: 'Test completed courses'
|
||||
spec: cypress/e2e/Completed_courses.js
|
||||
- name: 'Test studyprogramme overview'
|
||||
spec: cypress/e2e/Studyprogramme_overview.js
|
||||
- name: 'Test study programme overview'
|
||||
spec: cypress/e2e/Study_programme_overview.js
|
||||
- name: 'Test teachers and users'
|
||||
spec: |
|
||||
cypress/e2e/Teachers.js
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
spec: cypress/e2e/University.js
|
||||
name: ${{ matrix.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: 'Run tests'
|
||||
uses: ./.github/actions/run-tests
|
||||
with:
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Get current time
|
||||
id: get_current_time
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Build backend image
|
||||
id: build-image
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
if: success()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
|
||||
with:
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
if: failure()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: 'Send notification to Slack'
|
||||
uses: UniversityOfHelsinkiCS/slackbot-action@v1.5
|
||||
with:
|
||||
|
||||
+16
-16
@@ -12,12 +12,12 @@ on:
|
||||
|
||||
jobs:
|
||||
# === Static tests / linting ===
|
||||
lint_backend_and_frontend:
|
||||
name: 'Lint backend and frontend'
|
||||
lint_packages:
|
||||
name: 'Lint packages'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Lint backend and frontend
|
||||
- uses: actions/checkout@v5
|
||||
- name: Lint backend, frontend and shared
|
||||
uses: ./.github/actions/lint
|
||||
|
||||
# === Build database images ===
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
name: 'Build backend'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: 'Build image and export it'
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
name: 'Build frontend'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Get current time
|
||||
id: get_current_time
|
||||
run: echo "NOW=$(date --iso-8601=seconds)" >> $GITHUB_OUTPUT
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
user_db_migrations: ${{ steps.check_migrations.outputs.user_db_migrations }}
|
||||
sis_db_migrations: ${{ steps.check_migrations.outputs.sis_db_migrations }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Check if new migrations have been added
|
||||
id: check_migrations
|
||||
uses: dorny/paths-filter@v3
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.sis_db_migrations == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -120,9 +120,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.kone_db_migrations == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: 'Download backend image'
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: backend-image
|
||||
- name: 'Load built backend image'
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check_migration_files.outputs.user_db_migrations == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: check_migrations
|
||||
with:
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
- 'services/backend/src/database/migrations_user/**'
|
||||
- '.github/workflows/test.yaml'
|
||||
- name: 'Download backend image'
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: backend-image
|
||||
- name: 'Load built backend image'
|
||||
@@ -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
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
spec: cypress/e2e/University.js
|
||||
name: ${{ matrix.name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: 'Run tests'
|
||||
uses: ./.github/actions/run-tests
|
||||
with:
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
name: 'Lint scheduler and worker'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 22
|
||||
- name: 'Install dependencies'
|
||||
run: npm ci && cd updater/sis-updater-scheduler && npm ci && cd ../sis-updater-worker && npm ci
|
||||
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
|
||||
- name: 'Run linter'
|
||||
run: npm run eslint -- updater
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
name: 'Build Updater worker'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: 'Build the image'
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
name: 'Build Updater scheduler'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: 'Build the image'
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
- build_worker
|
||||
- build_scheduler
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: 'Login to toska docker registry'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
- name: 'Download built images'
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: 'updater-{worker,scheduler}-image'
|
||||
merge-multiple: true
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
needs: test_updater
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Build worker image
|
||||
id: build-image
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
needs: test_updater
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Build scheduler image
|
||||
id: build-image
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
const path = require('path')
|
||||
import { relative } from 'path'
|
||||
|
||||
const cwd = process.cwd()
|
||||
const dockerCmdBase = `docker run --rm --volume ${cwd}:/oodikone --workdir /oodikone`
|
||||
const relativeFilePaths = files => [...files.map(file => path.relative(cwd, file))].join(' ')
|
||||
const relativeFilePaths = files => [...files.map(file => relative(cwd, file))].join(' ')
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
'{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 =>
|
||||
+2
-1
@@ -6,5 +6,6 @@
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "es5"
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Oodikone
|
||||
[](https://qlty.sh/gh/UniversityOfHelsinkiCS/projects/oodikone)
|
||||
|
||||
An application for analyzing university data, running at [https://oodikone.helsinki.fi/](https://oodikone.helsinki.fi/).
|
||||
|
||||
@@ -70,12 +71,9 @@ graph TB
|
||||
end
|
||||
server -->|Read| sisdb[(sis-db)]
|
||||
subgraph Updater
|
||||
updaterscheduler[Scheduler] --> |Push jobs| nats
|
||||
nats(NATS server)
|
||||
updaterworker --> |Fetch jobs| nats
|
||||
updaterscheduler[Scheduler] --> |Push BullMQ jobs| redis(Redis)
|
||||
updaterworker[Worker] -->|Write| sisdb
|
||||
updaterscheduler --> redis(Redis)
|
||||
updaterworker --> redis
|
||||
updaterworker --> |Fetch BullMQ jobs| redis
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ set_custom_select_prompt() {
|
||||
# Run scripts and set the prompt
|
||||
show_welcome
|
||||
init_dirs
|
||||
get_username
|
||||
get_s3_config
|
||||
set_custom_select_prompt
|
||||
|
||||
while true; do
|
||||
|
||||
+3
-4
@@ -1,10 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
/* eslint-disable import/no-commonjs */
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
/* eslint-disable import/no-unused-modules */
|
||||
/* eslint-disable import-x/no-extraneous-dependencies */
|
||||
/* eslint-disable import-x/no-default-export */
|
||||
const { defineConfig } = require('cypress')
|
||||
|
||||
module.exports = defineConfig({
|
||||
experimentalStudio: process.env.NODE_ENV === 'development',
|
||||
projectId: 'c3jsph',
|
||||
defaultCommandTimeout: 30000,
|
||||
requestTimeout: 30000,
|
||||
|
||||
+1061
-773
File diff suppressed because it is too large
Load Diff
@@ -56,10 +56,10 @@ describe('Faculty statistics', () => {
|
||||
})
|
||||
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Basic information')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Students by starting year')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Graduation times')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Update statistics')
|
||||
cy.cs('faculty-tabs').should('contain', 'Basic information')
|
||||
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
|
||||
cy.cs('faculty-tabs').should('contain', 'Graduation times')
|
||||
cy.cs('faculty-tabs').should('contain', 'Update statistics')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,66 +71,66 @@ describe('Faculty statistics', () => {
|
||||
|
||||
describe('Info boxes', () => {
|
||||
it('Students of the faculty', () => {
|
||||
cy.get('[data-cy=StudentsOfTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=StudentsOfTheFacultyInfoBoxContent]').contains('Taulukon luvut on')
|
||||
cy.cs('students-of-the-faculty-info-box-button').click()
|
||||
cy.cs('students-of-the-faculty-info-box-content').contains('Taulukon luvut on')
|
||||
})
|
||||
|
||||
it('Graduated of the faculty', () => {
|
||||
cy.get('[data-cy=GraduatedOfTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=GraduatedOfTheFacultyInfoBoxContent]').contains('Sisältää kyseisenä')
|
||||
cy.cs('graduated-of-the-faculty-info-box-button').click()
|
||||
cy.cs('graduated-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
|
||||
})
|
||||
|
||||
it('Thesis writers of the faculty', () => {
|
||||
cy.get('[data-cy=ThesisWritersOfTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=ThesisWritersOfTheFacultyInfoBoxContent]').contains('Sisältää kyseisenä')
|
||||
cy.cs('thesis-writers-of-the-faculty-info-box-button').click()
|
||||
cy.cs('thesis-writers-of-the-faculty-info-box-content').contains('Sisältää kyseisenä')
|
||||
})
|
||||
|
||||
it('Credits produced by the faculty', () => {
|
||||
cy.get('[data-cy=CreditsProducedByTheFacultyInfoBoxButton]').click()
|
||||
cy.get('[data-cy=CreditsProducedByTheFacultyInfoBoxContent]').contains('Sisältää opintopisteet')
|
||||
cy.cs('credits-produced-by-the-faculty-info-box-button').click()
|
||||
cy.cs('credits-produced-by-the-faculty-info-box-content').contains('Sisältää opintopisteet')
|
||||
})
|
||||
})
|
||||
|
||||
it('All graphs and tables are shown', () => {
|
||||
cy.get('[data-cy="StudentsOfTheFacultyGraph"]').should('be.visible')
|
||||
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('be.visible')
|
||||
cy.get('[data-cy="GraduatedOfTheFacultyGraph"]').should('be.visible')
|
||||
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('be.visible')
|
||||
cy.get('[data-cy="ThesisWritersOfTheFacultyGraph"]').should('be.visible')
|
||||
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('be.visible')
|
||||
cy.get('[data-cy="CreditsProducedByTheFacultyGraph"]').should('be.visible')
|
||||
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('be.visible')
|
||||
cy.cs('students-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('graduated-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('thesis-writers-of-the-faculty-line-graph-section').should('be.visible')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('be.visible')
|
||||
cy.cs('credits-produced-by-the-faculty-stacked-bar-chart-section').should('be.visible')
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('be.visible')
|
||||
})
|
||||
|
||||
it('Correct tabs are shown', () => {
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Basic information')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Students by starting year')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('contain', 'Graduation times')
|
||||
cy.get('[data-cy="FacultyTabs"]').should('not.contain', 'Update statistics')
|
||||
cy.cs('faculty-tabs').should('contain', 'Basic information')
|
||||
cy.cs('faculty-tabs').should('contain', 'Students by starting year')
|
||||
cy.cs('faculty-tabs').should('contain', 'Graduation times')
|
||||
cy.cs('faculty-tabs').should('not.contain', 'Update statistics')
|
||||
})
|
||||
|
||||
it('Toggle years works', () => {
|
||||
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('contain', '2022')
|
||||
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
|
||||
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
|
||||
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('contain', '2022')
|
||||
cy.get('[data-cy="YearToggle"]').click()
|
||||
cy.get('[data-cy="CreditsProducedByTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="ThesisWritersOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="StudentsOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
|
||||
cy.get('[data-cy="GraduatedOfTheFacultyInteractiveDataTable"]').should('contain', '2022 - 2023')
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022')
|
||||
cy.cs('year-toggle').click()
|
||||
cy.cs('credits-produced-by-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('thesis-writers-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('students-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
cy.cs('graduated-of-the-faculty-interactive-data-table').should('contain', '2022 - 2023')
|
||||
})
|
||||
|
||||
it('Toggle programmes works', () => {
|
||||
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('not.exist')
|
||||
cy.get('[data-cy="ProgrammeToggle"]').click()
|
||||
cy.get('[data-cy="FacultyProgrammesShownInfo"]').should('be.visible')
|
||||
cy.cs('faculty-programmes-shown-info').should('not.exist')
|
||||
cy.cs('programme-toggle').click()
|
||||
cy.cs('faculty-programmes-shown-info').should('be.visible')
|
||||
})
|
||||
|
||||
it('Toggle study rights works', () => {
|
||||
cy.get('[data-cy="FacultyExcludeSpecialsInfo"]').should('not.exist')
|
||||
cy.get('[data-cy="StudentToggle"]').click()
|
||||
cy.get('[data-cy="FacultyExcludeSpecialsInfo"]').should('be.visible')
|
||||
cy.cs('faculty-exclude-specials-info').should('not.exist')
|
||||
cy.cs('study-right-toggle').click()
|
||||
cy.cs('faculty-exclude-specials-info').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Data can be exported to Excel files', { retries: 2 }, () => {
|
||||
@@ -142,7 +142,7 @@ describe('Faculty statistics', () => {
|
||||
'CreditsProducedByTheFaculty',
|
||||
]
|
||||
sections.forEach(section => {
|
||||
cy.get(`[data-cy="${section}Graph"]`).within(() => {
|
||||
cy.cs(`${section}Graph`).within(() => {
|
||||
cy.get('[aria-label="View chart menu, Chart"]').click({ force: true })
|
||||
cy.contains('li.highcharts-menu-item', 'Download XLS').click()
|
||||
const downloadedFile = `oodikone_${section}_H50_${timestamp}.xls`
|
||||
@@ -161,14 +161,14 @@ describe('Faculty statistics', () => {
|
||||
})
|
||||
|
||||
it('Export button', { retries: 2 }, () => {
|
||||
cy.get('[data-cy=FacultyStudentTableExportButton]').should('be.visible').and('not.be.disabled').click()
|
||||
cy.cs('faculty-student-table-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_programme_stats_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it('Info box', () => {
|
||||
cy.get('[data-cy=FacultyStudentTableInfoBoxButton]').click()
|
||||
cy.get('[data-cy=FacultyStudentTableInfoBoxContent]').contains('Opiskelijat, joiden')
|
||||
cy.cs('faculty-student-table-info-box-button').click()
|
||||
cy.cs('faculty-student-table-info-box-content').contains('Opiskelijat, joiden')
|
||||
})
|
||||
|
||||
it.skip('Study right toggle', () => {
|
||||
@@ -192,10 +192,10 @@ describe('Faculty statistics', () => {
|
||||
})
|
||||
|
||||
it('Percentage toggle works', () => {
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('not.contain', '92.3 %')
|
||||
cy.get('[data-cy="HidePercentagesToggle"]').click()
|
||||
cy.get('[data-cy="FacultyStudentStatsTable"]').should('contain', '92.3 %')
|
||||
cy.cs('faculty-student-stats-table').should('be.visible')
|
||||
cy.cs('faculty-student-stats-table').should('not.contain', '92.3 %')
|
||||
cy.cs('percentage-toggle').click()
|
||||
cy.cs('faculty-student-stats-table').should('contain', '92.3 %')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,33 +208,33 @@ describe('Faculty statistics', () => {
|
||||
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.get('[data-cy=FacultyProgressExportButton]').should('be.visible').and('not.be.disabled').click()
|
||||
cy.cs('FacultyProgress-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_progress_tab_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it.skip('Info boxes', () => {
|
||||
cy.get('[data-cy=FacultyProgressInfoBoxButton]').click()
|
||||
cy.get('[data-cy=FacultyProgressInfoBoxContent]').contains('Kuvaa tiedekuntaan')
|
||||
cy.cs('FacultyProgress-info-box-button').click()
|
||||
cy.cs('FacultyProgress-info-box-content').contains('Kuvaa tiedekuntaan')
|
||||
// TODO: Click open the accordion first
|
||||
cy.get('[data-cy=BachelorMastersProgressInfoBoxButton]').click()
|
||||
cy.get('[data-cy=BachelorMastersProgressInfoBoxContent]').contains('The starting year is the')
|
||||
cy.cs('BachelorMastersProgress-info-box-button').click()
|
||||
cy.cs('BachelorMastersProgress-info-box-content').contains('The starting year is the')
|
||||
})
|
||||
|
||||
it.skip('Progress bar charts exist', () => {
|
||||
// TODO: Click open the accordion first
|
||||
cy.get('[data-cy="FacultyBachelorsProgressBarChart"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorMastersProgressBarChart"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyMastersProgressBarChart"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorsProgressBarChart"]').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyBachelorMastersProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyMastersProgressBarChart').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressBarChart').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Progress tables exist', () => {
|
||||
// TODO: Click open the accordion first
|
||||
cy.get('[data-cy="FacultyBachelorsProgressTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorMasterProgressTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyMastersProgressTable"]').should('be.visible')
|
||||
cy.get('[data-cy="FacultyBachelorsProgressTable"]').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
|
||||
cy.cs('FacultyBachelorMasterProgressTable').should('be.visible')
|
||||
cy.cs('FacultyMastersProgressTable').should('be.visible')
|
||||
cy.cs('FacultyBachelorsProgressTable').should('be.visible')
|
||||
})
|
||||
|
||||
it.skip('Graduated toggle', () => {
|
||||
@@ -255,14 +255,14 @@ describe('Faculty statistics', () => {
|
||||
|
||||
it.skip('Export button', { retries: 2 }, () => {
|
||||
// TODO: Fix this test
|
||||
cy.get('[data-cy=AverageGraduationTimesExportButton]').should('be.visible').and('not.be.disabled').click()
|
||||
cy.cs('average-graduation-times-export-button').should('be.visible').and('not.be.disabled').click()
|
||||
const downloadedFile = `oodikone_H50_graduation_times_${timestamp}.xlsx`
|
||||
cy.readFile(path.join(downloadsFolder, downloadedFile))
|
||||
})
|
||||
|
||||
it('Info boxes', () => {
|
||||
cy.get('[data-cy=AverageGraduationTimesInfoBoxButton]').click()
|
||||
cy.get('[data-cy=AverageGraduationTimesInfoBoxContent]').contains('Opiskelijoiden keskimääräiset')
|
||||
cy.cs('average-graduation-times-info-box-button').click()
|
||||
cy.cs('average-graduation-times-info-box-content').contains('Opiskelijoiden keskimääräiset')
|
||||
})
|
||||
|
||||
it.skip('Study times toggle', () => {
|
||||
@@ -278,10 +278,10 @@ describe('Faculty statistics', () => {
|
||||
})
|
||||
|
||||
it('Graphs are visible', () => {
|
||||
cy.get('[data-cy="bachelorGraduationTimes"]').should('be.visible')
|
||||
cy.cs('bachelor-graduation-times-section').should('be.visible')
|
||||
// ! No test data for bachelor + master
|
||||
cy.get('[data-cy="masterGraduationTimes"]').should('be.visible')
|
||||
cy.get('[data-cy="doctorGraduationTimes"]').should('be.visible')
|
||||
cy.cs('master-graduation-times-section').should('be.visible')
|
||||
cy.cs('doctor-graduation-times-section').should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+372
-319
@@ -1,416 +1,469 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const MOCKED_DATE = new Date('2024-08-30').getTime()
|
||||
const setClockToMockedDate = () => {
|
||||
const MOCKED_DATE = new Date('2024-08-30')
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
}
|
||||
|
||||
const checkFilteringResult = studentCount => {
|
||||
const checkFilteredStudentCount = studentCount => {
|
||||
cy.contains(`Students (${studentCount})`)
|
||||
}
|
||||
|
||||
// Semantic UI doesn't allow injection of data-cy:s for single multiple dropdown selection.
|
||||
// This function tries to click "x" on all selections inside dropdown matching given attribute
|
||||
const clearSemanticUIMultipleDropDownSelection = dataCyAttribute => {
|
||||
cy.cs(dataCyAttribute).find('i.delete').click({ force: true })
|
||||
cy.cs(dataCyAttribute).click()
|
||||
}
|
||||
|
||||
const clearSingleDropdownSelection = dataCyAttribute => {
|
||||
cy.cs(dataCyAttribute).find('i.clear').click({ force: true })
|
||||
}
|
||||
|
||||
const testRangeFilter = (parentEl, min, max, expected) => {
|
||||
cy.cs(parentEl)
|
||||
.cs('range-selector-min')
|
||||
.find('input')
|
||||
.invoke('val')
|
||||
.then(initialMin => {
|
||||
cy.cs(parentEl)
|
||||
.cs('range-selector-max')
|
||||
.find('input')
|
||||
.invoke('val')
|
||||
.then(initialMax => {
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').clear()
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').type(min)
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').clear()
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').type(max)
|
||||
|
||||
checkFilteringResult(expected)
|
||||
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').clear()
|
||||
cy.cs(parentEl).cs('range-selector-min').find('input').type(initialMin)
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').clear()
|
||||
cy.cs(parentEl).cs('range-selector-max').find('input').type(initialMax)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Helper tool to create pre and post steps for each filter step. Created to avoid copypasting clicking and checking
|
||||
// to every it-function. Reason behind using test function wrapper is that Cypresses internal beforeEach / afterEach
|
||||
// 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`)
|
||||
|
||||
card.invoke('attr', 'data-open').then(open => {
|
||||
const getHeader = () => cy.cs(`${filterName}-header`)
|
||||
|
||||
if (open === 'false') {
|
||||
getHeader().click({ force: true })
|
||||
}
|
||||
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
testStepFunctionToRun()
|
||||
checkFilteringResult(amountWithoutFiltering)
|
||||
|
||||
if (open === 'false') {
|
||||
getHeader().click({ force: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe("Population statistics with a master's programme", () => {
|
||||
const pathToMathMSc2020 =
|
||||
'/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"MH50_001"%7D&year=2020'
|
||||
describe("Population statistics with a master's programme", { testIsolation: false }, () => {
|
||||
const pathToMathMSc2020 = '/populations?years=2020&programme=MH50_001&semesters=FALL&semesters=SPRING'
|
||||
const defaultAmountOfStudents = 26
|
||||
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathMSc2020)
|
||||
})
|
||||
const expectedBaMaStudents = 24
|
||||
const expectedMasterOnlyStudents = 2
|
||||
|
||||
before(() => cy.init(pathToMathMSc2020))
|
||||
|
||||
it('Study track filter works', () => {
|
||||
runTestStepWithPreAndPostParts('StudyTrack', () => {
|
||||
const card = cy.cs('StudyTrack-filter-card')
|
||||
const programmeDropdown = card.cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(15)
|
||||
programmeDropdown.get('i.delete').click()
|
||||
})
|
||||
cy.cs('studyTrackFilter-header').click()
|
||||
cy.selectFromDropdown('studyTrackFilter', 0, true)
|
||||
checkFilteredStudentCount(15)
|
||||
|
||||
cy.cs('studyTrackFilter-clear').click()
|
||||
cy.cs('studyTrackFilter-header').click()
|
||||
})
|
||||
|
||||
describe('Study right type filter', () => {
|
||||
it('is visible', () => {
|
||||
cy.contains('Study right type')
|
||||
})
|
||||
it('Study right type filter works', () => {
|
||||
cy.cs('studyRightTypeFilter-header').click()
|
||||
cy.get('input[name="studyRightTypeFilter"][value="0"]').should('be.checked')
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
// Bachelor + master
|
||||
cy.get('input[name="studyRightTypeFilter"][value="1"]').click()
|
||||
cy.get('input[name="studyRightTypeFilter"][value="1"]').should('be.checked')
|
||||
checkFilteredStudentCount(expectedBaMaStudents)
|
||||
// Master only
|
||||
cy.get('input[name="studyRightTypeFilter"][value="2"]').click()
|
||||
cy.get('input[name="studyRightTypeFilter"][value="2"]').should('be.checked')
|
||||
checkFilteredStudentCount(expectedMasterOnlyStudents)
|
||||
// All
|
||||
cy.get('input[name="studyRightTypeFilter"][value="0"]').click()
|
||||
cy.get('input[name="studyRightTypeFilter"][value="0"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
it('works', () => {
|
||||
runTestStepWithPreAndPostParts('studyRightTypeFilter', () => {
|
||||
const expectedBaMaStudents = 24
|
||||
const expectedMasterOnlyStudents = 2
|
||||
cy.cs('studyRightTypeFilter-filter-card').cs('bama').click()
|
||||
checkFilteringResult(expectedBaMaStudents)
|
||||
cy.cs('studyRightTypeFilter-filter-card').cs('master').click()
|
||||
checkFilteringResult(expectedMasterOnlyStudents)
|
||||
cy.cs('studyRightTypeFilter-filter-card').cs('all').click()
|
||||
checkFilteringResult(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
cy.cs('studyRightTypeFilter-header').click()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Population Statistics', () => {
|
||||
const pathToMathBSc2020 =
|
||||
'/populations?months=49&semesters=FALL&semesters=SPRING&studyRights=%7B"programme"%3A"KH50_001"%7D&year=2020'
|
||||
describe.skip('Population Statistics', { testIsolation: false }, () => {
|
||||
const pathToMathBSc2020 = '/populations?years=2020&programme=KH50_001&semesters=FALL&semesters=SPRING'
|
||||
const defaultAmountOfStudents = 27
|
||||
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
|
||||
const graduated = 16
|
||||
const transferred = 3
|
||||
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathBSc2020)
|
||||
before(() => cy.init(pathToMathBSc2020))
|
||||
beforeEach(() => setClockToMockedDate())
|
||||
|
||||
it.skip('Study right type filter is not visible', () => {
|
||||
cy.contains('Study right type').should('not.exist')
|
||||
})
|
||||
|
||||
it("Transfer filter is set to 'not transferred' by default", () => {
|
||||
cy.contains('By default only students who have not transferred to this study programme are shown.')
|
||||
// TODO: FilterRadio is not working correcly
|
||||
it.skip('Transfer filter works and is set to "not transferred" by default', () => {
|
||||
cy.cs('transferredToProgrammeFilter-filter-card').should('have.attr', 'data-active', 'true')
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
|
||||
const card = cy.cs('TransferredToProgramme-filter-card')
|
||||
card.get('[data-cy="option-havenot"] input').should('be.checked')
|
||||
})
|
||||
cy.cs('transferredToProgrammeFilter-filter-card').should('have.attr', 'data-active', 'true')
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Transferred"]').click()
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Transferred"]').should('be.checked')
|
||||
checkFilteredStudentCount(transferred)
|
||||
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="All"]').click()
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="All"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents + transferred)
|
||||
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').click()
|
||||
cy.get('input[name="transferredToProgrammeFilter"][value="Not transferred"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Graduation filter works', { retries: 3 }, () => {
|
||||
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
|
||||
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
|
||||
it('Graduation filter works', () => {
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
const graduated = 16
|
||||
getCard().cs('option-graduated-true').click()
|
||||
checkFilteringResult(graduated)
|
||||
getCard().cs('option-graduated-false').click()
|
||||
checkFilteringResult(defaultAmountOfStudents - graduated)
|
||||
getCard().cs('option-all').click()
|
||||
})
|
||||
})
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
|
||||
checkFilteredStudentCount(graduated)
|
||||
|
||||
it('Transfer filter works', () => {
|
||||
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
|
||||
const transferred = 3
|
||||
cy.cs('TransferredToProgramme-filter-card').cs('option-have').click()
|
||||
checkFilteringResult(transferred)
|
||||
cy.cs('TransferredToProgramme-filter-card').cs('option-all').click()
|
||||
checkFilteringResult(defaultAmountOfStudents + transferred)
|
||||
cy.cs('TransferredToProgramme-filter-card').cs('option-havenot').click()
|
||||
})
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents - graduated)
|
||||
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="0"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="0"]').should('be.checked')
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
})
|
||||
|
||||
it('Enrollment filter works', () => {
|
||||
runTestStepWithPreAndPostParts('EnrollmentStatus', () => {
|
||||
cy.cs('enrollmentStatusFilter-status').selectFromDropdown(0)
|
||||
cy.cs('enrollmentStatusFilter-semesters').selectFromDropdown(5)
|
||||
checkFilteringResult(25)
|
||||
clearSemanticUIMultipleDropDownSelection('enrollmentStatusFilter-semesters')
|
||||
})
|
||||
cy.cs('enrollmentStatusFilter-header').click()
|
||||
cy.selectFromDropdown('enrollmentStatusFilter-status', 0)
|
||||
cy.selectFromDropdown('enrollmentStatusFilter-semester', 'Syksy 2023', true)
|
||||
checkFilteredStudentCount(25)
|
||||
|
||||
cy.cs('enrollmentStatusFilter-clear').click()
|
||||
cy.cs('enrollmentStatusFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Credit filter works', { retries: 3 }, () => {
|
||||
runTestStepWithPreAndPostParts('CreditsEarned', () => {
|
||||
testRangeFilter('CreditsEarned-filter-card', 50, 150, 12)
|
||||
})
|
||||
it('Credit filter works', () => {
|
||||
cy.cs('creditsEarnedFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('creditsEarnedFilter', 50, 150, 12)
|
||||
|
||||
cy.cs('creditsEarnedFilter-clear').click()
|
||||
cy.cs('creditsEarnedFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Age filter works', { retries: 3 }, () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
testRangeFilter('Age-filter-card', 23, 30, 21)
|
||||
})
|
||||
it('Age filter works', () => {
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 21)
|
||||
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Gender', () => {
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(13)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(14)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
|
||||
checkFilteringResult(0)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(3)
|
||||
checkFilteringResult(0)
|
||||
clearSingleDropdownSelection('genderFilter-dropdown')
|
||||
})
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.selectFromDropdown('genderFilter', 0)
|
||||
checkFilteredStudentCount(13)
|
||||
cy.selectFromDropdown('genderFilter', 1)
|
||||
checkFilteredStudentCount(14)
|
||||
cy.selectFromDropdown('genderFilter', 2)
|
||||
checkFilteredStudentCount(0)
|
||||
cy.selectFromDropdown('genderFilter', 3)
|
||||
checkFilteredStudentCount(0)
|
||||
|
||||
cy.cs('genderFilter-clear').click()
|
||||
cy.cs('genderFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Starting year filter works', () => {
|
||||
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(1)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(6)
|
||||
checkFilteringResult(21)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
})
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 0)
|
||||
checkFilteredStudentCount(1)
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 7)
|
||||
checkFilteredStudentCount(21)
|
||||
|
||||
cy.cs('startYearAtUniFilter-clear').click()
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Admission type filter works', () => {
|
||||
runTestStepWithPreAndPostParts('AdmissionType', () => {
|
||||
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(18)
|
||||
cy.cs('admissionTypeFilter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(6)
|
||||
clearSingleDropdownSelection('admissionTypeFilter-dropdown')
|
||||
})
|
||||
cy.cs('admissionTypeFilter-header').click()
|
||||
cy.selectFromDropdown('admissionTypeFilter', 0)
|
||||
checkFilteredStudentCount(18)
|
||||
cy.selectFromDropdown('admissionTypeFilter', 1)
|
||||
checkFilteredStudentCount(6)
|
||||
|
||||
cy.cs('admissionTypeFilter-clear').click()
|
||||
cy.cs('admissionTypeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Courses filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Courses', () => {
|
||||
const courses = [
|
||||
{ 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()
|
||||
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()
|
||||
checkFilteringResult(6)
|
||||
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(1)
|
||||
checkFilteringResult(5)
|
||||
courses.forEach(({ code }) => cy.cs(`courseFilter-${code}-clear`).click())
|
||||
})
|
||||
const courses = [
|
||||
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
|
||||
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
|
||||
]
|
||||
|
||||
cy.cs('courseFilter-header').click()
|
||||
cy.selectFromDropdown('courseFilter', `${courses[0].code} - ${courses[0].name}`)
|
||||
checkFilteredStudentCount(9)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
|
||||
checkFilteredStudentCount(7)
|
||||
cy.selectFromDropdown('courseFilter', `${courses[1].code} - ${courses[1].name}`)
|
||||
checkFilteredStudentCount(5)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 1)
|
||||
checkFilteredStudentCount(4)
|
||||
|
||||
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
|
||||
checkFilteredStudentCount(7)
|
||||
|
||||
cy.cs('courseFilter-clear').click()
|
||||
cy.cs('courseFilter-header').click()
|
||||
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Filter combinations work', () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
|
||||
getCard().cs('option-graduated-true').click()
|
||||
testRangeFilter('Age-filter-card', 23, 30, 12)
|
||||
getCard().cs('option-all').click()
|
||||
})
|
||||
})
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
|
||||
checkFilteredStudentCount(graduated)
|
||||
|
||||
cy.cs('ageFilter-header').click()
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 12)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-clear').click()
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('"Reset All Filters" button works', { retries: 3 }, () => {
|
||||
cy.cs('Gender-header').click()
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(13)
|
||||
cy.cs('StartYearAtUni-header').click()
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(5)
|
||||
checkFilteringResult(8)
|
||||
it('"Reset All Filters" button works', () => {
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.selectFromDropdown('genderFilter', 0)
|
||||
checkFilteredStudentCount(13)
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 7)
|
||||
checkFilteredStudentCount(8)
|
||||
|
||||
cy.cs('reset-all-filters').click()
|
||||
checkFilteringResult(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Study right type filter is not visible', () => {
|
||||
cy.contains('Study right type').should('not.exist')
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
|
||||
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'
|
||||
const defaultAmountOfStudents = 48
|
||||
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
|
||||
describe('Population Statistics with Bachelor + Master', { testIsolation: false }, () => {
|
||||
const pathToMathBSc2017 =
|
||||
'/populations?years=2017&programme=KH50_001&showBachelorAndMaster=true&semesters=FALL&semesters=SPRING'
|
||||
const defaultAmountOfStudents = 47
|
||||
|
||||
beforeEach(() => {
|
||||
cy.init(pathToLimits2021)
|
||||
before(() => cy.init(pathToMathBSc2017))
|
||||
|
||||
it('Graduation filter works', () => {
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
|
||||
checkFilteredStudentCount(42)
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="2"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="2"]').should('be.checked')
|
||||
checkFilteredStudentCount(18)
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-1"]').should('be.checked')
|
||||
checkFilteredStudentCount(5)
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-2"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="-2"]').should('be.checked')
|
||||
checkFilteredStudentCount(29)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-clear').click()
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Study right status filter works', () => {
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.cs('studyRightStatusFilter-header').click()
|
||||
|
||||
// Bachelor
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Active Bachelor study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Active Bachelor study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(0)
|
||||
// Master (combined)
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Active Master study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Active Master study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(0)
|
||||
|
||||
// Non-Bachelor
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Inactive Bachelor study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Inactive Bachelor study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(5)
|
||||
// Non-Master (combined)
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Inactive Master study right"]').click()
|
||||
cy.get('input[name="studyRightStatusFilter"][value="Inactive Master study right"]').should('be.checked')
|
||||
checkFilteredStudentCount(24)
|
||||
|
||||
cy.cs('studyRightStatusFilter-clear').click()
|
||||
cy.cs('studyRightStatusFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
describe.skip('Course Statistics', { testIsolation: false }, () => {
|
||||
const pathToLimits2021 =
|
||||
'/coursepopulation?coursecodes=%5B"MAT11003"%2C"57116"%2C"57016"%2C"AYMAT11003"%5D&from=72&separate=false&to=72&unifyCourses=unifyStats'
|
||||
const defaultAmountOfStudents = 48
|
||||
|
||||
before(() => cy.init(pathToLimits2021))
|
||||
beforeEach(() => setClockToMockedDate())
|
||||
|
||||
it('Grade filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Grade', () => {
|
||||
cy.cs('gradeFilter-5').click()
|
||||
checkFilteringResult(23)
|
||||
cy.cs('gradeFilter-3').click()
|
||||
checkFilteringResult(29)
|
||||
cy.cs('gradeFilter-3').click()
|
||||
cy.cs('gradeFilter-5').click()
|
||||
})
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.cs('gradeFilter-header').click()
|
||||
|
||||
cy.cs('gradeFilter-5').click()
|
||||
checkFilteredStudentCount(23)
|
||||
cy.cs('gradeFilter-3').click()
|
||||
checkFilteredStudentCount(29)
|
||||
cy.cs('gradeFilter-3').click()
|
||||
cy.cs('gradeFilter-5').click()
|
||||
|
||||
cy.cs('gradeFilter-clear').should('not.exist')
|
||||
|
||||
cy.cs('gradeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Programme defaults to "Attainment" mode', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
cy.cs('Programme-filter-mode-selector').children().eq(0).should('contain', 'Attainment')
|
||||
})
|
||||
it('Programme filter works and defaults to "Attainment"', () => {
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.cs('programmeFilter-header').click()
|
||||
cy.cs('programmeFilter-mode-selector').children().eq(0).should('contain', 'Attainment')
|
||||
cy.selectFromDropdown('programmeFilter', 1, true)
|
||||
checkFilteredStudentCount(33)
|
||||
|
||||
cy.cs('programmeFilter-clear').click()
|
||||
cy.cs('programmeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Programme filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
const card = cy.cs('Programme-filter-card')
|
||||
const programmeDropdown = card.cs('Programme-filter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(33)
|
||||
programmeDropdown.get('i.delete').click()
|
||||
})
|
||||
})
|
||||
it('Age filter works', () => {
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 36)
|
||||
|
||||
it('Age filter works', { retries: 3 }, () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
testRangeFilter('Age-filter-card', 21, 30, 36)
|
||||
})
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Gender', () => {
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(9)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(39)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
|
||||
checkFilteringResult(0)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(3)
|
||||
checkFilteringResult(0)
|
||||
clearSingleDropdownSelection('genderFilter-dropdown')
|
||||
})
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.selectFromDropdown('genderFilter', 0)
|
||||
checkFilteredStudentCount(9)
|
||||
cy.selectFromDropdown('genderFilter', 1)
|
||||
checkFilteredStudentCount(39)
|
||||
cy.selectFromDropdown('genderFilter', 2)
|
||||
checkFilteredStudentCount(0)
|
||||
cy.selectFromDropdown('genderFilter', 3)
|
||||
checkFilteredStudentCount(0)
|
||||
|
||||
cy.cs('genderFilter-clear').click()
|
||||
cy.cs('genderFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Starting year filter works', () => {
|
||||
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(1)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(10)
|
||||
checkFilteringResult(24)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
})
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 0)
|
||||
checkFilteredStudentCount(1)
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 10)
|
||||
checkFilteredStudentCount(24)
|
||||
|
||||
cy.cs('startYearAtUniFilter-clear').click()
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Filter combinations work', () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('Grade', () => {
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
cy.cs('gradeFilter-5').click()
|
||||
checkFilteringResult(23)
|
||||
testRangeFilter('Age-filter-card', 21, 30, 17)
|
||||
cy.cs('gradeFilter-5').click()
|
||||
})
|
||||
})
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 36)
|
||||
|
||||
cy.cs('gradeFilter-header').click()
|
||||
cy.cs('gradeFilter-5').click()
|
||||
checkFilteredStudentCount(17)
|
||||
|
||||
cy.cs('gradeFilter-clear').click()
|
||||
cy.cs('gradeFilter-header').click()
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Population Statistics', () => {
|
||||
let runTestStepWithPreAndPostParts
|
||||
describe('Custom Population Statistics', { testIsolation: false }, () => {
|
||||
let defaultAmountOfStudents
|
||||
|
||||
beforeEach(() => {
|
||||
before(() => {
|
||||
cy.init('/custompopulation')
|
||||
cy.cs('custom-pop-search-button').click()
|
||||
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1, studentNumbersForCSStudentsSet2 }) => {
|
||||
const students = [...studentNumbersForCSStudentsSet1, ...studentNumbersForCSStudentsSet2]
|
||||
runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(students.length)
|
||||
cy.cs('student-number-input').click().type(students.join('\n'))
|
||||
defaultAmountOfStudents = students.length
|
||||
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()
|
||||
})
|
||||
beforeEach(() => setClockToMockedDate())
|
||||
|
||||
it('Age filter works', () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
testRangeFilter('Age-filter-card', 24, 28, 5)
|
||||
})
|
||||
cy.cs('ageFilter-header').click()
|
||||
cy.setRangeSelect('ageFilter', 24, 28, 5)
|
||||
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Programme defaults to "Active Study Right" mode', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
cy.cs('Programme-filter-mode-selector').children().eq(0).should('contain', 'Active Study Right')
|
||||
})
|
||||
})
|
||||
it('Programme filter works and defaults to "Active Study Right"', () => {
|
||||
cy.cs('programmeFilter-header').click()
|
||||
cy.cs('programmeFilter-mode-selector').children().eq(0).should('contain', 'Active Study Right')
|
||||
cy.selectFromDropdown('programmeFilter', 1, true)
|
||||
checkFilteredStudentCount(3)
|
||||
|
||||
it('Programme filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
const card = cy.cs('Programme-filter-card')
|
||||
const programmeDropdown = card.cs('Programme-filter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(3)
|
||||
programmeDropdown.get('i.delete').click()
|
||||
})
|
||||
cy.cs('programmeFilter-clear').click()
|
||||
cy.cs('programmeFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Gender', () => {
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(5)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(3)
|
||||
cy.cs('genderFilter-dropdown').selectFromDropdown(2)
|
||||
checkFilteringResult(0)
|
||||
clearSingleDropdownSelection('genderFilter-dropdown')
|
||||
})
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.selectFromDropdown('genderFilter', 0)
|
||||
checkFilteredStudentCount(5)
|
||||
cy.selectFromDropdown('genderFilter', 1)
|
||||
checkFilteredStudentCount(3)
|
||||
cy.selectFromDropdown('genderFilter', 2)
|
||||
checkFilteredStudentCount(0)
|
||||
cy.selectFromDropdown('genderFilter', 3)
|
||||
checkFilteredStudentCount(0)
|
||||
|
||||
cy.cs('genderFilter-clear').click()
|
||||
cy.cs('genderFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Starting year filter works', () => {
|
||||
runTestStepWithPreAndPostParts('StartYearAtUni', () => {
|
||||
cy.cs('startYearAtUni-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(2)
|
||||
clearSemanticUIMultipleDropDownSelection('startYearAtUni-dropdown')
|
||||
})
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
cy.selectFromDropdown('startYearAtUniFilter', 0)
|
||||
checkFilteredStudentCount(2)
|
||||
|
||||
cy.cs('startYearAtUniFilter-clear').click()
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Courses filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Courses', () => {
|
||||
const courses = [
|
||||
{ code: 'MAT11005', name: 'Integraalilaskenta' },
|
||||
{ code: 'MAT21007', name: 'Mitta ja integraali' },
|
||||
]
|
||||
cy.cs('courseFilter-course-dropdown').click().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()
|
||||
checkFilteringResult(5)
|
||||
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(3)
|
||||
checkFilteringResult(2)
|
||||
courses.forEach(({ code }) => cy.cs(`courseFilter-${code}-clear`).click())
|
||||
})
|
||||
const courses = [
|
||||
{ code: 'MAT11005', name: 'Integraalilaskenta' },
|
||||
{ code: 'MAT21007', name: 'Mitta ja integraali' },
|
||||
]
|
||||
|
||||
cy.cs('courseFilter-header').click()
|
||||
cy.selectFromDropdown('courseFilter', `${courses[0].code} - ${courses[0].name}`)
|
||||
checkFilteredStudentCount(7)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
|
||||
checkFilteredStudentCount(6)
|
||||
cy.selectFromDropdown('courseFilter', `${courses[1].code} - ${courses[1].name}`)
|
||||
checkFilteredStudentCount(5)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[1].code}`, 3)
|
||||
checkFilteredStudentCount(2)
|
||||
|
||||
cy.cs(`courseFilter-${courses[1].code}-clear`).click()
|
||||
checkFilteredStudentCount(6)
|
||||
|
||||
cy.cs('courseFilter-clear').click()
|
||||
cy.cs('courseFilter-header').click()
|
||||
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,14 +6,13 @@ const hasLanded = () => {
|
||||
}
|
||||
|
||||
const chooseSemester = (semester, fromOrTo) => {
|
||||
cy.get(`[data-cy="semester-${fromOrTo}"]`)
|
||||
.click()
|
||||
.within(() => {
|
||||
cy.get('div.visible.menu.transition').within(() => {
|
||||
cy.contains('div.item', semester).click()
|
||||
})
|
||||
cy.get('div.menu.transition').should('not.have.class', 'visible')
|
||||
cy.get(`[data-cy="semester-${fromOrTo}"]`).click()
|
||||
cy.get(`[data-cy="semester-${fromOrTo}"]`).within(() => {
|
||||
cy.get('div.visible.menu.transition').within(() => {
|
||||
cy.contains('div.item', semester).click()
|
||||
})
|
||||
cy.get('div.menu.transition').should('not.have.class', 'visible')
|
||||
})
|
||||
}
|
||||
|
||||
const checkNumbers = (numbers, numberOfColumns, mode) => {
|
||||
@@ -74,11 +73,13 @@ describe('When language center is opened', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Faculties tab "exceeding" button works', () => {
|
||||
cy.get('[data-cy="difference-button"]').click()
|
||||
checkNumbers([...new Array(12).fill(0), 1, 221], 15)
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Faculties tab semester selector changes numbers', () => {
|
||||
chooseSemester('Syksy 2020', 'from')
|
||||
checkNumbers([28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
|
||||
@@ -92,10 +93,12 @@ describe('When language center is opened', () => {
|
||||
chooseSemester('Kevät 2024', 'to')
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Semester tab shows numbers', () => {
|
||||
checkNumbers([69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1, 2076], 17, 'semesters')
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Coloring mode works on semester tab', () => {
|
||||
cy.contains('Compare to average of course').click()
|
||||
const expectedAlphas = [0.04, 0.016, 0.17, 0.03, 0.192, 0.055, 0.247, 0.08, 0.173, 0.05, 0.07, 0.043, 0.004, 0]
|
||||
|
||||
+35
-27
@@ -1,21 +1,18 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const deleteAllSearches = () => {
|
||||
cy.contains('Saved populations')
|
||||
.parent()
|
||||
.parent()
|
||||
.parent()
|
||||
.parent()
|
||||
.get('.dropdown')
|
||||
.then(dropdown => {
|
||||
const searchItems = dropdown.find('div[role=option] > span[class=text]')
|
||||
for (let i = 0; i < searchItems.length; i++) {
|
||||
if (searchItems[i].textContent.includes('TEST-')) {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(searchItems[i].textContent).type('{enter}')
|
||||
cy.get('button').contains('Delete').click()
|
||||
}
|
||||
cy.cs('open-uni-search-button').click()
|
||||
cy.contains('Saved populations').parent().parent().parent().parent().get('.search').as('search')
|
||||
|
||||
cy.get('@search').then($el => {
|
||||
const searchItems = $el.find('div[role=option] > span[class=text]')
|
||||
for (let i = searchItems.length - 1; 0 <= i; i--) {
|
||||
if (searchItems[i].textContent.includes('TEST-')) {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(`${searchItems[i].textContent}{enter}`)
|
||||
cy.get('button').contains('Delete').click()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Open uni population tests', () => {
|
||||
@@ -26,27 +23,37 @@ describe('Open uni population tests', () => {
|
||||
})
|
||||
after(() => {
|
||||
cy.init('/openunipopulation', 'admin')
|
||||
cy.url().should('include', '/openunipopulation')
|
||||
cy.contains('Open uni student population')
|
||||
cy.get('[data-cy="open-uni-search-button"]').click()
|
||||
deleteAllSearches()
|
||||
})
|
||||
|
||||
it('Finds a proper population', () => {
|
||||
// TODO: Rewrite the whole component
|
||||
it.skip('Finds a proper population', () => {
|
||||
cy.fixture('openUniPopulation').then(({ courseCodesSet1 }) => {
|
||||
cy.get('[data-cy="open-uni-search-button"]').click()
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet1.join('\n'))
|
||||
cy.get('[data-cy="begin-of-search"]').clear().type('01.01.2015')
|
||||
cy.get('[data-cy="end-of-search"]').clear().type('01.01.2020')
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').as('textarea')
|
||||
cy.get('@textarea').type(courseCodesSet1.join('\n'))
|
||||
cy.cs('begin-of-search').click()
|
||||
cy.cs('begin-of-search').clear()
|
||||
cy.cs('begin-of-search').type('01.01.2015')
|
||||
cy.cs('end-of-search').click()
|
||||
cy.cs('end-of-search').clear()
|
||||
cy.cs('end-of-search').type('01.01.2020')
|
||||
cy.get('button').contains('Search population').click()
|
||||
cy.contains('Beginning of the search for all fields:')
|
||||
cy.contains('01.01.2015')
|
||||
cy.contains('End of the search for enrollments:')
|
||||
cy.contains('01.01.2020')
|
||||
cy.url().should(
|
||||
'include',
|
||||
'/openunipopulation?courseCode=TKT10001&courseCode=TKT10002&enddate=01-01-2020&startdate=01-01-2015'
|
||||
)
|
||||
cy.location('pathname').should('eq', '/openunipopulation')
|
||||
cy.location('search').then(search => {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
const courseCodes = params.getAll('courseCode')
|
||||
expect(courseCodes).to.include.members(['TKT10001', 'TKT10002'])
|
||||
|
||||
expect(params.get('startdate')).to.eq('01-01-2015')
|
||||
expect(params.get('enddate')).to.eq('01-01-2020')
|
||||
})
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
|
||||
})
|
||||
@@ -73,9 +80,10 @@ describe('Open uni population tests', () => {
|
||||
cy.get('[data-cy="search-name"]').type('TEST-Avoimen-populaatio')
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet1.join('\n'))
|
||||
cy.get('[data-cy="save-search"]').click()
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio').type('{enter}')
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio{enter}')
|
||||
cy.contains('TEST-Avoimen-populaatio')
|
||||
cy.get('button').contains('Search population').click()
|
||||
cy.get('button').contains('Search population').as('btn')
|
||||
cy.get('@btn').click()
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[0])
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
|
||||
})
|
||||
@@ -92,7 +100,7 @@ describe('Open uni population tests', () => {
|
||||
cy.get('[data-cy="open-uni-table-div"]').should('contain.text', courseCodesSet1[1])
|
||||
|
||||
cy.get('[data-cy="open-uni-search-button"]').click()
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio').type('{enter}')
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type('TEST-Avoimen-populaatio{enter}')
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').should('contain', courseCodesSet1.join(', '))
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').type('\n')
|
||||
cy.contains('Insert course code(s)').siblings().get('textarea').type(courseCodesSet2.join('\n'))
|
||||
|
||||
@@ -2,57 +2,65 @@
|
||||
// Now "Class statistics" in UI
|
||||
|
||||
const selectStudyProgramme = programme => {
|
||||
cy.contains('Select study programme')
|
||||
cy.get('[data-cy=select-study-programme]').click().children().contains(programme).click()
|
||||
cy.cs('population-programme-selector').within(() =>
|
||||
cy.get('input').should('have.attr', 'placeholder', 'Select degree programme')
|
||||
)
|
||||
cy.cs('population-programme-selector-parent').click()
|
||||
cy.cs('population-programme-selector-parent').within(() => cy.contains(programme).click())
|
||||
}
|
||||
|
||||
const selectStudyTrack = studyTrack => {
|
||||
cy.contains('Select study track')
|
||||
cy.get('[data-cy=select-study-track]').click().children().contains(studyTrack).click()
|
||||
cy.cs('population-studytrack-selector').within(() =>
|
||||
cy.get('input').should('have.attr', 'placeholder', 'Select study track')
|
||||
)
|
||||
cy.cs('population-studytrack-selector-parent').click()
|
||||
cy.cs('population-studytrack-selector-parent').within(() => cy.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`
|
||||
return `/populations?months=49&semesters=FALL&semesters=SPRING&programme=${programme}&years=2020`
|
||||
}
|
||||
|
||||
const pathToMathBSc2020 = getPath('KH50_001')
|
||||
const pathToMathMSc2020 = getPath('MH50_001')
|
||||
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/populations')
|
||||
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.intercept('/api/v2/studyprogrammes/**/studytracks').as('studytracks')
|
||||
})
|
||||
|
||||
describe('Population search', () => {
|
||||
it('Info box works', () => {
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('not.exist')
|
||||
cy.get('[data-cy="PopulationSearch-open-info"]').click()
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('be.visible')
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('contain', 'Tässä osiossa voi tarkastella')
|
||||
cy.get('[data-cy="PopulationSearch-close-info"]').click()
|
||||
cy.get('[data-cy="PopulationSearch-info-content"]').should('not.exist')
|
||||
cy.cs('PopulationSearch-info-box-content').should('not.exist')
|
||||
cy.cs('PopulationSearch-info-box-button').trigger('mouseover')
|
||||
cy.cs('PopulationSearch-info-box-content').should('be.visible')
|
||||
cy.cs('PopulationSearch-info-box-content').contains('Tässä osiossa voi tarkastella')
|
||||
cy.cs('PopulationSearch-info-box-button').trigger('mouseout')
|
||||
cy.cs('PopulationSearch-info-box-content').should('not.exist')
|
||||
})
|
||||
|
||||
it('Form is usable', () => {
|
||||
// TODO: Delete MUI
|
||||
it.skip('Form is usable', { retries: { runMode: 2 } }, () => {
|
||||
cy.contains('Search for class')
|
||||
cy.contains('See class').should('be.disabled')
|
||||
cy.contains('Class of')
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get('.form-control').as('enrollmentSelect')
|
||||
})
|
||||
|
||||
cy.get('@enrollmentSelect')
|
||||
.its(`${[0]}.value`)
|
||||
.then(beforeVal => {
|
||||
cy.get('@enrollmentSelect').click()
|
||||
cy.get('.yearSelectInput .rdtPrev').click({ force: true })
|
||||
cy.get('.yearSelectInput table').contains('2018-2019').click({ force: true })
|
||||
cy.get('@enrollmentSelect').should('not.have.value', beforeVal)
|
||||
})
|
||||
cy.cs('population-year-selector').as('yearSelect')
|
||||
cy.cs('population-year-decrement').as('yearDecrement')
|
||||
cy.cs('population-year-increment').as('yearIncrement')
|
||||
|
||||
cy.contains('No study tracks available for this programme')
|
||||
cy.get('@yearSelect').click()
|
||||
cy.contains('2018 - 2019').click()
|
||||
cy.get('@yearSelect').within(() => cy.contains('2018 - 2019'))
|
||||
cy.get('@yearDecrement').click()
|
||||
cy.get('@yearSelect').within(() => cy.contains('2017 - 2018'))
|
||||
cy.get('@yearIncrement').click()
|
||||
cy.get('@yearSelect').within(() => cy.contains('2018 - 2019'))
|
||||
|
||||
cy.cs('population-studytrack-selector').within(() => {
|
||||
cy.get('input').should('have.attr', 'placeholder', 'No study tracks available')
|
||||
})
|
||||
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
cy.contains('See class').should('be.enabled')
|
||||
selectStudyTrack('Matematiikka ja soveltava matematiikka')
|
||||
@@ -60,21 +68,25 @@ describe('Population statistics tests', () => {
|
||||
})
|
||||
|
||||
describe('Correct population is shown for programme', () => {
|
||||
it('without study tracks', () => {
|
||||
cy.contains('Select study programme')
|
||||
// TODO: Delete MUI
|
||||
it.skip('without study tracks', () => {
|
||||
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
selectStudyProgramme('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
cy.contains('See class').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2017 - 2018')
|
||||
cy.contains('class size 47 students')
|
||||
cy.contains('Class size 47 students')
|
||||
})
|
||||
|
||||
it('with study tracks', () => {
|
||||
// TODO: Delete MUI
|
||||
it.skip('with study tracks', () => {
|
||||
selectStudyProgramme('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
cy.wait('@studytracks').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
selectStudyTrack('Matematiikka ja soveltava matematiikka')
|
||||
cy.contains('See class').click()
|
||||
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma 2017 - 2018')
|
||||
cy.contains('studytrack MAST-MSM')
|
||||
cy.contains('class size 1 students')
|
||||
cy.contains('Class size 1 students')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -83,97 +95,13 @@ describe('Population statistics tests', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.cs('filtered-students')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('class size 30 students')
|
||||
cy.contains('Class size 30 students')
|
||||
cy.contains('Excludes exchange students')
|
||||
cy.contains('Excludes students with non-degree study right')
|
||||
cy.contains('Excludes students who have transferred out of this programme')
|
||||
cy.cs('filtered-students')
|
||||
})
|
||||
|
||||
it('Courses of class is displayed and link to individual course stats page works', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
|
||||
cy.get('[data-cy=toggle-group-module-MAT110]').click()
|
||||
cy.contains('td', 'MAT11001').siblings().find('i.level.up').click()
|
||||
cy.wait('@coursePage')
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT11001 • Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('AYMAT11001 • Avoin yo: Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('57033 • Johdatus yliopistomatematiikkaan')
|
||||
cy.contains('A57033 • Avoin yo: Johdatus yliopistomatematiikkaan')
|
||||
})
|
||||
|
||||
it('Courses of class curriculum selection works', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.get('[data-cy=curriculum-picker]').contains('2020–2023')
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]')
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
|
||||
})
|
||||
|
||||
it('New fetch of courses data is done when curriculum is changed', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v2/populationstatistics/courses').as('courseData')
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2020–2023').click({ force: true })
|
||||
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(true)
|
||||
})
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('New fetch of courses data is done when filtered students change', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v2/populationstatistics/courses').as('courseData')
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
})
|
||||
cy.get('[data-cy=GraduatedFromProgramme-filter-card]').within(() => {
|
||||
cy.get('[data-cy=GraduatedFromProgramme-header]').click()
|
||||
cy.get('[data-cy=option-graduated-true]').click()
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(16)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("Empty 'tags' tab has a link to the page where tags can be created", { retries: 2 }, () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Students (27)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
|
||||
})
|
||||
cy.get('[data-cy=student-table-tabs]').within(() => {
|
||||
cy.contains('Tags').click()
|
||||
})
|
||||
cy.contains('No tags defined. You can define them here.').find('a').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?p_m_tab=0&p_tab=4')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Create tags for study programme')
|
||||
cy.contains('button', 'Create a new tag').should('be.disabled')
|
||||
})
|
||||
|
||||
// This test sometimes fails on headless mode. It seems that the click on the
|
||||
// 'Fetch class with new settings' button doesn't always trigger history.push()
|
||||
// so the page doesn't reload. This is why waiting also doesn't help.
|
||||
@@ -200,121 +128,244 @@ 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()
|
||||
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.cs('credits-gained-table-All students of the class').within(() => {
|
||||
cy.get('th').eq(1).contains('Credits gained between 01.08.2020 and 30.08.2024')
|
||||
cy.get('th').eq(1).contains('(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.cs('credit-statistics-tab').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('h5', 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('Courses data is changed when curriculum is changed', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
|
||||
cy.cs('curriculum-picker').scrollIntoView()
|
||||
cy.cs('curriculum-picker').should('be.visible')
|
||||
cy.cs('curriculum-picker').click()
|
||||
cy.contains('2020–2023').click({ force: true })
|
||||
cy.contains('Students (27)')
|
||||
cy.cs('toggle-group-module-DIGI-k').should('exist')
|
||||
cy.cs('toggle-group-module-DIGI-k').scrollIntoView()
|
||||
cy.cs('toggle-group-module-DIGI-k').should('be.visible')
|
||||
cy.cs('toggle-group-module-DIGI-k').click({ force: true })
|
||||
cy.contains('DIGI-100').should('exist')
|
||||
|
||||
cy.cs('curriculum-picker').scrollIntoView()
|
||||
cy.cs('toggle-group-module-DIGI-k').should('be.visible')
|
||||
cy.cs('curriculum-picker').click()
|
||||
cy.contains('2023–2026').click({ force: true })
|
||||
cy.cs('toggle-group-module-DIGI-k').should('exist')
|
||||
cy.contains('DIGI-100').should('not.exist')
|
||||
})
|
||||
|
||||
it('Courses data is changed when filtered students change', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
|
||||
cy.cs('graduatedFromProgrammeFilter-filter-card').within(() => {
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
|
||||
})
|
||||
|
||||
cy.contains('Students (16)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Students', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Students (27)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (27)').click()
|
||||
})
|
||||
})
|
||||
|
||||
it("'General tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('522142')
|
||||
cy.contains('Tilastotiede')
|
||||
cy.contains('Matematiikka')
|
||||
cy.contains('Taloustieteen maisteriohjelma')
|
||||
cy.contains('Todistusvalinta')
|
||||
cy.contains('Saksa')
|
||||
cy.contains('Female')
|
||||
})
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it("'Courses tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Courses').click()
|
||||
cy.contains('MAT12001')
|
||||
cy.contains('MAT21001')
|
||||
})
|
||||
})
|
||||
|
||||
it("'Modules tab' Displays correct modules based on the selected programme", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Modules').click()
|
||||
cy.contains('MAT011')
|
||||
cy.contains('MAT110')
|
||||
})
|
||||
cy.contains('Courses of class').click()
|
||||
cy.cs('curriculum-picker').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.contains('Courses of class').click()
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('MAT011').should('not.exist')
|
||||
cy.contains('MAT110')
|
||||
})
|
||||
})
|
||||
|
||||
it.skip("Empty 'tags' tab has a link to the page where tags can be created", () => {
|
||||
// TODO: This fails in the pipeline, but works locally. Investigate.
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Tags').click()
|
||||
cy.contains('No tags defined. You can define them here.').find('a').click()
|
||||
})
|
||||
cy.url().should('include', '/study-programme/KH50_001?tab=4')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Create new tag')
|
||||
cy.cs('create-button').should('be.disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -332,7 +383,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()
|
||||
@@ -351,29 +404,23 @@ describe('Population statistics tests', () => {
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathBSc2020, 'onlyiamrights')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('class size 30 students')
|
||||
cy.contains('Class size 30 students')
|
||||
})
|
||||
|
||||
it('Population statistics is visible', () => {
|
||||
cy.get('.card').within(() => {
|
||||
cy.cs('PopulationQueryCard').within(() => {
|
||||
cy.contains('Excludes exchange students')
|
||||
cy.contains('Excludes students with non-degree study right')
|
||||
cy.contains('Excludes students who have transferred out of this programme')
|
||||
})
|
||||
})
|
||||
|
||||
it('Only correct panes are visible', () => {
|
||||
const correctPanes = [
|
||||
'Credit accumulation (for 27 students)',
|
||||
'Credit statistics',
|
||||
'Age distribution',
|
||||
'Courses of class',
|
||||
]
|
||||
cy.get('.accordion.ui.fluid.styled').within(() => {
|
||||
cy.get('.title').should('have.length', correctPanes.length)
|
||||
correctPanes.forEach((pane, index) => {
|
||||
cy.get('.title').eq(index).contains(pane)
|
||||
})
|
||||
it('Only correct panels are visible', () => {
|
||||
cy.cs('panelview-parent').within(() => {
|
||||
cy.cs('Credit accumulation (for 27 students)')
|
||||
cy.cs('Credit statistics')
|
||||
cy.cs('Age distribution')
|
||||
cy.cs('Courses of class')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+66
-50
@@ -1,15 +1,15 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const student = {
|
||||
firstnames: 'Varpu Roope',
|
||||
lastname: 'Mårtensson',
|
||||
studentnumber: '550003',
|
||||
sis_person_id: 'hy-hlo-115926826',
|
||||
firstNames: 'Varpu Roope',
|
||||
lastName: 'Mårtensson',
|
||||
studentNumber: '550003',
|
||||
sisPersonId: 'hy-hlo-115926826',
|
||||
email: 'sisutestidata134902@testisisudata.fi',
|
||||
}
|
||||
|
||||
const typeToSearch = text => {
|
||||
cy.get("input[placeholder='Search with a student number or name (surname firstname)']").type(text)
|
||||
cy.cs('student-search').type(text)
|
||||
}
|
||||
|
||||
const typeStudentNumberAndClick = studentNumber => {
|
||||
@@ -17,79 +17,95 @@ const typeStudentNumberAndClick = studentNumber => {
|
||||
cy.contains('td', studentNumber).click()
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
cy.get("input[placeholder='Search with a student number or name (surname firstname)']").clear()
|
||||
}
|
||||
|
||||
describe('Students tests', () => {
|
||||
describe('When using basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init()
|
||||
cy.init('')
|
||||
cy.contains('Students').click()
|
||||
cy.url().should('include', '/students')
|
||||
cy.contains('Show student names')
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('Students search form is usable', () => {
|
||||
typeToSearch(student.lastname)
|
||||
typeToSearch(student.lastName)
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name').should('not.exist')
|
||||
cy.contains('First names').should('not.exist')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.firstnames)
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name')
|
||||
cy.contains('First names')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains(student.lastName)
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains('Student number')
|
||||
cy.contains('Last name').should('not.exist')
|
||||
cy.contains('First names').should('not.exist')
|
||||
cy.contains('Started')
|
||||
cy.contains('Credits')
|
||||
cy.contains('Active study rights')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
})
|
||||
|
||||
it('Search term must be at least 4 characters long', () => {
|
||||
typeToSearch(student.lastname.slice(0, 3))
|
||||
typeToSearch(student.lastName.slice(0, 3))
|
||||
cy.contains('Search term is not accurate enough')
|
||||
typeToSearch(student.lastname.slice(3))
|
||||
typeToSearch(student.lastName.slice(3))
|
||||
cy.get('table tbody tr').should('have.length', 1)
|
||||
})
|
||||
|
||||
it('Can search with studentnumber too', () => {
|
||||
typeToSearch(student.studentnumber)
|
||||
cy.contains(student.studentnumber)
|
||||
it('Can search with student number too', () => {
|
||||
typeToSearch(student.studentNumber)
|
||||
cy.contains(student.studentNumber)
|
||||
})
|
||||
|
||||
it('Can get student specific page by clicking student', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma (01.08.2020–31.07.2027)')
|
||||
cy.contains(student.lastname).should('not.exist')
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.email).should('not.exist')
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.lastname)
|
||||
cy.contains(student.firstnames)
|
||||
cy.contains(student.lastName)
|
||||
cy.contains(student.firstNames)
|
||||
cy.contains(student.email)
|
||||
|
||||
cy.cs('toggleStudentNames').click()
|
||||
cy.contains(student.lastname).should('not.exist')
|
||||
cy.contains(student.firstnames).should('not.exist')
|
||||
cy.contains(student.lastName).should('not.exist')
|
||||
cy.contains(student.firstNames).should('not.exist')
|
||||
cy.contains(student.email).should('not.exist')
|
||||
})
|
||||
|
||||
it("'Update student' button is not shown", () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('[data-cy=student-info-card]').within(() => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('button', 'Update student').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('Can get back to search menu', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.go('back')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
cy.contains('Credits').should('not.exist')
|
||||
})
|
||||
|
||||
it('Can jump to course', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('Tilastollinen päättely I (MAT12004)')
|
||||
.parent()
|
||||
.siblings()
|
||||
@@ -104,20 +120,20 @@ describe('Students tests', () => {
|
||||
})
|
||||
|
||||
it('Has correct Sisu link', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('[data-cy=sisu-link]')
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('sisu-link')
|
||||
.should('have.attr', 'href')
|
||||
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sis_person_id}/basic/basic-info`)
|
||||
.and('include', `https://sisu.helsinki.fi/tutor/role/staff/student/${student.sisPersonId}/basic/basic-info`)
|
||||
})
|
||||
|
||||
it('Semester enrollments can be toggled', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
const programmes = [
|
||||
'Oikeustieteen tohtoriohjelma',
|
||||
'Matemaattisten tieteiden kandiohjelma',
|
||||
'Oikeustieteen maisterin koulutusohjelmaOikeusnotaarin koulutusohjelma',
|
||||
]
|
||||
cy.get('[data-cy=student-info-card]').within(() => {
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('Enrollments').click()
|
||||
cy.get('table tbody tr')
|
||||
.should('have.length', 3)
|
||||
@@ -129,38 +145,38 @@ describe('Students tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("Searching with bad inputs doesn't yield results", () => {
|
||||
it("Searching with bad name doesn't yield results", () => {
|
||||
typeToSearch('SWAG LITTINEN')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
clearSearch()
|
||||
})
|
||||
|
||||
it("Searching with bad student number doesn't yield results", () => {
|
||||
typeToSearch('01114')
|
||||
cy.contains('Student number').should('not.exist')
|
||||
})
|
||||
|
||||
it('Can jump to population page', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('[data-cy=study-rights-section]').within(() => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('study-rights-section').within(() => {
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
.within(() => cy.get('a').click())
|
||||
})
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('class size 30 students')
|
||||
cy.contains('Class size 30 students')
|
||||
})
|
||||
|
||||
it('Grade graph works in all three different modes', () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.contains('button', 'Grade graph').click()
|
||||
cy.contains('button', 'Show group mean').click()
|
||||
cy.get('[data-cy=group-size-input]').within(() => {
|
||||
cy.cs('group-size-input').within(() => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').should('have.value', '5')
|
||||
})
|
||||
cy.contains('.highcharts-container text', "Nov '22")
|
||||
cy.contains('.highcharts-container text', "Jul '22").should('not.exist')
|
||||
cy.get('[data-cy=group-size-input]').within(() => {
|
||||
cy.cs('group-size-input').within(() => {
|
||||
cy.contains('label', 'Group size')
|
||||
cy.get('input').clear()
|
||||
cy.get('input').type('10')
|
||||
@@ -215,7 +231,7 @@ describe('Students tests', () => {
|
||||
})
|
||||
|
||||
it("Shows 'Has not graduated' when the student has not graduated", () => {
|
||||
cy.visit(`students/${student.studentnumber}`)
|
||||
cy.visit(`students/${student.studentNumber}`)
|
||||
cy.contains('h2', 'Bachelor Honours')
|
||||
cy.contains('[data-cy=honours-chip-not-qualified]', 'Not qualified for Honours')
|
||||
cy.contains('[data-cy=honours-chip-error]', 'Has not graduated')
|
||||
@@ -229,14 +245,14 @@ describe('Students tests', () => {
|
||||
cy.init('/students', 'admin')
|
||||
})
|
||||
|
||||
it('Does not crash if student has no studyright or courses', () => {
|
||||
it('Does not crash if student has no study rights or courses', () => {
|
||||
typeStudentNumberAndClick('450730')
|
||||
cy.contains('Credits: 0')
|
||||
})
|
||||
|
||||
it("'Update student' button is shown", () => {
|
||||
typeStudentNumberAndClick(student.studentnumber)
|
||||
cy.get('[data-cy=student-info-card]').within(() => {
|
||||
typeStudentNumberAndClick(student.studentNumber)
|
||||
cy.cs('student-info-card').within(() => {
|
||||
cy.contains('button', 'Update student')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,27 +66,30 @@ describe('Study guidance group tests', () => {
|
||||
cy.cs('Credit accumulation (for 2 students)')
|
||||
cy.cs('Age distribution')
|
||||
cy.cs('Courses of population')
|
||||
cy.cs('Students (2)')
|
||||
cy.contains('Students (2)')
|
||||
cy.cs('Credit statistics').should('not.exist')
|
||||
})
|
||||
|
||||
it('students table has the correct tabs', () => {
|
||||
cy.cs('Students (2)').click()
|
||||
it.skip('students table has the correct tabs', () => {
|
||||
cy.contains('Students (2)').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'])
|
||||
})
|
||||
})
|
||||
|
||||
it('general tab of the students table has the correct columns', () => {
|
||||
const firstRowHeadings = ['Student number', 'Credits', 'Start year at uni', 'Study programmes', 'Tags']
|
||||
const secondRowHeadings = ['All', 'Since 1.1.1970']
|
||||
cy.cs('Students (2)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').then($tr => {
|
||||
const firstRowTexts = Array.from($tr[0].children).map(elem => elem.innerText.replace('\n', ' '))
|
||||
expect(firstRowTexts).to.deep.equal(firstRowHeadings)
|
||||
const secondRowTexts = Array.from($tr[1].children).map(elem => elem.innerText)
|
||||
expect(secondRowTexts).to.deep.equal(secondRowHeadings)
|
||||
it.skip('general tab of the students table has the correct columns', () => {
|
||||
const colHeaders = [
|
||||
'Student number',
|
||||
'All credits',
|
||||
'Credits since 1.1.1970',
|
||||
'Start year at uni',
|
||||
'Study programmes',
|
||||
'Tags',
|
||||
]
|
||||
cy.contains('Students (2)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
|
||||
colHeaders.every(heading => cy.contains(heading))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -107,31 +110,36 @@ describe('Study guidance group tests', () => {
|
||||
cy.cs('Credit statistics')
|
||||
cy.cs('Age distribution')
|
||||
cy.cs('Courses of population')
|
||||
cy.cs('Students (3)')
|
||||
cy.contains('Students (3)')
|
||||
})
|
||||
|
||||
it("clicking the 'Show starting from associated year' button activates the 'Date of Course Credits' filter", () => {
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-open="false"]')
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-active="false"]')
|
||||
cy.cs('Credit accumulation (for 3 students)').click()
|
||||
cy.get('.ui.primary.button').contains('Show starting from associated year').click()
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-open="true"]')
|
||||
cy.get('.ui.mini.icon.button.credit-date-filter-input').contains('01.08.2020')
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-active="true"]')
|
||||
cy.get('.date-picker')
|
||||
.eq(0)
|
||||
.within(() => cy.get('input').should('have.value', '01.08.2020'))
|
||||
cy.contains('Reset All Filters')
|
||||
cy.get('.ui.primary.button').contains('Show all credits').click()
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-open="false"]')
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-active="false"]')
|
||||
})
|
||||
|
||||
it('students table has the correct tabs', () => {
|
||||
cy.cs('Students (3)').click()
|
||||
it.skip('students table has the correct tabs', () => {
|
||||
cy.contains('Students (3)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] .ui.attached.tabular.menu').then($menu => {
|
||||
const tabNames = Array.from($menu[0].children).map(elem => elem.innerText)
|
||||
expect(tabNames).to.deep.equal(['General', 'Courses', 'Progress'])
|
||||
expect(tabNames).to.deep.equal(['General', 'Courses', 'Modules', 'Progress'])
|
||||
})
|
||||
})
|
||||
|
||||
it('general tab of the students table has the correct columns', () => {
|
||||
const firstRowHeadings = [
|
||||
it.skip('general tab of the students table has the correct columns', () => {
|
||||
const colHeaders = [
|
||||
'Student number',
|
||||
'Credits',
|
||||
'All credits',
|
||||
'Credits in HOPS',
|
||||
'Credits since 1.8.2020',
|
||||
'Start of study right',
|
||||
'Started in programme',
|
||||
'Semesters present',
|
||||
@@ -146,15 +154,9 @@ describe('Study guidance group tests', () => {
|
||||
'Latest attainment date',
|
||||
'Tags',
|
||||
]
|
||||
const secondRowHeadings = ['All', 'HOPS', 'Since 1.8.2020']
|
||||
cy.cs('Students (3)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').then($tr => {
|
||||
const firstRowTexts = Array.from($tr[0].children).map(elem => elem.innerText.replaceAll('\n', ' '))
|
||||
expect(firstRowTexts).to.deep.equal(firstRowHeadings)
|
||||
const secondRowTexts = Array.from($tr[1].children)
|
||||
.filter(elem => elem.style.display !== 'none')
|
||||
.map(elem => elem.innerText)
|
||||
expect(secondRowTexts).to.deep.equal(secondRowHeadings)
|
||||
cy.contains('Students (3)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
|
||||
colHeaders.every(heading => cy.contains(heading))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,882 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const getEmptyYears = isAcademicYear => {
|
||||
const today = new Date()
|
||||
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
|
||||
|
||||
const years = []
|
||||
for (let year = latestYear; year >= 2024; year--) {
|
||||
if (isAcademicYear) {
|
||||
years.push(`${year} - ${year + 1}`)
|
||||
} else {
|
||||
years.push(year)
|
||||
}
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
const tagName = `tag-${new Date().getTime()}`
|
||||
|
||||
const selectYear = year => {
|
||||
// ? Testing the MUI X DatePicker seems very difficult. It does not detect data-cy and the tests fail in CI/CD
|
||||
cy.contains('Associated start year (optional)').click({ force: true })
|
||||
cy.get('[placeholder=YYYY]').type(year)
|
||||
}
|
||||
|
||||
const deleteTag = tagName => {
|
||||
cy.cs(`delete-tag-${tagName}-button`).click()
|
||||
cy.contains('Delete tag')
|
||||
cy.contains('Are you sure you want to delete tag')
|
||||
cy.cs('confirm-delete-tag-button').click()
|
||||
}
|
||||
|
||||
describe('Study programme overview', () => {
|
||||
describe('Study programme selector', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme', 'admin')
|
||||
})
|
||||
|
||||
it('Correct categories', () => {
|
||||
cy.contains('Bachelor programmes')
|
||||
cy.contains('Master programmes')
|
||||
cy.contains('Combined programmes')
|
||||
cy.contains('Doctoral programmes')
|
||||
})
|
||||
|
||||
it('Study programme search filter', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').should('exist')
|
||||
cy.cs('study-programme-filter').type('Tietojenkäsittelytieteen')
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').should('not.exist')
|
||||
})
|
||||
|
||||
it('Old and specialized programmes filter', () => {
|
||||
cy.contains('Biotekniikka (ylempi)').should('not.exist')
|
||||
cy.cs('filter-old-programmes-toggle').click()
|
||||
cy.contains('Biotekniikka (ylempi)').should('exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Basic information tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
})
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('Basic information tab loads', () => {
|
||||
cy.cs('students-of-the-study-programme-section')
|
||||
cy.cs('credits-produced-by-the-study-programme-section')
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
|
||||
cy.cs('programmes-before-or-after-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
})
|
||||
|
||||
it('Basic information contains correct students', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Transferred Away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
[2023, 8, 8, 26, 0, 0],
|
||||
[2022, 25, 26, 48, 1, 4],
|
||||
[2021, 29, 32, 47, 0, 2],
|
||||
[2020, 26, 27, 12, 1, 3],
|
||||
[2019, 28, 34, 1, 0, 1],
|
||||
[2018, 40, 45, 0, 0, 0],
|
||||
[2017, 41, 47, 0, 0, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'students-of-the-study-programme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct credits', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 222],
|
||||
[2022, 3235, 3205, 0, 30, 209],
|
||||
[2021, 5133, 5108, 0, 25, 428],
|
||||
[2020, 5801, 5796, 0, 5, 94],
|
||||
[2019, 5305, 5305, 0, 0, 162],
|
||||
[2018, 3442, 3432, 0, 10, 21],
|
||||
[2017, 1211, 1211, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
it("Toggling 'Show special categories' on displays additional information", () => {
|
||||
cy.cs('special-categories-toggle').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 0, 0, 222],
|
||||
[2022, 3235, 3205, 0, 30, 0, 0, 209],
|
||||
[2021, 5133, 5108, 0, 25, 0, 0, 428],
|
||||
[2020, 5801, 5796, 0, 5, 0, 0, 94],
|
||||
[2019, 5305, 5305, 0, 0, 0, 0, 162],
|
||||
[2018, 3442, 3432, 0, 10, 0, 0, 21],
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'credits-produced-by-the-study-programme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct thesis writers and graduates', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 26, 5],
|
||||
[2022, 48, 13],
|
||||
[2021, 47, 76],
|
||||
[2020, 12, 23],
|
||||
[2019, 1, 19],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
it('Special study rights can be excluded and basic data changes accordingly', () => {
|
||||
cy.cs('study-right-toggle').click()
|
||||
const years = getEmptyYears()
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated]
|
||||
...years.map(year => [year, 0, 0, 0]),
|
||||
[2023, 8, 8, 24],
|
||||
[2022, 25, 26, 44],
|
||||
[2021, 29, 32, 46],
|
||||
[2020, 26, 27, 11],
|
||||
[2019, 28, 34, 1],
|
||||
[2018, 40, 45, 0],
|
||||
[2017, 41, 47, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const graduatedTableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 24, 5],
|
||||
[2022, 44, 12],
|
||||
[2021, 46, 69],
|
||||
[2020, 11, 22],
|
||||
[2019, 1, 18],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(graduatedTableContents, 'graduated-and-graduations-of-the-programme')
|
||||
})
|
||||
|
||||
it('Year can be changed to academic year, and data changes accordingly', () => {
|
||||
cy.cs('year-toggle').click()
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Transferred away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 4, 0, 0],
|
||||
['2022 - 2023', 25, 26, 36, 0, 0],
|
||||
['2021 - 2022', 29, 32, 57, 1, 6],
|
||||
['2020 - 2021', 26, 27, 30, 0, 3],
|
||||
['2019 - 2020', 28, 34, 7, 1, 1],
|
||||
['2018 - 2019', 40, 45, 0, 0, 0],
|
||||
['2017 - 2018', 41, 47, 0, 0, 0],
|
||||
]
|
||||
cy.checkTableStats(studentTableContents, 'students-of-the-study-programme')
|
||||
|
||||
const creditTableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 160, 160, 0, 0, 67],
|
||||
['2022 - 2023', 2725, 2720, 0, 5, 337],
|
||||
['2021 - 2022', 4092, 4042, 0, 50, 198],
|
||||
['2020 - 2021', 5420, 5415, 0, 5, 321],
|
||||
['2019 - 2020', 6043, 6043, 0, 0, 101],
|
||||
['2018 - 2019', 4856, 4851, 0, 5, 107],
|
||||
['2017 - 2018', 2350, 2345, 0, 5, 26],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'credits-produced-by-the-study-programme')
|
||||
cy.cs('year-toggle').click()
|
||||
})
|
||||
|
||||
it('Basic information graphs render', () => {
|
||||
cy.cs('students-of-the-study-programme-line-graph-section')
|
||||
.should('contain', 'Started studying')
|
||||
.should('contain', 'Accepted')
|
||||
.should('contain', 'Graduated')
|
||||
.should('contain', 'Transferred away')
|
||||
.should('contain', 'Transferred to')
|
||||
|
||||
cy.cs('credits-produced-by-the-study-programme-stacked-bar-chart-section')
|
||||
.should('contain', 'Degree students')
|
||||
.should('contain', 'Transferred')
|
||||
.should('contain', 5796)
|
||||
.should('contain', 428)
|
||||
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-bar-chart-section')
|
||||
.should('contain', 'Graduated students')
|
||||
.should('contain', 'Wrote thesis')
|
||||
.should('contain', 47)
|
||||
.should('contain', 76)
|
||||
|
||||
cy.cs('bachelor-breakdown-bar-chart-section')
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2022')
|
||||
cy.contains('48 graduated').trigger('mouseover')
|
||||
cy.contains('48 students graduated in year 2022')
|
||||
cy.contains('median study time: 44.5 months')
|
||||
cy.contains('22 graduated on time')
|
||||
cy.contains('12 graduated max year overtime')
|
||||
cy.contains('14 graduated over year late')
|
||||
})
|
||||
|
||||
cy.cs('programmes-before-or-after-stacked-bar-chart-section')
|
||||
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
|
||||
.should('contain', 'Datatieteen maisteriohjelma')
|
||||
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
.should('contain', 32)
|
||||
.should('contain', 21)
|
||||
.should('contain', 17)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graduation times of master programmes', () => {
|
||||
it('are split into two graphs', () => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
|
||||
|
||||
cy.cs('bachelor-breakdown-bar-chart-section')
|
||||
cy.cs('master-breakdown-bar-chart-section')
|
||||
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('master-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Master study right')
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2021')
|
||||
cy.contains('2 graduated').trigger('mouseover')
|
||||
cy.contains('2 students graduated in year 2021')
|
||||
cy.contains('median study time: 25 months')
|
||||
cy.contains('1 graduated on time')
|
||||
cy.contains('1 graduated max year overtime')
|
||||
cy.contains('0 graduated over year late')
|
||||
})
|
||||
|
||||
cy.cs('bachelor-median-time-bar-chart-section').within(() => {
|
||||
cy.contains('Bachelor + master study right')
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2023')
|
||||
cy.contains('11 graduated').trigger('mouseover')
|
||||
cy.contains('11 students graduated in year 2023')
|
||||
cy.contains('median study time: 69 months')
|
||||
cy.contains('3 graduated on time')
|
||||
cy.contains('7 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Study tracks and class statistics tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept(
|
||||
'GET',
|
||||
'api/v2/studyprogrammes/KH50_001/studytrackstats?graduated=GRADUATED_INCLUDED&special_groups=SPECIAL_INCLUDED&combined_programme='
|
||||
).as('stQuery')
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
})
|
||||
|
||||
describe('Info boxes', () => {
|
||||
it('Study track overview section', () => {
|
||||
cy.cs('study-track-overview-info-box-button').click()
|
||||
cy.cs('study-track-overview-info-box-content').contains('Opiskelijat, joiden')
|
||||
})
|
||||
|
||||
it('Progress of students section', () => {
|
||||
cy.cs('progress-of-students-info-box-button').click()
|
||||
cy.cs('progress-of-students-info-box-content').contains('Kuvaa koulutusohjelmassa')
|
||||
})
|
||||
|
||||
it('Average graduation times section', () => {
|
||||
cy.cs('average-graduation-times-info-box-button').click()
|
||||
cy.cs('average-graduation-times-info-box-content').contains('Yksittäinen palkki')
|
||||
})
|
||||
})
|
||||
|
||||
it('All sections are visible', () => {
|
||||
cy.cs('study-track-selector-section')
|
||||
cy.cs('study-track-overview-section')
|
||||
cy.cs('progress-of-students-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
})
|
||||
|
||||
it('Students of the study programme are shown correctly', () => {
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 1],
|
||||
['2022 - 2023', 26, 25, 0, 0, 24, 2, 19, 7, 0, 25, 1],
|
||||
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
|
||||
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 3],
|
||||
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 35, 2],
|
||||
['2018 - 2019', 45, 40, 0, 0, 6, 39, 26, 19, 0, 44, 1],
|
||||
['2017 - 2018', 47, 41, 0, 0, 5, 42, 31, 16, 0, 47, 0],
|
||||
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 224, 12],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
it.skip('Years in the students table can be expanded and study track data will be shown', () => {
|
||||
// TODO: Fix this test
|
||||
cy.cs('study-tracks-and-class-statistics-data-table').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.get('td i.angle.right.icon').click()
|
||||
})
|
||||
|
||||
const dataForStudyTracks = [
|
||||
['Ekonometria, MAT-EKO', 2, 2, 0, 0, 0, 2, 1, 1, 0, 2, 0],
|
||||
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
|
||||
['Tietojenkäsittelyteoria, MAT-TIE', 2, 1, 0, 0, 1, 1, 2, 0, 0, 2, 0],
|
||||
['Tilastotiede, MAT-TIL', 4, 4, 0, 0, 0, 4, 1, 3, 0, 4, 0],
|
||||
]
|
||||
|
||||
dataForStudyTracks.forEach((data, index) => {
|
||||
cy.get('tbody tr.regular-row')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
cy.get('td').each((cell, i) => {
|
||||
cy.wrap(cell).contains(data[i])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.get('td i.angle.down.icon').click()
|
||||
})
|
||||
|
||||
cy.get('tbody tr.regular-row').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Population link button works for', () => {
|
||||
it('a single year', () => {
|
||||
cy.cs('2023-population-link-button').first().click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2023 - 2024')
|
||||
cy.contains('Class size 8 students')
|
||||
})
|
||||
|
||||
it('total', () => {
|
||||
cy.cs('total-population-link-button').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Class size 227 students')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.cs('show-study-tracks-button').first().click()
|
||||
cy.contains('td', 'Matematiikka (MAT-MAT)').within(() => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains('Studytrack MAT-MAT')
|
||||
cy.contains('Class size 26 students')
|
||||
cy.contains('Showing 3 out of 26 students')
|
||||
})
|
||||
})
|
||||
|
||||
it('Student progress data is shown correctly', () => {
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 26, 9, 9, 4, 3, 0, 1, 0],
|
||||
['2021 - 2022', 38, 8, 6, 11, 8, 5, 0, 0],
|
||||
['2020 - 2021', 30, 2, 1, 3, 7, 4, 4, 9],
|
||||
['2019 - 2020', 35, 1, 0, 2, 1, 0, 4, 27],
|
||||
['2018 - 2019', 45, 0, 1, 1, 2, 0, 3, 38],
|
||||
['2017 - 2018', 47, 0, 1, 3, 0, 1, 2, 40],
|
||||
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'study-programme-progress')
|
||||
})
|
||||
|
||||
it('Progress section', () => {
|
||||
cy.cs('programme-progress-bar-chart-section')
|
||||
.should('contain', 'Less than 30 credits')
|
||||
.should('contain', '30–60 credits')
|
||||
.should('contain', 'At least 180 credits')
|
||||
.should('contain', '49.8%') // The percentage for total, at least 180 credits, to check that the graph renders
|
||||
|
||||
cy.cs('programme-progress-bar-chart-section').contains('49.8%').trigger('mouseover', { force: true })
|
||||
cy.contains('At least 180 credits: 114')
|
||||
})
|
||||
|
||||
it('Average graduation times section', () => {
|
||||
cy.cs('average-graduation-times-section').within(() => {
|
||||
cy.contains('2020 - 2021')
|
||||
cy.get('[aria-label="2020 - 2021, 15. On time."]').trigger('mouseover')
|
||||
cy.contains('On time: 15')
|
||||
cy.contains("Click a bar to view that year's study track level breakdown")
|
||||
cy.get('[aria-label="2019 - 2020, 22. On time."]').click()
|
||||
cy.contains('Year 2019 - 2020 by start year')
|
||||
cy.get('[aria-label="MAT-MAT, 14. On time."]').trigger('mouseover')
|
||||
cy.contains('Matematiikka')
|
||||
cy.contains('MAT-MAT')
|
||||
cy.contains('On time: 14')
|
||||
})
|
||||
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
|
||||
cy.cs('average-graduation-times-section').within(() => {
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('19 graduated').trigger('mouseover')
|
||||
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
|
||||
cy.contains('median study time: 33 months')
|
||||
cy.contains('15 graduated on time')
|
||||
cy.contains('3 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Study track can be changed', () => {
|
||||
beforeEach(() => {
|
||||
cy.wait('@stQuery')
|
||||
cy.contains('All students of the programme')
|
||||
cy.cs('study-track-select').click()
|
||||
cy.contains('Ekonometria')
|
||||
cy.contains('Tietojenkäsittelyteoria')
|
||||
cy.contains('Tilastotiede')
|
||||
cy.contains('Matematiikka').click()
|
||||
})
|
||||
|
||||
it('Students of the study track are shown correctly', () => {
|
||||
cy.cs('study-track-overview-section').contains('Students of the study track MAT-MAT by starting year')
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2022 - 2023', 3, 3, 0, 0, 1, 2, 2, 1, 0, 3, 0],
|
||||
['2021 - 2022', 5, 1, 0, 0, 1, 4, 3, 2, 0, 5, 0],
|
||||
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
|
||||
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 17, 1],
|
||||
['2018 - 2019', 23, 21, 0, 0, 2, 21, 11, 12, 0, 23, 0],
|
||||
['2017 - 2018', 28, 24, 0, 0, 1, 27, 15, 13, 0, 28, 0],
|
||||
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 88, 4],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'study-tracks-and-class-statistics')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.cs('2020-population-link-button').first().click()
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('Studytrack MAT-MAT')
|
||||
cy.contains('Class size 30 students')
|
||||
cy.contains('Showing 10 out of 30 students')
|
||||
})
|
||||
|
||||
it('Info message about missing progress stats is displayed', () => {
|
||||
cy.contains('Progress of students of the study track MAT-MAT by starting year')
|
||||
cy.contains('Progress data is currently only available for all students of the study programme.')
|
||||
})
|
||||
|
||||
it.skip('Average graduation times are displayed correctly', () => {
|
||||
cy.get("[data-cy='Section-averageGraduationTimesStudyTracks']")
|
||||
cy.get("[data-cy='graduation-times-graph-breakdownBachelor']").within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.get('[aria-label="2020 - 2021, 9. On time."]').trigger('mouseover')
|
||||
cy.contains('Graduated On time: 9 students')
|
||||
})
|
||||
|
||||
cy.cs('graduation-time-toggle').click()
|
||||
cy.cs('bachelor-median-time-bar-chart').within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('12 graduated').trigger('mouseover')
|
||||
cy.contains('From class of 2020 - 2021, 12/13 students have graduated')
|
||||
cy.contains('median study time: 34 months')
|
||||
cy.contains('9 graduated on time')
|
||||
cy.contains('2 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Programme courses tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('programme-courses-tab').click()
|
||||
|
||||
cy.cs('by-credit-type-section')
|
||||
cy.cs('by-credit-type-tab')
|
||||
cy.cs('by-semester-tab')
|
||||
})
|
||||
|
||||
describe('By credit type tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT21001')
|
||||
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
|
||||
cy.get('td').eq(3).contains('341')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('time range selection works', () => {
|
||||
cy.cs('from-year-select').click()
|
||||
cy.cs('from-year-select-option-2021').click()
|
||||
cy.cs('to-year-select').click()
|
||||
cy.cs('to-year-select-option-2022').click()
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT21001')
|
||||
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
|
||||
cy.get('td').eq(3).contains('121')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('year toggle works', () => {
|
||||
cy.cs('year-toggle').click()
|
||||
|
||||
cy.cs('from-year-select').click()
|
||||
cy.cs('from-year-select-option-2017').click()
|
||||
cy.cs('to-year-select').click()
|
||||
cy.cs('to-year-select-option-2023').click()
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT21001')
|
||||
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
|
||||
cy.get('td').eq(3).contains('311')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: Fix sorting / filters
|
||||
it.skip('different sorting options work', () => {
|
||||
cy.cs('CoursesSortableTable').within(() => {
|
||||
// Course code
|
||||
cy.get('th').eq(0).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('odgi-zef0')
|
||||
cy.get('td').eq(1).contains('Työ- ja organisaatiopsykologian perusopinnot')
|
||||
})
|
||||
cy.get('th').eq(0).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT-yht')
|
||||
cy.get('td').eq(1).contains('Muut opinnot')
|
||||
})
|
||||
|
||||
// Course name
|
||||
cy.get('th').eq(1).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT20003')
|
||||
cy.get('td').eq(1).contains('Äidinkielen opinnot')
|
||||
})
|
||||
cy.get('th').eq(1).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
})
|
||||
|
||||
// Total credits
|
||||
cy.get('th').eq(2).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT110')
|
||||
cy.get('td').eq(1).contains('Matematiikka, perusopinnot')
|
||||
})
|
||||
cy.get('th').eq(2).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT20001')
|
||||
cy.get('td').eq(1).contains('Kypsyysnäyte')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("'Show credits/Show students' toggle works", () => {
|
||||
cy.get('thead tr').within(() => {
|
||||
cy.get('th').should('have.length', 8)
|
||||
const headers = ['Type', 'Code', 'Name', 'Total', 'Major', 'Non-major', 'Non-degree', 'Transferred']
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
|
||||
cy.cs('show-credits-students-toggle').click()
|
||||
|
||||
cy.get('thead tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 10)
|
||||
const headers = [
|
||||
'Type',
|
||||
'Code',
|
||||
'Name',
|
||||
'Total',
|
||||
'Passed',
|
||||
'Not',
|
||||
'Major',
|
||||
'Non-major',
|
||||
'Non-degree',
|
||||
'Transferred',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('Course')
|
||||
cy.get('td').eq(1).contains('MAT21001')
|
||||
cy.get('td').eq(2).contains('Lineaarialgebra ja matriisilaskenta II')
|
||||
cy.get('td').eq(3).contains('341')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('By semester tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('by-semester-tab').click()
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('test', () => {
|
||||
// TODO: Implement some real tests for this tab
|
||||
cy.contains('From')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Degree courses tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('degree-courses-tab').click()
|
||||
})
|
||||
|
||||
it('Curriculum section', () => {
|
||||
cy.cs('curriculum-section')
|
||||
cy.contains('Select curriculum to edit:')
|
||||
cy.cs('curriculum-picker').contains('2023–2026')
|
||||
// TODO: Test changing the curriculum
|
||||
})
|
||||
|
||||
describe('Credit criteria section', () => {
|
||||
it('info box', () => {
|
||||
cy.cs('credit-criteria-section')
|
||||
cy.cs('credit-criteria-info-box-button').click()
|
||||
cy.cs('credit-criteria-info-box-content').contains('Here you can change')
|
||||
})
|
||||
|
||||
it.skip('changing the credit criteria works', () => {
|
||||
// TODO: Test using the inputs
|
||||
// TODO: Button status
|
||||
// TODO: Test "previously set to _" text changing when saving
|
||||
})
|
||||
})
|
||||
|
||||
it('Degree course table', () => {
|
||||
cy.cs('degree-course-table')
|
||||
// TODO: Test clicking the arrow buttons
|
||||
// TODO: Test toggling visibility for MODULES and COURSES
|
||||
// TODO: Test changing criterion labels
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tags tab works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
cy.cs('tags-tab').click()
|
||||
})
|
||||
|
||||
it('info box', () => {
|
||||
cy.cs('create-new-tag-info-box-button').click()
|
||||
cy.cs('create-new-tag-info-box-content').contains('Here you can create')
|
||||
})
|
||||
|
||||
describe('Adding tags to populations and removing them works', () => {
|
||||
beforeEach(() => {
|
||||
cy.contains('This study programme does not have any tags yet')
|
||||
cy.cs('tag-name-text-field').type(tagName)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
deleteTag(tagName)
|
||||
cy.contains('This study programme does not have any tags yet')
|
||||
})
|
||||
|
||||
it('can create and delete tags without start year', () => {
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.contains('No associated start year')
|
||||
})
|
||||
|
||||
it.skip('can create and delete tags with start year', () => {
|
||||
// TODO: This test fails in the pipeline, but works locally. Investigate.
|
||||
selectYear('2022')
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.contains('Associated start year 2022')
|
||||
})
|
||||
|
||||
it.skip('population link works', () => {
|
||||
// TODO: This test fails in the pipeline, but works locally. Investigate.
|
||||
selectYear('2022')
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.cs('population-link-button').click()
|
||||
cy.contains(`Tagged with: ${tagName}`)
|
||||
cy.go('back')
|
||||
})
|
||||
|
||||
it('can create personal tags', () => {
|
||||
cy.cs('personal-tag-toggle').click()
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
cy.cs(`${tagName}-visibility-icon`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Adding tags to students and removing them works', () => {
|
||||
const studentInput = '477806,478275; 478953 479239\n 480080'
|
||||
const studentNumbers = studentInput.match(/[^\s,;]+/g)
|
||||
|
||||
it.skip('can add tags to students', () => {
|
||||
// TODO: This test fails in the pipeline, but works locally. Investigate.
|
||||
cy.cs('tag-name-text-field').type(tagName)
|
||||
selectYear('2022')
|
||||
cy.cs('create-button').click()
|
||||
cy.contains(tagName)
|
||||
|
||||
cy.cs('add-students-button').click()
|
||||
cy.cs('add-students-text-field').type(studentInput)
|
||||
cy.cs('add-students-confirm-button').click()
|
||||
cy.contains('Students added to tag')
|
||||
|
||||
cy.cs('population-link-button').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains(`Tagged with: ${tagName}`)
|
||||
cy.contains('Students (5)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (5)').click()
|
||||
})
|
||||
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.contains(studentNumber)
|
||||
}
|
||||
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags')
|
||||
cy.contains(tagName)
|
||||
}
|
||||
|
||||
cy.get('a').contains('Matemaattisten tieteiden kandiohjelma').invoke('removeAttr', 'target').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?tab=4')
|
||||
|
||||
deleteTag(tagName)
|
||||
})
|
||||
|
||||
it('deleting a tag from tag view also removes it from students', () => {
|
||||
cy.contains(tagName).should('not.exist')
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags').should('not.exist')
|
||||
cy.contains(tagName).should('not.exist')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('IAM user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme', 'onlyiamrights')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
})
|
||||
|
||||
it.skip('year selector', () => {
|
||||
// TODO: Implement this test
|
||||
// Things to test:
|
||||
// - Clicking the button works
|
||||
// - Selecting a study track works
|
||||
// - The button is disabled if only one year is selected
|
||||
// - The slider is not visible if the user does not have correct programme rights
|
||||
})
|
||||
|
||||
it('can access programme and correct tabs are visible', () => {
|
||||
cy.contains('Basic information')
|
||||
cy.contains('Study tracks and class statistics')
|
||||
|
||||
cy.contains('Update statistics').should('not.exist')
|
||||
cy.contains('Degree courses').should('not.exist')
|
||||
})
|
||||
|
||||
it('can access basic information', () => {
|
||||
cy.cs('basic-information-tab').click()
|
||||
|
||||
cy.cs('students-of-the-study-programme-section')
|
||||
cy.cs('credits-produced-by-the-study-programme-section')
|
||||
cy.cs('graduated-and-thesis-writers-of-the-programme-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
cy.cs('programmes-before-or-after-section')
|
||||
})
|
||||
|
||||
it('can access study tracks', () => {
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
|
||||
cy.cs('study-track-overview-section')
|
||||
cy.cs('progress-of-students-section')
|
||||
cy.cs('average-graduation-times-section')
|
||||
})
|
||||
|
||||
it("doesn't see other tabs", () => {
|
||||
cy.cs('degree-courses-tab').should('not.exist')
|
||||
cy.cs('tags-tab').should('not.exist')
|
||||
cy.cs('update-statistics-tab').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,804 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const { getEmptyYears } = require('../support/commands')
|
||||
|
||||
const deleteTag = name => {
|
||||
cy.contains('td', name).siblings().contains('Delete').click()
|
||||
cy.contains('Are you sure you want to delete tag')
|
||||
cy.contains('Confirm').click()
|
||||
cy.contains('td', name).should('not.exist')
|
||||
}
|
||||
|
||||
describe('Study programme overview', () => {
|
||||
/* Basic information overview -tests */
|
||||
describe('Basic information view works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
})
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('Basic information tab loads', () => {
|
||||
cy.get('[data-cy=Section-studentsOfTheStudyProgramme]')
|
||||
cy.get('[data-cy=Section-creditsProducedByTheStudyProgramme]')
|
||||
cy.get('[data-cy=Section-graduatedAndThesisWritersOfTheProgramme]')
|
||||
cy.get('[data-cy=Section-programmesBeforeOrAfter]')
|
||||
cy.get('[data-cy=Section-averageGraduationTimes]')
|
||||
})
|
||||
|
||||
it('Basic information contains correct students', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Transferred Away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
[2023, 8, 8, 26, 0, 0],
|
||||
[2022, 25, 26, 48, 1, 4],
|
||||
[2021, 29, 32, 47, 0, 2],
|
||||
[2020, 26, 27, 12, 1, 3],
|
||||
[2019, 28, 34, 1, 0, 1],
|
||||
[2018, 40, 45, 0, 0, 0],
|
||||
[2017, 41, 47, 0, 0, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudentsOfTheStudyProgramme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct credits', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 222],
|
||||
[2022, 3235, 3205, 0, 30, 209],
|
||||
[2021, 5133, 5108, 0, 25, 428],
|
||||
[2020, 5801, 5796, 0, 5, 94],
|
||||
[2019, 5305, 5305, 0, 0, 162],
|
||||
[2018, 3442, 3432, 0, 10, 21],
|
||||
[2017, 1211, 1211, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyProgramme')
|
||||
})
|
||||
|
||||
it("Toggling 'Show special categories' on displays additional information", () => {
|
||||
cy.contains('Show special categories').click()
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0]),
|
||||
[2023, 1519, 1519, 0, 0, 0, 0, 222],
|
||||
[2022, 3235, 3205, 0, 30, 0, 0, 209],
|
||||
[2021, 5133, 5108, 0, 25, 0, 0, 428],
|
||||
[2020, 5801, 5796, 0, 5, 0, 0, 94],
|
||||
[2019, 5305, 5305, 0, 0, 0, 0, 162],
|
||||
[2018, 3442, 3432, 0, 10, 0, 0, 21],
|
||||
[2017, 1211, 1211, 0, 0, 0, 0, 189],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'CreditsProducedByTheStudyProgramme')
|
||||
})
|
||||
|
||||
it('Basic information contains correct thesis writers and graduates', () => {
|
||||
const years = getEmptyYears()
|
||||
const tableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 26, 5],
|
||||
[2022, 48, 13],
|
||||
[2021, 47, 76],
|
||||
[2020, 12, 23],
|
||||
[2019, 1, 19],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'GraduatedAndThesisWritersOfTheProgramme')
|
||||
})
|
||||
|
||||
it('Special study rights can be excluded and basic data changes accordingly', () => {
|
||||
cy.get('[data-cy=StudentToggle]').click()
|
||||
const years = getEmptyYears()
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated]
|
||||
...years.map(year => [year, 0, 0, 0]),
|
||||
[2023, 8, 8, 24],
|
||||
[2022, 25, 26, 44],
|
||||
[2021, 29, 32, 46],
|
||||
[2020, 26, 27, 11],
|
||||
[2019, 28, 34, 1],
|
||||
[2018, 40, 45, 0],
|
||||
[2017, 41, 47, 0],
|
||||
]
|
||||
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyProgramme')
|
||||
|
||||
const graduatedTableContents = [
|
||||
// [Year, Graduated, Wrote thesis]
|
||||
...years.map(year => [year, 0, 0]),
|
||||
[2023, 24, 5],
|
||||
[2022, 44, 12],
|
||||
[2021, 46, 69],
|
||||
[2020, 11, 22],
|
||||
[2019, 1, 18],
|
||||
[2018, 0, 0],
|
||||
[2017, 0, 1],
|
||||
]
|
||||
|
||||
cy.checkTableStats(graduatedTableContents, 'GraduatedAndThesisWritersOfTheProgramme')
|
||||
})
|
||||
|
||||
it('Year can be changed to academic year, and data changes accordingly', () => {
|
||||
cy.get('[data-cy=YearToggle]').click({ force: true })
|
||||
const isAcademicYear = true
|
||||
const years = getEmptyYears(isAcademicYear)
|
||||
const studentTableContents = [
|
||||
// [Year, Started studying, Accepted, Graduated, Transferred away, Transferred to]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 4, 0, 0],
|
||||
['2022 - 2023', 25, 26, 36, 0, 0],
|
||||
['2021 - 2022', 29, 32, 57, 1, 6],
|
||||
['2020 - 2021', 26, 27, 30, 0, 3],
|
||||
['2019 - 2020', 28, 34, 7, 1, 1],
|
||||
['2018 - 2019', 40, 45, 0, 0, 0],
|
||||
['2017 - 2018', 41, 47, 0, 0, 0],
|
||||
]
|
||||
cy.checkTableStats(studentTableContents, 'StudentsOfTheStudyProgramme')
|
||||
|
||||
const creditTableContents = [
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 160, 160, 0, 0, 67],
|
||||
['2022 - 2023', 2725, 2720, 0, 5, 337],
|
||||
['2021 - 2022', 4092, 4042, 0, 50, 198],
|
||||
['2020 - 2021', 5420, 5415, 0, 5, 321],
|
||||
['2019 - 2020', 6043, 6043, 0, 0, 101],
|
||||
['2018 - 2019', 4856, 4851, 0, 5, 107],
|
||||
['2017 - 2018', 2350, 2345, 0, 5, 26],
|
||||
]
|
||||
|
||||
cy.checkTableStats(creditTableContents, 'CreditsProducedByTheStudyProgramme')
|
||||
cy.get('[data-cy=YearToggle]').click()
|
||||
})
|
||||
|
||||
it('Basic information graphs render', () => {
|
||||
cy.get('[data-cy=Graph-StudentsOfTheStudyProgramme')
|
||||
.should('contain', 'Started studying')
|
||||
.should('contain', 'Accepted')
|
||||
.should('contain', 'Graduated')
|
||||
.should('contain', 'Transferred away')
|
||||
.should('contain', 'Transferred to')
|
||||
|
||||
cy.get('[data-cy=Graph-CreditsProducedByTheStudyProgramme')
|
||||
.should('contain', 'Degree students')
|
||||
.should('contain', 'Transferred')
|
||||
.should('contain', 5796)
|
||||
.should('contain', 428)
|
||||
|
||||
cy.get('[data-cy=Graph-GraduatedAndThesisWritersOfTheProgramme')
|
||||
.should('contain', 'Graduated students')
|
||||
.should('contain', 'Wrote thesis')
|
||||
.should('contain', 47)
|
||||
.should('contain', 76)
|
||||
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2022')
|
||||
cy.contains('48 graduated').trigger('mouseover')
|
||||
cy.contains('48 students graduated in year 2022')
|
||||
cy.contains('median study time: 44.5 months')
|
||||
cy.contains('22 graduated on time')
|
||||
cy.contains('12 graduated max year overtime')
|
||||
cy.contains('14 graduated over year late')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=Graph-ProgrammesBeforeOrAfter')
|
||||
.should('contain', 'Tietojenkäsittelytieteen maisteriohjelma')
|
||||
.should('contain', 'Datatieteen maisteriohjelma')
|
||||
.should('contain', 'Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
.should('contain', 32)
|
||||
.should('contain', 21)
|
||||
.should('contain', 17)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graduation times of master programmes', () => {
|
||||
it('are split into two graphs', () => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matematiikan ja tilastotieteen maisteriohjelma').click({ force: true })
|
||||
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownMaster]')
|
||||
cy.get('[data-cy=graduation-times-graph-breakdownBachelor]')
|
||||
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get('[data-cy=graduation-times-graphMaster]').within(() => {
|
||||
cy.contains('Master study right')
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2021')
|
||||
cy.contains('2 graduated').trigger('mouseover')
|
||||
cy.contains('2 students graduated in year 2021')
|
||||
cy.contains('median study time: 25 months')
|
||||
cy.contains('1 graduated on time')
|
||||
cy.contains('1 graduated max year overtime')
|
||||
cy.contains('0 graduated over year late')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=graduation-times-graphBachelor]').within(() => {
|
||||
cy.contains('Bachelor + master study right')
|
||||
cy.contains('Graduation year')
|
||||
cy.contains('2023')
|
||||
cy.contains('11 graduated').trigger('mouseover')
|
||||
cy.contains('11 students graduated in year 2023')
|
||||
cy.contains('median study time: 69 months')
|
||||
cy.contains('3 graduated on time')
|
||||
cy.contains('7 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/* Study track overview tests */
|
||||
describe('Study track overview works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Study tracks and class statistics').click()
|
||||
})
|
||||
|
||||
// If the backend breaks for one of the sections, the section header is not rendered and this will fail
|
||||
it('Study tracks and class statistics -tab loads', () => {
|
||||
cy.get('[data-cy=Section-studyTrackOverview]')
|
||||
cy.get('[data-cy=Section-studyTrackProgress]')
|
||||
cy.get('[data-cy=Section-averageGraduationTimesStudyTracks]')
|
||||
})
|
||||
|
||||
it('Students of the study programme are shown correctly', () => {
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 1],
|
||||
['2022 - 2023', 26, 25, 0, 0, 24, 2, 19, 7, 0, 25, 1],
|
||||
['2021 - 2022', 38, 29, 0, 0, 33, 5, 29, 9, 0, 36, 4],
|
||||
['2020 - 2021', 30, 26, 0, 0, 11, 19, 15, 15, 0, 29, 3],
|
||||
['2019 - 2020', 35, 28, 0, 0, 8, 27, 22, 13, 0, 35, 2],
|
||||
['2018 - 2019', 45, 40, 0, 0, 6, 39, 26, 19, 0, 44, 1],
|
||||
['2017 - 2018', 47, 41, 0, 0, 5, 42, 31, 16, 0, 47, 0],
|
||||
['Total', 229, 197, 0, 0, 95, 134, 147, 82, 0, 224, 12],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudyTrackOverview')
|
||||
})
|
||||
|
||||
it('Years in the students table can be expanded and study track data will be shown', () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.get('td i.angle.right.icon').click()
|
||||
})
|
||||
|
||||
const dataForStudyTracks = [
|
||||
['Ekonometria, MAT-EKO', 2, 2, 0, 0, 0, 2, 1, 1, 0, 2, 0],
|
||||
['Matematiikka, MAT-MAT', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
|
||||
['Tietojenkäsittelyteoria, MAT-TIE', 2, 1, 0, 0, 1, 1, 2, 0, 0, 2, 0],
|
||||
['Tilastotiede, MAT-TIL', 4, 4, 0, 0, 0, 4, 1, 3, 0, 4, 0],
|
||||
]
|
||||
|
||||
dataForStudyTracks.forEach((data, index) => {
|
||||
cy.get('tbody tr.regular-row')
|
||||
.eq(index)
|
||||
.within(() => {
|
||||
cy.get('td').each((cell, i) => {
|
||||
cy.wrap(cell).contains(data[i])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.get('td i.angle.down.icon').click()
|
||||
})
|
||||
|
||||
cy.get('tbody tr.regular-row').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('Links to class statistics page work', () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td i.level.up.alternate.icon').click()
|
||||
})
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains('class size 26 students')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with all years combined work', { retries: 2 }, () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.get('td.total-row-cell a').click()
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('class size 227 students')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.get('tbody tr.header-row')
|
||||
.eq(3)
|
||||
.within(() => {
|
||||
cy.get('td i.angle.right.icon').click()
|
||||
})
|
||||
})
|
||||
|
||||
cy.contains('td', 'Matematiikka, MAT-MAT').within(() => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('div.sub.header', 'studytrack MAT-MAT')
|
||||
cy.contains('class size 30 students')
|
||||
cy.contains('10 students out of 30 shown')
|
||||
})
|
||||
|
||||
it('Student progress data is shown correctly', () => {
|
||||
const years = getEmptyYears(true)
|
||||
const tableContents = [
|
||||
// [Year, All, < 30 credits, 30–60 credits, 60–90 credits, 90–120 credits, 120–150 credits, 150–180 credits, ≥ 180 credits]
|
||||
...years.map(year => [year, 0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
['2023 - 2024', 8, 8, 0, 0, 0, 0, 0, 0],
|
||||
['2022 - 2023', 26, 9, 9, 4, 3, 0, 1, 0],
|
||||
['2021 - 2022', 38, 8, 6, 11, 8, 5, 0, 0],
|
||||
['2020 - 2021', 30, 2, 1, 3, 7, 4, 4, 9],
|
||||
['2019 - 2020', 35, 1, 0, 2, 1, 0, 4, 27],
|
||||
['2018 - 2019', 45, 0, 1, 1, 2, 0, 3, 38],
|
||||
['2017 - 2018', 47, 0, 1, 3, 0, 1, 2, 40],
|
||||
['Total', 229, 28, 18, 24, 21, 10, 14, 114],
|
||||
]
|
||||
|
||||
cy.checkTableStats(tableContents, 'StudyTrackProgress')
|
||||
})
|
||||
|
||||
it('Study track overview graphs render', () => {
|
||||
cy.get('[data-cy=Graph-StudyTrackProgress]')
|
||||
.should('contain', 'Less than 30 credits')
|
||||
.should('contain', '30–60 credits')
|
||||
.should('contain', 'At least 180 credits')
|
||||
.should('contain', '49.8%') // The percentage for total, at least 180 credits, to check that the graph renders
|
||||
|
||||
cy.get('[data-cy=Graph-StudyTrackProgress]').contains('49.8%').trigger('mouseover', { force: true })
|
||||
cy.contains('At least 180 credits: 114')
|
||||
|
||||
cy.get("[data-cy='Section-KH50_001']").within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.get('[aria-label="2020 - 2021, 15. On time."]').trigger('mouseover')
|
||||
cy.contains('On time: 15')
|
||||
cy.contains("Click a bar to view that year's study track level breakdown")
|
||||
cy.get('[aria-label="2019 - 2020, 22. On time."]').click()
|
||||
cy.contains("Click a bar to view that year's study track level breakdown").should('not.exist')
|
||||
cy.contains('Year 2019 - 2020 by start year')
|
||||
cy.get('[aria-label="MAT-MAT, 14. On time."]').trigger('mouseover')
|
||||
cy.contains('Matematiikka')
|
||||
cy.contains('MAT-MAT')
|
||||
cy.contains('On time: 14')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
|
||||
cy.get("[data-cy='Section-KH50_001']").within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('19 graduated').trigger('mouseover')
|
||||
cy.contains('From class of 2020 - 2021, 19/30 students have graduated')
|
||||
cy.contains('median study time: 33 months')
|
||||
cy.contains('15 graduated on time')
|
||||
cy.contains('3 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Study track can be changed', () => {
|
||||
beforeEach(() => {
|
||||
cy.get('.studytrack-selector').contains('All students of the programme, KH50_001').click()
|
||||
cy.get('.studytrack-selector .visible.menu').contains('Matematiikka, MAT-MAT').click()
|
||||
})
|
||||
|
||||
it('Students of the study track are shown correctly', () => {
|
||||
cy.get("[data-cy='Section-studyTrackOverview']").contains(
|
||||
'Students of the study track MAT-MAT by starting year'
|
||||
)
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2022 - 2023', 3, 3, 0, 0, 1, 2, 2, 1, 0, 3, 0],
|
||||
['2021 - 2022', 5, 1, 0, 0, 1, 4, 3, 2, 0, 5, 0],
|
||||
['2020 - 2021', 13, 10, 0, 0, 1, 12, 7, 6, 0, 12, 3],
|
||||
['2019 - 2020', 17, 14, 0, 0, 0, 17, 10, 7, 0, 17, 1],
|
||||
['2018 - 2019', 23, 21, 0, 0, 2, 21, 11, 12, 0, 23, 0],
|
||||
['2017 - 2018', 28, 24, 0, 0, 1, 27, 15, 13, 0, 28, 0],
|
||||
['Total', 89, 73, 0, 0, 6, 83, 48, 41, 0, 88, 4],
|
||||
]
|
||||
cy.checkTableStats(tableContents, 'StudyTrackOverview')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
cy.get('[data-cy=Table-StudyTrackOverview]').within(() => {
|
||||
cy.get('tbody tr.regular-row')
|
||||
.eq(2)
|
||||
.within(() => {
|
||||
cy.get('a').click()
|
||||
})
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('div.sub.header', 'studytrack MAT-MAT')
|
||||
cy.contains('class size 30 students')
|
||||
cy.contains('10 students out of 30 shown')
|
||||
})
|
||||
|
||||
it('Info message about missing progress stats is displayed', () => {
|
||||
cy.contains('.divider', 'Progress of students of the study track MAT-MAT by starting year')
|
||||
cy.contains(
|
||||
'.message',
|
||||
'Currently progress data is only available for all students of the study programme. Please select ”All students of the programme” to view the progress data.'
|
||||
)
|
||||
})
|
||||
|
||||
it('Average graduation times are displayed correctly', () => {
|
||||
cy.get("[data-cy='Section-averageGraduationTimesStudyTracks']")
|
||||
cy.get("[data-cy='graduation-times-graph-breakdownBachelor']").within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.get('[aria-label="2020 - 2021, 9. On time."]').trigger('mouseover')
|
||||
cy.contains('Graduated On time: 9 students')
|
||||
})
|
||||
|
||||
cy.get('[data-cy=GraduationTimeToggle]').click()
|
||||
cy.get("[data-cy='graduation-times-graphBachelor']").within(() => {
|
||||
cy.contains('Start year')
|
||||
cy.contains('2020 - 2021')
|
||||
cy.contains('12 graduated').trigger('mouseover')
|
||||
cy.contains('From class of 2020 - 2021, 12/13 students have graduated')
|
||||
cy.contains('median study time: 34 months')
|
||||
cy.contains('9 graduated on time')
|
||||
cy.contains('2 graduated max year overtime')
|
||||
cy.contains('1 graduated over year late')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/* Programme courses -tests */
|
||||
describe('Programme courses works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Programme courses').click()
|
||||
})
|
||||
|
||||
it('content loads', () => {
|
||||
cy.get('[data-cy=CoursesYearFilter]')
|
||||
cy.get('[data-cy=CourseTabs]')
|
||||
})
|
||||
|
||||
it('time range selection works', () => {
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
cy.get('td').eq(2).contains('75')
|
||||
})
|
||||
})
|
||||
cy.get('[data-cy=fromYear]').click().contains('2018').click()
|
||||
cy.get('[data-cy=toYear').click().contains('2019').click()
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
cy.get('td').eq(2).contains('15')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("'Calendar year/Academic year' toggle works", () => {
|
||||
cy.get('[data-cy=calendarAcademicYearToggle]').click()
|
||||
cy.get('[data-cy=fromYear]').contains('2017-2018')
|
||||
cy.get('[data-cy=toYear').contains('2023-2024')
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
cy.get('td').eq(2).contains('75')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('contains correct courses in alphabetical order', () => {
|
||||
cy.get('[data-cy=CoursesSortableTable]').within(() => {
|
||||
cy.get('tr').eq(1).contains('Advanced calculus')
|
||||
cy.get('tr').eq(-1).contains('Äidinkielen opinnot')
|
||||
})
|
||||
})
|
||||
|
||||
it('different sorting options work', () => {
|
||||
cy.get('[data-cy=CoursesSortableTable]').within(() => {
|
||||
// Course code
|
||||
cy.get('th').eq(0).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('odgi-zef0')
|
||||
cy.get('td').eq(1).contains('Työ- ja organisaatiopsykologian perusopinnot')
|
||||
})
|
||||
cy.get('th').eq(0).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT-yht')
|
||||
cy.get('td').eq(1).contains('Muut opinnot')
|
||||
})
|
||||
|
||||
// Course name
|
||||
cy.get('th').eq(1).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT20003')
|
||||
cy.get('td').eq(1).contains('Äidinkielen opinnot')
|
||||
})
|
||||
cy.get('th').eq(1).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
})
|
||||
|
||||
// Total credits
|
||||
cy.get('th').eq(2).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT110')
|
||||
cy.get('td').eq(1).contains('Matematiikka, perusopinnot')
|
||||
})
|
||||
cy.get('th').eq(2).click()
|
||||
cy.get('tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT20001')
|
||||
cy.get('td').eq(1).contains('Kypsyysnäyte')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("'Show credits/Show students' toggle works", () => {
|
||||
cy.get('[data-cy=CoursesSortableTable] thead tr').within(() => {
|
||||
cy.get('th').should('have.length', 8)
|
||||
const headers = [
|
||||
'Code',
|
||||
'Name',
|
||||
'Total credits',
|
||||
'Major credits',
|
||||
'Non-major credits',
|
||||
'Non-degree credits',
|
||||
'Transferred credits',
|
||||
'Type',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
cy.get('td').eq(2).contains('75')
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy=creditsStudentsToggle]').click()
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] thead tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 7)
|
||||
const headers = [
|
||||
'Code',
|
||||
'Name',
|
||||
'Total',
|
||||
'Breakdown of total',
|
||||
'Breakdown of passed students',
|
||||
'Not included in total nor passed',
|
||||
'Type',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] thead tr')
|
||||
.eq(1)
|
||||
.within(() => {
|
||||
cy.get('th').should('have.length', 6)
|
||||
const headers = [
|
||||
'Passed',
|
||||
'Not completed',
|
||||
'Major students',
|
||||
'Non-major students',
|
||||
'Non-degree students',
|
||||
'Transferred students',
|
||||
]
|
||||
headers.forEach((header, index) => {
|
||||
cy.get('th').eq(index).contains(header)
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('[data-cy=CoursesSortableTable] tbody').within(() => {
|
||||
cy.get('tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
cy.get('td').eq(0).contains('MAT11008')
|
||||
cy.get('td').eq(1).contains('Advanced calculus')
|
||||
cy.get('td').eq(2).contains('23')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Degree courses works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.get('.attached').contains('Degree courses').click()
|
||||
})
|
||||
|
||||
it('content loads', () => {
|
||||
cy.contains('h3', 'Select curriculum to edit:')
|
||||
cy.get('[data-cy=curriculum-picker]').contains('2023–2026')
|
||||
cy.contains('Change visibility of degree courses and select criteria for academic years')
|
||||
cy.contains('form', 'First year (12 months) last set: 0')
|
||||
cy.get('table').within(() => {
|
||||
cy.contains('Muut opinnot')
|
||||
cy.contains('Matematiikka, perusopinnot')
|
||||
cy.contains('div.green.label', 'visible')
|
||||
cy.contains('button', 'Set hidden')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/* Tag-tests */
|
||||
describe('Tags view works for basic user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
cy.get('.attached').contains('Tags').click()
|
||||
})
|
||||
|
||||
it('can create and delete tags for population', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2022')
|
||||
cy.contains('Create a new tag').click()
|
||||
cy.contains(name)
|
||||
cy.contains('2022')
|
||||
deleteTag(name)
|
||||
})
|
||||
|
||||
it('can create personal tags', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2022')
|
||||
|
||||
cy.get('.ui > label').click()
|
||||
cy.contains('Create a new tag').click()
|
||||
cy.contains(name)
|
||||
deleteTag(name)
|
||||
})
|
||||
|
||||
describe('Adding tags to students and removing them works', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
const studentInput = '477806,478275; 478953 479239\n 480080'
|
||||
const studentNumbers = studentInput.match(/[^\s,;]+/g)
|
||||
|
||||
it('can add tags to students', () => {
|
||||
cy.get('.tagNameSelectInput > .ui > input').type(name)
|
||||
cy.get('.yearSelectInput').type('2022')
|
||||
cy.contains('Create a new tag').click()
|
||||
cy.contains(name)
|
||||
|
||||
cy.contains('Add a tag to students').click()
|
||||
cy.get('.form > .field > .dropdown').click().get('.ui > input.search').type(name).click()
|
||||
|
||||
cy.get('.form > .field > .dropdown > .visible').contains(name).click()
|
||||
|
||||
cy.get('textarea').type(studentInput)
|
||||
cy.contains('Add tags').click()
|
||||
|
||||
cy.contains('Successfully added tags to students.')
|
||||
|
||||
cy.contains('td', name).get('i.level.up.alternate.icon').click()
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains(`Tagged with: ${name}`)
|
||||
cy.contains('Students (5)')
|
||||
.parent()
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Students (5)').click()
|
||||
})
|
||||
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.contains(studentNumber)
|
||||
}
|
||||
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags')
|
||||
cy.contains(name)
|
||||
}
|
||||
|
||||
cy.get('a').contains('Matemaattisten tieteiden kandiohjelma').invoke('removeAttr', 'target').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?p_tab=4')
|
||||
|
||||
deleteTag(name)
|
||||
})
|
||||
|
||||
it('deleting a tag from tag view also removes it from students', () => {
|
||||
cy.contains(name).should('not.exist')
|
||||
for (const studentNumber of studentNumbers) {
|
||||
cy.visit(`/students/${studentNumber}`)
|
||||
cy.contains('Tags').should('not.exist')
|
||||
cy.contains(name).should('not.exist')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('IAM user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme', 'onlyiamrights')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
})
|
||||
|
||||
it('can access programme and correct tabs are visible', () => {
|
||||
cy.contains('Basic information')
|
||||
cy.contains('Study tracks and class statistics')
|
||||
|
||||
cy.contains('Update statistics').should('not.exist')
|
||||
cy.contains('Degree courses').should('not.exist')
|
||||
})
|
||||
|
||||
it('can access basic information', () => {
|
||||
cy.contains('Basic information').click()
|
||||
|
||||
cy.get('[data-cy=Section-studentsOfTheStudyProgramme]')
|
||||
cy.get('[data-cy=Section-creditsProducedByTheStudyProgramme]')
|
||||
cy.get('[data-cy=Section-graduatedAndThesisWritersOfTheProgramme]')
|
||||
cy.get('[data-cy=Section-programmesBeforeOrAfter]')
|
||||
cy.get('[data-cy=Section-averageGraduationTimes]')
|
||||
})
|
||||
|
||||
it('can access study tracks', () => {
|
||||
cy.get('.attached').contains('Study tracks and class statistics').click()
|
||||
|
||||
cy.get('[data-cy=Section-studyTrackOverview]')
|
||||
cy.get('[data-cy=Section-studyTrackProgress]')
|
||||
cy.get('[data-cy=Section-averageGraduationTimesStudyTracks]')
|
||||
})
|
||||
|
||||
it("doesn't see other tabs", () => {
|
||||
cy.get('div.ui.tabular.menu a').should('have.length', 2)
|
||||
})
|
||||
})
|
||||
})
|
||||
+19
-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(`${level}-faculty-progress-table`))
|
||||
}
|
||||
|
||||
const graduationTimesLevels = ['bachelor', 'master', 'doctor'] // ? Bachelor + master seems to be missing in test data
|
||||
|
||||
const checkAverageGraduationTimesBreakdownBarCharts = () => {
|
||||
graduationTimesLevels.forEach(level => {
|
||||
cy.get(`[data-cy=${level}BreakdownBarChart]`)
|
||||
cy.cs(`${level}-breakdown-bar-chart`)
|
||||
})
|
||||
}
|
||||
|
||||
const checkAverageGraduationTimesMedianBarCharts = () => {
|
||||
graduationTimesLevels.forEach(level => {
|
||||
cy.get(`[data-cy=${level}MedianBarChart]`)
|
||||
cy.cs(`${level}-median-bar-chart`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,20 +40,21 @@ 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()
|
||||
// Nothing in this test is explained. Non of these values are verified.
|
||||
it.skip('years in the tables can be clicked to show faculty level breakdown', () => {
|
||||
cy.cs('study-right-toggle').click()
|
||||
cy.cs('bachelors-faculty-progress-table-show-button3').click()
|
||||
cy.contains('29.5%').trigger('mouseover', { force: true })
|
||||
cy.contains('Matemaattis-luonnontieteellinen tiedekunta')
|
||||
cy.contains('H50')
|
||||
@@ -66,16 +67,16 @@ describe('University view', () => {
|
||||
})
|
||||
|
||||
it('info boxes contain correct information', () => {
|
||||
cy.get('[data-cy=FacultyProgressInfoBoxButton]').click()
|
||||
cy.get('[data-cy=FacultyProgressInfoBoxContent]').contains('Kuvaa tiedekuntaan kuuluvien')
|
||||
cy.get('[data-cy=FacultyBachelorMastersProgressInfoBoxButton]').click()
|
||||
cy.get('[data-cy=FacultyBachelorMastersProgressInfoBoxContent]').contains('The starting year is the')
|
||||
cy.cs('faculty-progress-info-box-button').click()
|
||||
cy.cs('faculty-progress-info-box-content').contains('Kuvaa tiedekuntaan kuuluvien')
|
||||
cy.cs('faculty-bachelor-masters-progress-info-box-button').click()
|
||||
cy.cs('faculty-bachelor-masters-progress-info-box-content').contains('The starting year is the')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Faculty graduations tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.get('[data-cy=FacultyGraduationsTab]').click()
|
||||
cy.cs('faculty-graduations-tab').click()
|
||||
})
|
||||
|
||||
it('has all the correct median time bar charts', () => {
|
||||
@@ -83,13 +84,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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+17
-12
@@ -9,7 +9,7 @@ visibleLinks.basic = [...visibleLinks.onlycoursestatistics, 'Faculties', 'Progra
|
||||
visibleLinks.admin = [...visibleLinks.basic, 'Teachers', 'Admin']
|
||||
|
||||
const containsLinks = links => {
|
||||
cy.get('[data-cy=nav-bar]').within(() => {
|
||||
cy.cs('nav-bar').within(() => {
|
||||
for (const link of links) {
|
||||
cy.contains(link)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const containsLinks = links => {
|
||||
}
|
||||
|
||||
const userButtonWorks = (username, mocking = false) => {
|
||||
cy.get('[data-cy=nav-bar-user-button]').click()
|
||||
cy.cs('nav-bar-user-button').click()
|
||||
cy.contains(mocking ? `Mocking as ${username}` : `Logged in as ${username}`)
|
||||
cy.contains('Language')
|
||||
cy.contains('suomi')
|
||||
@@ -61,10 +61,10 @@ describe('Users tests', () => {
|
||||
userButtonWorks('admin')
|
||||
})
|
||||
|
||||
describe('can mock as other users', () => {
|
||||
describe('can mock as other users', { retries: 3 }, () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('user-edit-button-basic').click()
|
||||
cy.get('i.spy').click()
|
||||
cy.cs('user-page-button-basic').click()
|
||||
cy.cs('mock-button').click()
|
||||
})
|
||||
|
||||
it('user button shows mocked user', () => {
|
||||
@@ -72,14 +72,19 @@ describe('Users tests', () => {
|
||||
})
|
||||
|
||||
it("only the mocked user's programmes are visible", () => {
|
||||
cy.intercept('/api/v3/populationstatistics/studyprogrammes').as('studyprogrammes')
|
||||
cy.visit('/populations')
|
||||
cy.contains('label', 'Study programme')
|
||||
.siblings()
|
||||
.within(() => {
|
||||
cy.get("div[role='option']").should('have.length', 2)
|
||||
cy.get("div[role='option']").eq(0).contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.get("div[role='option']").eq(1).contains('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
})
|
||||
cy.wait('@studyprogrammes').its('response.statusCode').should('be.oneOf', [200, 304])
|
||||
|
||||
cy.contains('h6', 'Degree programme')
|
||||
cy.cs('population-programme-selector').within(() =>
|
||||
cy.get('input').should('have.attr', 'placeholder', 'Select degree programme')
|
||||
)
|
||||
cy.cs('population-programme-selector-parent').click()
|
||||
cy.cs('population-programme-selector-parent').within(() => cy.contains('Matemaattisten tieteiden kandiohjelma'))
|
||||
cy.cs('population-programme-selector-parent').within(() =>
|
||||
cy.contains('Matematiikan ja tilastotieteen maisteriohjelma')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
/**
|
||||
* @type {Cypress.PluginConfig}
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
module.exports = (on, config) => {
|
||||
// `on` is used to hook into various events Cypress emits
|
||||
// `config` is the resolved Cypress config
|
||||
|
||||
+60
-44
@@ -79,16 +79,19 @@ const userHeaders = [
|
||||
]
|
||||
|
||||
/**
|
||||
Set up headers to login, set up correct user (admin / basic / etc.) and open given path.
|
||||
* Set up headers to login, set up correct user (admin / basic / etc.) and open given path.
|
||||
*/
|
||||
Cypress.Commands.add('init', (path = '', userId = 'basic') => {
|
||||
cy.intercept('', req => {
|
||||
const headersToUse = userHeaders.find(({ uid }) => uid === userId)
|
||||
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
|
||||
Cypress.Commands.add('init', { prevSubject: false }, (path, userId = 'basic') => {
|
||||
const headersToUse = userHeaders.find(({ uid }) => uid === userId)
|
||||
if (!headersToUse) throw Error(`${userId} is not valid user id!`)
|
||||
|
||||
cy.intercept({ hostname: 'localhost', url: '**', method: '*' }, req => {
|
||||
req.headers = headersToUse
|
||||
})
|
||||
const { baseUrl } = Cypress.config()
|
||||
cy.visit(baseUrl.concat(path))
|
||||
|
||||
// TODO: Add wait for visit url.
|
||||
// This is block by course population query.
|
||||
cy.visit({ method: 'GET', url: path, headers: headersToUse })
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -104,31 +107,63 @@ Cypress.Commands.add('cs', { prevSubject: 'optional' }, (subject, name) => {
|
||||
})
|
||||
|
||||
/**
|
||||
* Select item specified by `index` number from a semantic-ui dropdown with
|
||||
* `data-cy` attribute value `name`.
|
||||
* Select item from FilterSelect dropdown.
|
||||
* If the index is a number, select index-th item.
|
||||
* Otherwise select the item containing index text.
|
||||
*/
|
||||
Cypress.Commands.add('selectFromDropdown', { prevSubject: true }, (s, index) => {
|
||||
const subject = cy.wrap(s)
|
||||
const indexes = Array.isArray(index) ? index : [index]
|
||||
Cypress.Commands.add('selectFromDropdown', { prevSubject: false }, (label, index, isMultiSelect = false) => {
|
||||
cy.cs(`${label}-selector`).click()
|
||||
|
||||
indexes.forEach(i => {
|
||||
subject.click()
|
||||
|
||||
if (typeof i === 'number') {
|
||||
subject.children('.menu').children().eq(i).click({ force: true })
|
||||
} else {
|
||||
subject.children('.menu').containing(i).eq(0).click({ force: true })
|
||||
}
|
||||
cy.get(`[role="listbox"][aria-labelledby="${label}"]`).within(() => {
|
||||
if (typeof index === 'number') cy.get('li').eq(index).click()
|
||||
else cy.get('li').contains(index).click()
|
||||
})
|
||||
|
||||
// Close multiple selection so it does not block elements underneath it.
|
||||
if (Array.isArray(index)) {
|
||||
cy.wrap(s).children('.icon').click({ force: true })
|
||||
}
|
||||
if (isMultiSelect) cy.get('#menu- > .MuiModal-backdrop').click()
|
||||
})
|
||||
|
||||
/**
|
||||
* Set range selector values FilterRange.
|
||||
* If the reset flag is set, the function tries to reset the original values.
|
||||
*/
|
||||
Cypress.Commands.add('setRangeSelect', { prevSubject: false }, (filter, min, max, expected, reset) => {
|
||||
const minText = min.toString()
|
||||
const maxText = max.toString()
|
||||
const expectedText = expected.toString()
|
||||
|
||||
const parent = `${filter}-filter-card`
|
||||
|
||||
cy.cs(parent)
|
||||
.cs('FilterRangeStart')
|
||||
.find('input')
|
||||
.invoke('val')
|
||||
.then(initialMin => {
|
||||
cy.cs(parent)
|
||||
.cs('FilterRangeEnd')
|
||||
.find('input')
|
||||
.invoke('val')
|
||||
.then(initialMax => {
|
||||
cy.cs(parent).cs('FilterRangeStart').find('input').clear()
|
||||
cy.cs(parent).cs('FilterRangeStart').find('input').type(minText)
|
||||
cy.cs(parent).cs('FilterRangeEnd').find('input').clear()
|
||||
cy.cs(parent).cs('FilterRangeEnd').find('input').type(maxText)
|
||||
|
||||
// This should be linked to other checking functions.
|
||||
// Maybe one day.
|
||||
cy.contains(`Students (${expectedText})`)
|
||||
|
||||
if (reset) {
|
||||
cy.cs(parent).cs('FilterRangeStart').find('input').clear()
|
||||
cy.cs(parent).cs('FilterRangeStart').find('input').type(initialMin)
|
||||
cy.cs(parent).cs('FilterRangeEnd').find('input').clear()
|
||||
cy.cs(parent).cs('FilterRangeEnd').find('input')?.type(initialMax)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
@@ -142,22 +177,3 @@ Cypress.Commands.add('checkTableStats', (correctStats, tableName) => {
|
||||
cy.get('tr').filter(':visible').should('have.length', correctStats.length)
|
||||
})
|
||||
})
|
||||
|
||||
const getEmptyYears = isAcademicYear => {
|
||||
const today = new Date()
|
||||
const latestYear = isAcademicYear && today.getMonth() < 7 ? today.getFullYear() - 1 : today.getFullYear()
|
||||
|
||||
const years = []
|
||||
for (let year = latestYear; year >= 2024; year--) {
|
||||
if (isAcademicYear) {
|
||||
years.push(`${year} - ${year + 1}`)
|
||||
} else {
|
||||
years.push(year)
|
||||
}
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getEmptyYears,
|
||||
}
|
||||
|
||||
@@ -13,11 +13,9 @@
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
require('@cypress/code-coverage/support')
|
||||
require('./commands')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
Cypress.on('uncaught:exception', error => {
|
||||
/* returning false here prevents Cypress from failing the test */
|
||||
return true
|
||||
|
||||
+2
-16
@@ -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
|
||||
|
||||
+11
-19
@@ -49,9 +49,10 @@ services:
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./environments/ha:/opt/app-root/src/src/environment
|
||||
- ./services/backend/src:/opt/app-root/src/src
|
||||
- ./services/shared/:/opt/app-root/src/src/shared
|
||||
- backend-nodemod:/opt/app-root/backend/node_modules
|
||||
- ./environments/testu:/opt/app-root/backend/src/environment
|
||||
- ./services/backend/:/opt/app-root/backend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
|
||||
frontend:
|
||||
<<: *defaults
|
||||
@@ -66,8 +67,9 @@ services:
|
||||
REACT_APP_LANGUAGE_CENTER_VIEW_ENABLED: 'false'
|
||||
REACT_APP_SIS_URL: 'https://sis-qa.funidata.fi'
|
||||
volumes:
|
||||
- ./services/frontend/src/:/opt/app-root/src/src
|
||||
- ./services/shared/:/opt/app-root/src/src/shared
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
- ./services/frontend/:/opt/app-root/frontend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
@@ -106,12 +108,6 @@ services:
|
||||
ports:
|
||||
- 12345:5432
|
||||
|
||||
sis-updater-nats:
|
||||
<<: *defaults
|
||||
command: '-cid sis-updater-nats -m 8222 -p 4222 --max_channels 1000 --max_age 2h --hb_fail_count 2 --hb_timeout 10s --hb_interval 10s --auth dev --file_slice_max_bytes 0 --file_slice_max_age 60s --max_msgs 0 -store file -dir datastore'
|
||||
container_name: sis-updater-nats
|
||||
image: nats-streaming:0.25.6
|
||||
|
||||
sis-updater-scheduler:
|
||||
<<: *defaults
|
||||
build:
|
||||
@@ -122,17 +118,14 @@ services:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-updater-nats
|
||||
environment:
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SECRET_TOKEN: dev
|
||||
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
|
||||
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
|
||||
SIS_IMPORTER_DATABASE: importer-db
|
||||
SIS_IMPORTER_PASSWORD: dev
|
||||
SIS_IMPORTER_USER: dev
|
||||
SIS_NATS_TOKEN: dev
|
||||
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
|
||||
SERVICE_PROVIDER: fd
|
||||
ports:
|
||||
- 8082:8082
|
||||
@@ -150,20 +143,17 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- sis-db
|
||||
- sis-updater-nats
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
environment:
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_URI: //redis:6379
|
||||
REDIS_HOST: redis
|
||||
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
|
||||
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
|
||||
SIS_IMPORTER_DATABASE: importer-db
|
||||
SIS_IMPORTER_PASSWORD: dev
|
||||
SIS_IMPORTER_USER: dev
|
||||
SIS_PASSWORD: postgres
|
||||
SIS_NATS_TOKEN: dev
|
||||
SIS_NATS_URI: sis-updater-nats://sis-updater-nats:4222
|
||||
ROOT_ORG_ID: ${ROOT_ORG_ID}
|
||||
SERVICE_PROVIDER: fd
|
||||
volumes:
|
||||
@@ -183,6 +173,8 @@ services:
|
||||
- 12347:5432
|
||||
|
||||
volumes:
|
||||
backend-nodemod:
|
||||
frontend-nodemod:
|
||||
kone-db-data:
|
||||
redis-data:
|
||||
sis-db-data:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
services:
|
||||
frontend:
|
||||
volumes:
|
||||
- frontend-nodemod:/opt/app-root/src/node_modules
|
||||
- ./services/frontend/instrumented:/opt/app-root/src/src
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
- ./services/frontend/instrumented:/opt/app-root/frontend/src
|
||||
|
||||
+8
-22
@@ -44,9 +44,9 @@ services:
|
||||
profiles:
|
||||
- oodikone
|
||||
volumes:
|
||||
- backend-nodemod:/opt/app-root/src/node_modules
|
||||
- ./services/backend/:/opt/app-root/src
|
||||
- ./services/shared/:/opt/app-root/src/src/shared
|
||||
- backend-nodemod:/opt/app-root/backend/node_modules
|
||||
- ./services/backend/:/opt/app-root/backend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
|
||||
frontend:
|
||||
<<: *defaults
|
||||
@@ -59,9 +59,9 @@ services:
|
||||
profiles:
|
||||
- oodikone
|
||||
volumes:
|
||||
- frontend-nodemod:/opt/app-root/src/node_modules
|
||||
- ./services/frontend/:/opt/app-root/src
|
||||
- ./services/shared/:/opt/app-root/src/src/shared
|
||||
- frontend-nodemod:/opt/app-root/frontend/node_modules
|
||||
- ./services/frontend/:/opt/app-root/frontend
|
||||
- ./services/shared/:/opt/app-root/shared
|
||||
|
||||
kone-db:
|
||||
<<: *defaults
|
||||
@@ -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:
|
||||
|
||||
@@ -50,6 +50,17 @@ The steps to **add, remove, or modify columns** are mostly the same for each dat
|
||||
|
||||
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.
|
||||
@@ -113,5 +124,4 @@ The steps to **add, remove, or modify columns** are mostly the same for each dat
|
||||
|
||||
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 migration.
|
||||
- Optionally, use `db-cli` to check the production database state (see `how_to_db_cli.md` in GitLab).
|
||||
- **Create a new release**; then check the production logs (in OpenShift) to confirm the migrationor use `db-cli`.
|
||||
|
||||
@@ -44,6 +44,7 @@ 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)
|
||||
|
||||
|
||||
+208
-136
@@ -1,184 +1,256 @@
|
||||
import pluginJs from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import pluginCypress from 'eslint-plugin-cypress/flat'
|
||||
import pluginImport from 'eslint-plugin-import'
|
||||
import pluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import pluginReact from 'eslint-plugin-react'
|
||||
import pluginReactHooks from 'eslint-plugin-react-hooks'
|
||||
import tsEslint from 'typescript-eslint'
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
import eslint from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
import globals from 'globals'
|
||||
import eslintPluginCypress from 'eslint-plugin-cypress'
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import eslintPluginReact from 'eslint-plugin-react'
|
||||
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
|
||||
import eslintPluginImportX from 'eslint-plugin-import-x'
|
||||
import { createNodeResolver } from 'eslint-plugin-import-x'
|
||||
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
files: ['**/*.{js,ts,jsx,tsx}'],
|
||||
},
|
||||
{
|
||||
ignores: ['eslint.config.mjs', '**/build/', '**/dist/', '**/node_modules/'],
|
||||
// Global ignores
|
||||
ignores: [
|
||||
'eslint.config.mjs',
|
||||
'**/build/**',
|
||||
'**/dist/**',
|
||||
'**/node_modules/**',
|
||||
'**/instrumented/**',
|
||||
'**/coverage/**',
|
||||
'nyc-config.cjs',
|
||||
'.lintstagedrc.mjs',
|
||||
'cypress.config.js',
|
||||
]
|
||||
},
|
||||
|
||||
// Global options (excl. e2e)
|
||||
{
|
||||
ignores: ['cypress/**/*.js'],
|
||||
extends: [
|
||||
eslint.configs.recommended,
|
||||
eslintPluginImportX.flatConfigs.recommended,
|
||||
eslintPluginImportX.flatConfigs.typescript,
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2021,
|
||||
sourceType: 'module',
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: ['*.{js,cjs}']
|
||||
}
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
'import-x': eslintPluginImportX
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: [
|
||||
'services/frontend/tsconfig.json',
|
||||
'services/frontend/tsconfig.node.json',
|
||||
'services/backend/tsconfig.json',
|
||||
'services/shared/tsconfig.json',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
...tsEslint.configs.recommendedTypeChecked,
|
||||
...tsEslint.configs.stylisticTypeChecked,
|
||||
pluginCypress.configs.recommended,
|
||||
pluginReact.configs.flat.recommended,
|
||||
pluginReact.configs.flat['jsx-runtime'],
|
||||
pluginImport.flatConfigs.recommended,
|
||||
pluginImport.flatConfigs.typescript,
|
||||
pluginPrettierRecommended, // This must be the last plugin so it can override other configs
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': pluginReactHooks,
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
camelcase: 'off', // TODO: Enable this rule (at least for frontend)
|
||||
'class-methods-use-this': 'error',
|
||||
'consistent-return': 'error',
|
||||
'camelcase': 'off', // TODO: Enable. old db/backend logic is still using snake_case every now and then and needs a big refactor
|
||||
'class-methods-use-this': 'off', // Disabled in favor of @typescript-eslint
|
||||
'consistent-return': 'off', // Disabled in favor of @typescript-eslint
|
||||
'id-denylist': ['error', 'c', 'd', 'e', 'err', 't'],
|
||||
'import/no-commonjs': 'error',
|
||||
'import/no-default-export': 'error',
|
||||
'import/no-extraneous-dependencies': ['error', { devDependencies: false }],
|
||||
'import/no-unused-modules': ['error', { missingExports: true, unusedExports: true }],
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
|
||||
},
|
||||
],
|
||||
'no-alert': 'error',
|
||||
'no-async-promise-executor': 'error',
|
||||
'no-await-in-loop': 'error',
|
||||
'no-console': 'error',
|
||||
'no-implied-eval': 'error',
|
||||
'no-param-reassign': ['error', { props: false }],
|
||||
'no-implied-eval': 'off', // Disabled in favor of @typescript-eslint
|
||||
'no-param-reassign': 'error',
|
||||
'no-promise-executor-return': 'error',
|
||||
'no-return-assign': 'error',
|
||||
'no-return-await': 'error',
|
||||
'no-unused-vars': 'off', // Disabled in favor of @typescript-eslint
|
||||
'object-shorthand': ['error', 'always'],
|
||||
'prefer-const': 'error',
|
||||
'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
|
||||
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
|
||||
'react/display-name': 'off', // TODO: Delete this override
|
||||
'react/function-component-definition': [
|
||||
'error',
|
||||
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
|
||||
'prefer-destructuring': 'off', // Disabled in favor of @typescript-eslint
|
||||
'dot-notation': 'off', // Disabled in favor of @typescript-eslint
|
||||
'no-restricted-imports': ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
|
||||
'quotes': ['error', 'single',
|
||||
{ avoidEscape: true, allowTemplateLiterals: false }
|
||||
],
|
||||
'react/jsx-boolean-value': ['error', 'never'],
|
||||
'react/jsx-filename-extension': ['error', { allow: 'as-needed', extensions: ['.jsx', '.tsx'] }],
|
||||
'react/jsx-sort-props': ['error', { reservedFirst: false }],
|
||||
'react/no-array-index-key': 'error',
|
||||
'react/no-this-in-sfc': 'error',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/no-unknown-property': 'off', // TODO: Delete this override
|
||||
'react/prefer-stateless-function': 'error',
|
||||
'react/prop-types': 'off',
|
||||
...pluginReactHooks.configs.recommended.rules,
|
||||
'react-hooks/exhaustive-deps': 'warn', // TODO: Delete this override
|
||||
'@typescript-eslint/array-type': 'off',
|
||||
'@typescript-eslint/consistent-indexed-object-style': 'off',
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-misused-promises': 'off', // Most of these errors come from Express route handlers that are handled correctly by express-async-errors
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
|
||||
'import-x/no-commonjs': 'error',
|
||||
'import-x/no-default-export': 'error',
|
||||
'import-x/no-extraneous-dependencies': ['error', { devDependencies: false }],
|
||||
'import-x/no-unused-modules': ['error',
|
||||
{
|
||||
missingExports: true,
|
||||
unusedExports: true,
|
||||
src: ['{services,updater}/**/*.{js,jsx,ts,tsx}']
|
||||
}],
|
||||
'import-x/order': ['error', {
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
groups: [['builtin', 'external'], ['internal'], ['parent'], ['sibling', 'index']],
|
||||
}],
|
||||
|
||||
// TypeScript
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', caughtErrors: 'none', ignoreRestSiblings: true, varsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/array-type': 'off',
|
||||
'@typescript-eslint/class-methods-use-this': 'error',
|
||||
// '@typescript-eslint/consistent-indexed-object-style': 'off', // TODO: keep as error/record (default)
|
||||
'@typescript-eslint/consistent-return': 'error', // NOTE: This is weaker than tsconfig.json noImplicitReturns, and should be replaced once ts migration complete
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
'@typescript-eslint/dot-notation': 'error',
|
||||
'@typescript-eslint/prefer-destructuring': ['error',
|
||||
{ VariableDeclarator: { object: true } }
|
||||
],
|
||||
'@typescript-eslint/no-empty-object-type': ['error', { allowInterfaces: 'with-single-extends' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off', // TODO: enable when feeling brave
|
||||
'@typescript-eslint/no-implied-eval': 'error',
|
||||
'@typescript-eslint/no-shadow': 'off', // TODO: enable?
|
||||
'@typescript-eslint/no-unsafe-argument': 'off', // TODO: enable
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off', // TODO: enable
|
||||
'@typescript-eslint/no-unsafe-call': 'off', // TODO: enable
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off', // TODO: enable
|
||||
'@typescript-eslint/no-unsafe-return': 'off', // TODO: enable
|
||||
'@typescript-eslint/restrict-template-expressions': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
rules: {
|
||||
...tsEslint.configs.disableTypeChecked.rules,
|
||||
'dot-notation': 'error',
|
||||
settings: {
|
||||
'import-x/internal-regex': '^@oodikone/shared/',
|
||||
'import-x/resolver-next': [
|
||||
createTypeScriptImportResolver({
|
||||
project: [
|
||||
'services/frontend/tsconfig.json',
|
||||
'services/frontend/tsconfig.node.json',
|
||||
'services/backend/tsconfig.json',
|
||||
'services/shared/tsconfig.json'
|
||||
],
|
||||
}),
|
||||
createNodeResolver({
|
||||
extensions: ['.js', '.jsx', 'cjs', 'mjs', '.ts', '.tsx'],
|
||||
})
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ['services/backend/src/routes/*.{ts,js}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-misused-promises': 'off', // Errors come mainly from Express route handlers that are handled correctly by express-async-errors
|
||||
'@typescript-eslint/consistent-return': 'off',
|
||||
}
|
||||
},
|
||||
|
||||
// Disable type-aware linting for javascript files
|
||||
{
|
||||
files: ['{updater,services}/**/*.{js,cjs}'],
|
||||
extends: [tseslint.configs.disableTypeChecked]
|
||||
},
|
||||
|
||||
// Frontend camelCase and react
|
||||
{
|
||||
files: ['services/frontend/**/*.{js,jsx,mjs,cjs,ts,tsx}'],
|
||||
plugins: {
|
||||
'react': eslintPluginReact,
|
||||
},
|
||||
extends: [
|
||||
eslintPluginReact.configs.flat.recommended,
|
||||
eslintPluginReact.configs.flat['jsx-runtime'],
|
||||
eslintPluginReactHooks.configs['recommended-latest'],
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'camelcase': 'warn',
|
||||
'react/prop-types': 'off', // Typescript but worse
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/function-component-definition': ['error',
|
||||
{ namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' },
|
||||
],
|
||||
'react/no-array-index-key': 'error',
|
||||
'react/no-unstable-nested-components': 'warn', // TODO: turn into error, reduces re-renders
|
||||
'react/no-this-in-sfc': 'error',
|
||||
'react/prefer-stateless-function': 'error',
|
||||
'react/jsx-boolean-value': ['error', 'never'],
|
||||
'react/jsx-filename-extension': ['error',
|
||||
{ allow: 'as-needed', extensions: ['.jsx', '.tsx'] }
|
||||
],
|
||||
'react/jsx-no-leaked-render': 'warn', // TODO: enable, tho not that useful after full ts migration
|
||||
'react/jsx-no-useless-fragment': 'error',
|
||||
'react/jsx-props-no-spreading': 'warn', // TODO: enable, improves readability
|
||||
'react/jsx-sort-props': ['error', { reservedFirst: false }],
|
||||
'react/jsx-tag-spacing': 'warn',
|
||||
},
|
||||
settings: {
|
||||
'react': {
|
||||
'version': 'detect'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Cypress should only use rules provided by the plugin below
|
||||
{
|
||||
files: ['cypress/**/*.js'],
|
||||
extends: [
|
||||
eslintPluginCypress.configs.recommended
|
||||
]
|
||||
},
|
||||
|
||||
// Overrides for CommonJS
|
||||
{
|
||||
files: [
|
||||
'services/backend/**/*.js',
|
||||
'updater/sis-updater-worker/src/db/migrations/*.cjs',
|
||||
'updater/sis-updater-scheduler/**/*.js'
|
||||
],
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
rules: {
|
||||
'import-x/no-commonjs': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Rapodiff
|
||||
{
|
||||
files: ['services/backend/src/rapodiff/**/*'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Migration files
|
||||
{
|
||||
files: ['services/**/shared/**/*'],
|
||||
files: [
|
||||
'updater/sis-updater-worker/src/db/migrations/*.cjs',
|
||||
'services/backend/src/database/**/*.js'
|
||||
],
|
||||
rules: {
|
||||
'import/no-unused-modules': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['cypress/**/*.js', 'services/backend/**/*.js', 'updater/**/*.js'],
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'import/no-commonjs': 'off',
|
||||
'import/no-unused-modules': 'off',
|
||||
'import-x/no-unused-modules': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// TODO: See if these can be disabled and or fix
|
||||
{
|
||||
files: ['services/backend/**/*.{js,ts}', 'updater/**/*.js'],
|
||||
rules: {
|
||||
// TODO: Most of these overrides should probably be removed
|
||||
'consistent-return': 'off',
|
||||
'import/no-default-export': 'off',
|
||||
'import/no-unused-modules': 'off',
|
||||
'import-x/no-default-export': 'off',
|
||||
'import-x/no-unused-modules': 'off',
|
||||
'no-await-in-loop': 'off',
|
||||
'no-param-reassign': 'off',
|
||||
'no-promise-executor-return': 'off',
|
||||
'no-return-await': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
'import/internal-regex': '^@/shared/',
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: [
|
||||
'services/frontend/tsconfig.json',
|
||||
'services/frontend/tsconfig.node.json',
|
||||
'services/backend/tsconfig.json',
|
||||
'services/shared/tsconfig.json',
|
||||
],
|
||||
},
|
||||
},
|
||||
react: {
|
||||
version: '18.3',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
eslintPluginPrettierRecommended, // This must be the last plugin so it can override other configs
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
const { parserPlugins } = require('@istanbuljs/schema').defaults.nyc
|
||||
|
||||
module.exports = {
|
||||
Generated
+3094
-3076
File diff suppressed because it is too large
Load Diff
+24
-22
@@ -23,10 +23,13 @@
|
||||
"testupdater": "docker-compose --file docker-compose.ci.yml up --detach --no-recreate sis-updater-worker && SCHEDULE_IMMEDIATE=weekly EXIT_AFTER_IMMEDIATES=yes docker-compose --file docker-compose.ci.yml run sis-updater-scheduler npm start",
|
||||
"flushredis": "docker exec -it redis redis-cli flushall",
|
||||
"prepare": "husky",
|
||||
"rapodiff": "docker exec -t oodikone_backend_1 /opt/app-root/src/node_modules/.bin/tsx /opt/app-root/src/src/rapodiff/rapodiff.js",
|
||||
"install:local": "npm i && cd services/frontend && npm i && cd ../backend && npm i && cd ../../updater/sis-updater-scheduler && npm i && cd ../sis-updater-worker && npm i && cd ../..",
|
||||
"rapodiff": "docker exec -t oodikone_backend_1 /opt/app-root/backend/node_modules/.bin/tsx /opt/app-root/backend/src/rapodiff/rapodiff.js",
|
||||
"install:local": "npm i && npm i --prefix services/shared && npm i --prefix services/frontend && npm i --prefix services/backend && npm i --prefix updater/sis-updater-scheduler && npm i --prefix updater/sis-updater-worker",
|
||||
"ci:local": "npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker",
|
||||
"install:docker": "docker exec frontend npm i && docker exec oodikone_backend_1 npm i && docker exec sis-updater-scheduler npm i && docker exec oodikone_sis-updater-worker_1 npm i",
|
||||
"install:both": "npm run install:local && npm run install:docker"
|
||||
"ci:docker": "docker exec frontend npm ci && docker exec oodikone_backend_1 npm ci && docker exec sis-updater-scheduler npm ci && docker exec oodikone_sis-updater-worker_1 npm ci",
|
||||
"install:both": "npm run install:local && npm run install:docker",
|
||||
"ci:both": "npm run ci:local && npm run ci:docker"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -38,30 +41,29 @@
|
||||
},
|
||||
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
|
||||
"devDependencies": {
|
||||
"@cypress/code-coverage": "^3.13.11",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@cypress/code-coverage": "^3.14.4",
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"cypress": "^14.0.1",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.7.0",
|
||||
"eslint-plugin-cypress": "^4.1.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"globals": "^15.14.0",
|
||||
"cypress": "^14.4.1",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-import-resolver-typescript": "^4.4.3",
|
||||
"eslint-plugin-cypress": "^5.1.0",
|
||||
"eslint-plugin-import-x": "^4.16.1",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"globals": "^16.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.4.3",
|
||||
"lint-staged": "^16.1.0",
|
||||
"node-actionlint": "^1.2.2",
|
||||
"nyc": "^17.1.0",
|
||||
"prettier": "3.4.2",
|
||||
"stylelint": "^16.14.1",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"typescript-eslint": "^8.22.0"
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "^16.20.0",
|
||||
"stylelint-config-standard": "^39.0.0",
|
||||
"typescript-eslint": "^8.34.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.30.1"
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
## Folders
|
||||
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
||||
DUMP_DIR="$PROJECT_ROOT/.databasedumps"
|
||||
USER_DATA_FILE="$DUMP_DIR/hyuserdata"
|
||||
S3_CONFIG_FILE=~/.s3cfg
|
||||
S3_BUCKET="s3://psyduck"
|
||||
DOCKER_COMPOSE=$PROJECT_ROOT/docker-compose.yml
|
||||
|
||||
## Following the naming convention in docker-compose, these are names for database
|
||||
@@ -18,14 +19,16 @@ KONE_DB_NAME="kone-db"
|
||||
SIS_DB_NAME="sis-db"
|
||||
SIS_IMPORTER_DB_NAME="sis-importer-db"
|
||||
USER_DB_NAME="user-db"
|
||||
DATABASES=("$KONE_DB_NAME" "$SIS_DB_NAME" "$SIS_IMPORTER_DB_NAME" "$USER_DB_NAME")
|
||||
JAMI_DB_NAME="jami-db"
|
||||
DATABASES=("$KONE_DB_NAME" "$SIS_DB_NAME" "$SIS_IMPORTER_DB_NAME" "$USER_DB_NAME" "$JAMI_DB_NAME")
|
||||
|
||||
## Urls should be in same order as databases as both are iterated through by indexes.
|
||||
KONE_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/oodikone-kone-db.sql.gz"
|
||||
SIS_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/oodikone-sis-db.sql.gz"
|
||||
SIS_IMPORTER_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/importer.sql.gz"
|
||||
USER_DB_REAL_DUMP_URL="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/oodikone-user-db.sql.gz"
|
||||
REAL_DUMP_URLS=("$KONE_DB_REAL_DUMP_URL" "$SIS_DB_REAL_DUMP_URL" "$SIS_IMPORTER_DB_REAL_DUMP_URL" "$USER_DB_REAL_DUMP_URL")
|
||||
KONE_DB_S3_PATH="oodikone_kone"
|
||||
SIS_DB_S3_PATH="oodikone_updater"
|
||||
SIS_IMPORTER_DB_S3_PATH="sis_importer"
|
||||
USER_DB_S3_PATH="oodikone_user"
|
||||
JAMI_DB_S3_PATH="jami"
|
||||
S3_PATHS=("$KONE_DB_S3_PATH" "$SIS_DB_S3_PATH" "$SIS_IMPORTER_DB_S3_PATH" "$USER_DB_S3_PATH" "$JAMI_DB_S3_PATH")
|
||||
|
||||
# Source utility functions
|
||||
source "$PROJECT_ROOT"/scripts/utils.sh
|
||||
@@ -55,9 +58,46 @@ retry() {
|
||||
|
||||
download_real_dump() {
|
||||
local database=$1
|
||||
local pannu_url=$2
|
||||
local dump_destination="$DUMP_DIR/$database.sql.gz"
|
||||
scp -r -o ProxyCommand="ssh -l $username -W %h:%p melkki.cs.helsinki.fi" "$username@$pannu_url" "$dump_destination"
|
||||
local s3_path=$2
|
||||
local selection_flag=$3
|
||||
|
||||
rm -f "$dump_destination"
|
||||
|
||||
if [ "$selection_flag" = "true" ]; then
|
||||
local backup_files
|
||||
mapfile -t backup_files < <(s3cmd -c "$S3_CONFIG_FILE" ls "$S3_BUCKET/$s3_path/" | sort -rk1,2 | awk '{print $4}')
|
||||
|
||||
if [ ${#backup_files[@]} -eq 0 ]; then
|
||||
die "No backup files found in $S3_BUCKET bucket $s3_path path!"
|
||||
fi
|
||||
|
||||
infomsg "Available backups:"
|
||||
PS3="Select backup (or 0 to go back): "
|
||||
select chosen_backup in "${backup_files[@]}"; do
|
||||
if [ "$REPLY" = "0" ] || [ "$chosen_backup" = "Go back" ]; then
|
||||
infomsg "Going back to previous menu."
|
||||
break
|
||||
elif [ -n "$chosen_backup" ]; then
|
||||
infomsg "You selected: $chosen_backup"
|
||||
local FILE_NAME
|
||||
FILE_NAME=$(basename "$chosen_backup")
|
||||
infomsg "Fetching the selected dump: $FILE_NAME"
|
||||
s3cmd -c "$S3_CONFIG_FILE" get "$chosen_backup" "$dump_destination"
|
||||
if [ ! -f "$dump_destination" ]; then
|
||||
die "Download failed or file not found: $dump_destination"
|
||||
fi
|
||||
echo "$dump_destination"
|
||||
break
|
||||
else
|
||||
warningmsg "Invalid selection. Please select a valid backup number."
|
||||
fi
|
||||
done
|
||||
else
|
||||
local current_date
|
||||
current_date=$(date +"%Y%m%d")
|
||||
s3cmd -c "$S3_CONFIG_FILE" get "s3://psyduck/${s3_path}/${s3_path}_${current_date}.sql.gz" "$dump_destination"
|
||||
fi
|
||||
}
|
||||
|
||||
check_if_postgres_is_ready() {
|
||||
@@ -101,11 +141,12 @@ reset_databases() {
|
||||
|
||||
reset_jami_data() {
|
||||
local database="jami-db"
|
||||
local pannu_url="svm-116.cs.helsinki.fi:/home/toska_user/most_recent_backup_store/jami.sql.gz"
|
||||
local dump_destination="$DUMP_DIR/$database.sql.gz"
|
||||
local s3_path="jami"
|
||||
|
||||
infomsg "Removing old data"
|
||||
|
||||
|
||||
infomsg "Downloading Jami data"
|
||||
scp -r -o ProxyCommand="ssh -l $username -W %h:%p melkki.cs.helsinki.fi" "$username@$pannu_url" "$dump_destination"
|
||||
|
||||
infomsg "Removing database and related volume"
|
||||
docker volume rm oodikone_jami-data || warningmsg "This is okay, continuing"
|
||||
@@ -122,13 +163,12 @@ reset_jami_data() {
|
||||
}
|
||||
|
||||
reset_all_real_data() {
|
||||
infomsg "Downloading real data dumps, asking for pannu password when needed"
|
||||
infomsg "Downloading real data dumps"
|
||||
for i in ${!DATABASES[*]}; do
|
||||
local database="${DATABASES[$i]}"
|
||||
local url="${REAL_DUMP_URLS[$i]}"
|
||||
download_real_dump "$database" "$url"
|
||||
local url="${S3_PATHS[$i]}"
|
||||
download_real_dump "$database" "$url" false
|
||||
done
|
||||
reset_jami_data
|
||||
reset_databases "${DATABASES[@]}"
|
||||
}
|
||||
|
||||
@@ -152,28 +192,30 @@ reset_single_database() {
|
||||
|
||||
local database=""
|
||||
local url=""
|
||||
local selection_flag=true
|
||||
|
||||
while true; do
|
||||
select opt in "${options[@]}"; do
|
||||
case $opt in
|
||||
"kone-db")
|
||||
local database=$KONE_DB_NAME
|
||||
local url=$KONE_DB_REAL_DUMP_URL
|
||||
local url=$KONE_DB_S3_PATH
|
||||
break 2;;
|
||||
"sis-db")
|
||||
local database=$SIS_DB_NAME
|
||||
local url=$SIS_DB_REAL_DUMP_URL
|
||||
local url=$SIS_DB_S3_PATH
|
||||
break 2;;
|
||||
"sis-importer-db")
|
||||
local database=$SIS_IMPORTER_DB_NAME
|
||||
local url=$SIS_IMPORTER_DB_REAL_DUMP_URL
|
||||
local url=$SIS_IMPORTER_DB_S3_PATH
|
||||
break 2;;
|
||||
"user-db")
|
||||
local database=$USER_DB_NAME
|
||||
local url=$USER_DB_REAL_DUMP_URL
|
||||
local url=$USER_DB_S3_PATH
|
||||
break 2;;
|
||||
"jami-db")
|
||||
reset_jami_data
|
||||
local database=$JAMI_DB_NAME
|
||||
local url=$JAMI_DB_S3_PATH
|
||||
break 2;;
|
||||
"Return to main menu.")
|
||||
break 2;;
|
||||
@@ -186,7 +228,7 @@ reset_single_database() {
|
||||
|
||||
if [ -n "$database" ]; then
|
||||
infomsg "Downloading $database dump"
|
||||
download_real_dump $database $url
|
||||
download_real_dump $database $url $selection_flag
|
||||
reset_databases $database
|
||||
fi
|
||||
}
|
||||
@@ -225,21 +267,6 @@ set_up_oodikone_from_scratch() {
|
||||
infomsg "Building images"
|
||||
"$PROJECT_ROOT"/run.sh both anon build
|
||||
|
||||
TARGET_PATH="$PROJECT_ROOT/services/shared"
|
||||
LINK_PATHS=(
|
||||
"$PROJECT_ROOT/services/backend/src/shared"
|
||||
"$PROJECT_ROOT/services/frontend/src/shared"
|
||||
)
|
||||
|
||||
for SYMLINK_PATH in "${LINK_PATHS[@]}"; do
|
||||
infomsg "Linking $TARGET_PATH to $SYMLINK_PATH"
|
||||
if [ ! -L "$SYMLINK_PATH" ]; then
|
||||
ln -s "$TARGET_PATH" "$SYMLINK_PATH"
|
||||
else
|
||||
infomsg "Symbolic link $SYMLINK_PATH already exists. Skipping creation."
|
||||
fi
|
||||
done
|
||||
|
||||
successmsg "Setup ready, Oodikone can be started! See README for more info."
|
||||
}
|
||||
|
||||
@@ -257,14 +284,11 @@ init_dirs() {
|
||||
|
||||
# If username is not set, get username from data file.
|
||||
# Ask user to provide username, if username was not found from data file.
|
||||
get_username() {
|
||||
if [ ! -f "$USER_DATA_FILE" ]; then
|
||||
warningmsg "University username is needed to get database dumps from toska servers, please enter it now:"
|
||||
read -r username
|
||||
echo "$username" > "$USER_DATA_FILE"
|
||||
successmsg "Succesfully saved username for later usage."
|
||||
get_s3_config() {
|
||||
if [ ! -f "$S3_CONFIG_FILE" ]; then
|
||||
warningmsg "You have to set up s3 config for path ~/.s3cfg accessing the database dumps, config is visibe at the toska/dokumentaatio repo"
|
||||
return 0
|
||||
fi
|
||||
username=$(head -n 1 < "$USER_DATA_FILE")
|
||||
|
||||
infomsg "Using your university username - $username - for getting database dumps"
|
||||
infomsg "Using your config $S3_CONFIG_FILE - for getting database dumps"
|
||||
}
|
||||
|
||||
+15
-13
@@ -2,8 +2,6 @@ FROM registry.access.redhat.com/ubi9/nodejs-22-minimal AS build
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
WORKDIR /opt/app-root/src
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||
|
||||
@@ -19,23 +17,22 @@ ENV SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
|
||||
ARG SENTRY_RELEASE
|
||||
ENV SENTRY_RELEASE=$SENTRY_RELEASE
|
||||
|
||||
COPY ./backend/package*.json ./
|
||||
RUN npm ci --include=dev
|
||||
COPY ./shared ./src/shared
|
||||
WORKDIR /opt/app-root/shared
|
||||
COPY ./shared .
|
||||
|
||||
WORKDIR /opt/app-root/backend
|
||||
COPY ./backend .
|
||||
|
||||
RUN npm run build
|
||||
WORKDIR /opt/app-root
|
||||
|
||||
RUN npm ci --include=dev --prefix shared && npm ci --include=dev --prefix backend
|
||||
RUN npm run build --prefix shared && npm run build --prefix backend
|
||||
|
||||
|
||||
FROM registry.access.redhat.com/ubi9/nodejs-22-minimal
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
WORKDIR /opt/app-root/src
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||
|
||||
@@ -51,9 +48,14 @@ ENV SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT
|
||||
ARG SENTRY_RELEASE
|
||||
ENV SENTRY_RELEASE=$SENTRY_RELEASE
|
||||
|
||||
COPY --from=build /opt/app-root/src/package*.json ./
|
||||
COPY --from=build /opt/app-root/src/dist ./dist
|
||||
WORKDIR /opt/app-root/shared
|
||||
COPY --from=build /opt/app-root/shared/dist .
|
||||
|
||||
RUN npm ci
|
||||
WORKDIR /opt/app-root/backend
|
||||
COPY --from=build /opt/app-root/backend/package*.json .
|
||||
COPY --from=build /opt/app-root/backend/dist ./dist
|
||||
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
@@ -2,15 +2,12 @@ FROM node:22-alpine
|
||||
|
||||
ENV TZ="Europe/Helsinki"
|
||||
|
||||
WORKDIR /opt/app-root/src
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV NODE_ENV=development
|
||||
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||
|
||||
COPY ./package* ./
|
||||
RUN npm ci
|
||||
WORKDIR /opt/app-root/backend
|
||||
COPY . .
|
||||
RUN npm ci
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["node_modules/.bin/tsx", "watch", "--clear-screen=false", "index.js"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
require('./src/util/sentry') // Sentry must be the first import
|
||||
require('./src/app')
|
||||
const logger = require('./src/util/logger')
|
||||
import './src/util/sentry' // Sentry must be the first import
|
||||
import './src/app'
|
||||
import logger from './src/util/logger'
|
||||
|
||||
process.on('unhandledRejection', reason => {
|
||||
logger.error({ message: 'unhandledRejection: ', meta: reason })
|
||||
Generated
+1305
-919
File diff suppressed because it is too large
Load Diff
@@ -5,41 +5,42 @@
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/node": "^8.53.0",
|
||||
"@sentry/profiling-node": "^8.53.0",
|
||||
"axios": "^1.7.9",
|
||||
"bullmq": "^5.39.1",
|
||||
"compression": "^1.7.5",
|
||||
"@oodikone/shared": "file:../shared",
|
||||
"@sentry/node": "^10.5.0",
|
||||
"@sentry/profiling-node": "^10.5.0",
|
||||
"axios": "^1.9.0",
|
||||
"bullmq": "^5.53.2",
|
||||
"compression": "^1.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"cron": "^3.5.0",
|
||||
"cron": "^4.3.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"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",
|
||||
"lru-cache": "^11.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
"papaparse": "^5.5.2",
|
||||
"pg": "^8.13.1",
|
||||
"redis": "^4.7.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.16.0",
|
||||
"redis": "^4.7.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sequelize": "^6.37.5",
|
||||
"sequelize": "^6.37.7",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"umzug": "^3.8.2",
|
||||
"unfuck-utf8-headers-middleware": "^1.0.1",
|
||||
"winston": "^3.17.0",
|
||||
"winston-gelf-transporter": "^1.0.2"
|
||||
"winston-gelf-transporter": "^1.0.2",
|
||||
"winston-loki": "^6.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/validator": "^13.12.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "^22.15.31",
|
||||
"@types/validator": "^13.15.1",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"overrides": {
|
||||
"winston-gelf-transporter": {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
require('express-async-errors')
|
||||
const express = require('express')
|
||||
import 'express-async-errors'
|
||||
import express from 'express'
|
||||
|
||||
const { baseUrl, backendPort } = require('./config')
|
||||
const { initializeDatabaseConnection, dbConnections } = require('./database/connection')
|
||||
const { startCron } = require('./events')
|
||||
const routes = require('./routes').default
|
||||
const logger = require('./util/logger')
|
||||
require('./worker/worker')
|
||||
import { baseUrl, backendPort } from './config'
|
||||
import { initializeDatabaseConnection, dbConnections } from './database/connection'
|
||||
import { startCron } from './events'
|
||||
import routes from './routes'
|
||||
import logger from './util/logger'
|
||||
import './worker/worker'
|
||||
|
||||
initializeDatabaseConnection()
|
||||
.then(() => {
|
||||
dbConnections.connect()
|
||||
void dbConnections.connect()
|
||||
|
||||
dbConnections.on('connect', () => {
|
||||
logger.info('Connected to sis db successfully')
|
||||
@@ -1,103 +0,0 @@
|
||||
// Node env to use
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const isStaging = process.env.REACT_APP_STAGING === 'true'
|
||||
const isProduction = !isStaging && process.env.NODE_ENV === 'production'
|
||||
|
||||
const runningInCI = process.env.CI === 'true'
|
||||
|
||||
// IAM group
|
||||
const requiredGroup = isStaging
|
||||
? ['grp-oodikone-staging-users', 'grp-oodikone-basic-staging-users']
|
||||
: ['grp-oodikone-users', 'grp-oodikone-basic-users']
|
||||
|
||||
// Pate
|
||||
const pateToken = process.env.PATE_API_TOKEN || ''
|
||||
|
||||
// Jami
|
||||
const jamiUrl = process.env.JAMI_URL || ''
|
||||
|
||||
// Importer client
|
||||
const importerUrl = process.env.IMPORTER_DB_API_URL || ''
|
||||
const importerToken = process.env.IMPORTER_DB_API_TOKEN || ''
|
||||
const importerDbApiUser = process.env.IMPORTER_DB_API_USER || ''
|
||||
const importerDbApiPassword = process.env.IMPORTER_DB_API_PASSWORD || ''
|
||||
|
||||
// Sisu
|
||||
const sisUrl = process.env.SIS_HOST
|
||||
const sisGrapqlAppAccount = process.env.SIS_GRAPHQL_APPLICATION_ACCOUNT
|
||||
const sisGrapqlAppKey = process.env.SIS_GRAPHQL_APPLICATION_KEY
|
||||
|
||||
// Networking: Urls & ports
|
||||
const baseUrl = isDev ? '/api' : ''
|
||||
const frontUrl = process.env.FRONT_URL
|
||||
const backendPort = 8080
|
||||
|
||||
// System run in whose environment
|
||||
const serviceProvider = process.env.SERVICE_PROVIDER ? process.env.SERVICE_PROVIDER.toLowerCase() : 'toska'
|
||||
|
||||
// Optional logout-url configuration
|
||||
const configLogoutUrl = serviceProvider === 'toska' ? undefined : process.env.LOGOUT_URL
|
||||
|
||||
// Other stuff
|
||||
const { DB_URL_KONE, DB_URL_USER, SECRET_TOKEN, SIS_DB_URL, SIS_UPDATER_URL, CRYPT_KEY } = process.env
|
||||
|
||||
const rootOrgId = process.env.ROOT_ORG_ID || 'hy-university-root-id'
|
||||
|
||||
const languageCenterViewEnabled =
|
||||
process.env.LANGUAGE_CENTER_VIEW_ENABLED == null ? true : process.env.LANGUAGE_CENTER_VIEW_ENABLED === 'true'
|
||||
|
||||
const concurrentWorkers = process.env.CONCURRENT_WORKERS ? parseInt(process.env.CONCURRENT_WORKERS, 10) : 2
|
||||
|
||||
let DB_MAX_CONNECTIONS = parseInt(process.env.DB_MAX_CONNECTIONS, 10)
|
||||
if (Number.isNaN(DB_MAX_CONNECTIONS)) {
|
||||
DB_MAX_CONNECTIONS = 5 // sequelize's default
|
||||
}
|
||||
let DB_MAX_CRON_CONNECTIONS = DB_MAX_CONNECTIONS - 5
|
||||
if (DB_MAX_CRON_CONNECTIONS < 1) {
|
||||
DB_MAX_CRON_CONNECTIONS = 1
|
||||
}
|
||||
|
||||
const redis = process.env.REDIS
|
||||
const DB_SCHEMA_KONE = process.env.DB_SCHEMA_KONE || 'public'
|
||||
|
||||
const KONE_PASSWORD = process.env.KONE_PASSWORD || 'postgres'
|
||||
const USER_PASSWORD = process.env.USER_PASSWORD || 'postgres'
|
||||
const SIS_PASSWORD = process.env.SIS_PASSWORD || 'postgres'
|
||||
|
||||
module.exports = {
|
||||
DB_URL_USER,
|
||||
CRYPT_KEY,
|
||||
DB_URL_KONE,
|
||||
DB_MAX_CONNECTIONS,
|
||||
DB_MAX_CRON_CONNECTIONS,
|
||||
redis,
|
||||
SECRET_TOKEN,
|
||||
DB_SCHEMA_KONE,
|
||||
KONE_PASSWORD,
|
||||
USER_PASSWORD,
|
||||
SIS_PASSWORD,
|
||||
requiredGroup,
|
||||
SIS_DB_URL,
|
||||
SIS_UPDATER_URL,
|
||||
runningInCI,
|
||||
isProduction,
|
||||
pateToken,
|
||||
jamiUrl,
|
||||
baseUrl,
|
||||
frontUrl,
|
||||
backendPort,
|
||||
importerUrl,
|
||||
importerToken,
|
||||
importerDbApiUser,
|
||||
importerDbApiPassword,
|
||||
isDev,
|
||||
isStaging,
|
||||
rootOrgId,
|
||||
serviceProvider,
|
||||
configLogoutUrl,
|
||||
sisUrl,
|
||||
sisGrapqlAppAccount,
|
||||
sisGrapqlAppKey,
|
||||
languageCenterViewEnabled,
|
||||
concurrentWorkers,
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Node env to use
|
||||
export const isDev = process.env.NODE_ENV === 'development'
|
||||
export const isStaging = process.env.REACT_APP_STAGING === 'true'
|
||||
export const isProduction = !isStaging && process.env.NODE_ENV === 'production'
|
||||
|
||||
export const runningInCI = process.env.CI === 'true'
|
||||
|
||||
// IAM group
|
||||
export const requiredGroup = isStaging
|
||||
? ['grp-oodikone-staging-users', 'grp-oodikone-basic-staging-users']
|
||||
: ['grp-oodikone-users', 'grp-oodikone-basic-users']
|
||||
|
||||
// Pate
|
||||
export const pateToken = process.env.PATE_API_TOKEN ?? ''
|
||||
|
||||
// Jami
|
||||
export const jamiUrl = process.env.JAMI_URL ?? ''
|
||||
|
||||
// Importer client
|
||||
export const importerUrl = process.env.IMPORTER_DB_API_URL ?? ''
|
||||
export const importerToken = process.env.IMPORTER_DB_API_TOKEN ?? ''
|
||||
export const importerDbApiUser = process.env.IMPORTER_DB_API_USER ?? ''
|
||||
export const importerDbApiPassword = process.env.IMPORTER_DB_API_PASSWORD ?? ''
|
||||
|
||||
// Sisu
|
||||
export const sisUrl = process.env.SIS_HOST
|
||||
export const sisGrapqlAppAccount = process.env.SIS_GRAPHQL_APPLICATION_ACCOUNT
|
||||
export const sisGrapqlAppKey = process.env.SIS_GRAPHQL_APPLICATION_KEY
|
||||
|
||||
// Networking: Urls & ports
|
||||
export const baseUrl = isDev ? '/api' : ''
|
||||
export const frontUrl = process.env.FRONT_URL
|
||||
export const backendPort = 8080
|
||||
|
||||
// System run in whose environment
|
||||
export const serviceProvider = process.env.SERVICE_PROVIDER?.toLowerCase() ?? 'toska'
|
||||
|
||||
// Optional logout-url configuration
|
||||
export const configLogoutUrl = serviceProvider === 'toska' ? undefined : process.env.LOGOUT_URL
|
||||
|
||||
// Other stuff
|
||||
export const { DB_URL_KONE, DB_URL_USER, SECRET_TOKEN, SIS_DB_URL, SIS_UPDATER_URL, CRYPT_KEY } = process.env
|
||||
|
||||
export const rootOrgId = process.env.ROOT_ORG_ID ?? 'hy-university-root-id'
|
||||
|
||||
export const languageCenterViewEnabled =
|
||||
process.env.LANGUAGE_CENTER_VIEW_ENABLED == null ? true : process.env.LANGUAGE_CENTER_VIEW_ENABLED === 'true'
|
||||
|
||||
export const concurrentWorkers = parseInt(process.env.CONCURRENT_WORKERS!, 10) || 2
|
||||
|
||||
export const DB_MAX_CONNECTIONS = parseInt(process.env.DB_MAX_CONNECTIONS!, 10) || 5 // sequelize's default
|
||||
export const DB_MAX_CRON_CONNECTIONS = Math.max(1, DB_MAX_CONNECTIONS - 5)
|
||||
export const CRON_SCHEDULE = process.env.CRON_SCHEDULE ?? '0 23 * * *' // Default to 23:00 daily
|
||||
|
||||
export const redis = process.env.REDIS
|
||||
export const DB_SCHEMA_KONE = process.env.DB_SCHEMA_KONE ?? 'public'
|
||||
|
||||
export const KONE_PASSWORD = process.env.KONE_PASSWORD ?? 'postgres'
|
||||
export const USER_PASSWORD = process.env.USER_PASSWORD ?? 'postgres'
|
||||
export const SIS_PASSWORD = process.env.SIS_PASSWORD ?? 'postgres'
|
||||
@@ -1,4 +1,7 @@
|
||||
// Linter was not having any of this so needed to add resolveJsonModule:true to tsconfig
|
||||
// export const { facultyCodes, ignoredFacultyCodes, magicFacultyCode } = require('../environment/organizationConfig.json')
|
||||
|
||||
export { facultyCodes, ignoredFacultyCodes, magicFacultyCode } from '../environment/organizationConfig.json'
|
||||
import organizationConfig from '../environment/organizationConfig.json'
|
||||
|
||||
export const { facultyCodes } = organizationConfig
|
||||
export const { ignoredFacultyCodes } = organizationConfig
|
||||
export const { magicFacultyCode } = organizationConfig
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// This is locked behind admin check.
|
||||
// We probably want to keep it on the server side and not share it.
|
||||
export const roles = [
|
||||
'admin',
|
||||
'courseStatistics',
|
||||
|
||||
@@ -2,38 +2,38 @@ import EventEmitter from 'events'
|
||||
import { Sequelize } from 'sequelize-typescript'
|
||||
import { SequelizeStorage, Umzug } from 'umzug'
|
||||
|
||||
import conf from '../config'
|
||||
import * as conf from '../config'
|
||||
import {
|
||||
Course,
|
||||
CourseProvider,
|
||||
CourseType,
|
||||
Credit,
|
||||
CreditTeacher,
|
||||
CreditType,
|
||||
CurriculumPeriod,
|
||||
Enrollment,
|
||||
Organization,
|
||||
ProgrammeModule,
|
||||
ProgrammeModuleChild,
|
||||
Semester,
|
||||
SISStudyRight,
|
||||
SISStudyRightElement,
|
||||
Student,
|
||||
Studyplan,
|
||||
StudyrightExtent,
|
||||
Teacher,
|
||||
CourseModel,
|
||||
CourseProviderModel,
|
||||
CourseTypeModel,
|
||||
CreditModel,
|
||||
CreditTeacherModel,
|
||||
CreditTypeModel,
|
||||
CurriculumPeriodModel,
|
||||
EnrollmentModel,
|
||||
OrganizationModel,
|
||||
ProgrammeModuleModel,
|
||||
ProgrammeModuleChildModel,
|
||||
SemesterModel,
|
||||
SISStudyRightModel,
|
||||
SISStudyRightElementModel,
|
||||
StudentModel,
|
||||
StudyplanModel,
|
||||
StudyrightExtentModel,
|
||||
TeacherModel,
|
||||
} from '../models'
|
||||
import {
|
||||
CustomPopulationSearch,
|
||||
ExcludedCourse,
|
||||
OpenUniPopulationSearch,
|
||||
ProgressCriteria,
|
||||
StudyGuidanceGroupTag,
|
||||
StudyProgrammePin,
|
||||
Tag,
|
||||
TagStudent,
|
||||
CustomPopulationSearchModel,
|
||||
ExcludedCourseModel,
|
||||
OpenUniPopulationSearchModel,
|
||||
ProgressCriteriaModel,
|
||||
StudyGuidanceGroupTagModel,
|
||||
StudyProgrammePinModel,
|
||||
TagModel,
|
||||
TagStudentModel,
|
||||
} from '../models/kone'
|
||||
import { User } from '../models/user'
|
||||
import { UserModel } from '../models/user'
|
||||
|
||||
import logger from '../util/logger'
|
||||
|
||||
@@ -60,24 +60,24 @@ class DbConnection extends EventEmitter {
|
||||
logging: false,
|
||||
password: conf.SIS_PASSWORD,
|
||||
models: [
|
||||
Course,
|
||||
CourseProvider,
|
||||
CourseType,
|
||||
Credit,
|
||||
CreditTeacher,
|
||||
CreditType,
|
||||
CurriculumPeriod,
|
||||
Enrollment,
|
||||
Organization,
|
||||
ProgrammeModule,
|
||||
ProgrammeModuleChild,
|
||||
Semester,
|
||||
SISStudyRight,
|
||||
SISStudyRightElement,
|
||||
Student,
|
||||
Studyplan,
|
||||
StudyrightExtent,
|
||||
Teacher,
|
||||
CourseModel,
|
||||
CourseProviderModel,
|
||||
CourseTypeModel,
|
||||
CreditModel,
|
||||
CreditTeacherModel,
|
||||
CreditTypeModel,
|
||||
CurriculumPeriodModel,
|
||||
EnrollmentModel,
|
||||
OrganizationModel,
|
||||
ProgrammeModuleModel,
|
||||
ProgrammeModuleChildModel,
|
||||
SemesterModel,
|
||||
SISStudyRightModel,
|
||||
SISStudyRightElementModel,
|
||||
StudentModel,
|
||||
StudyplanModel,
|
||||
StudyrightExtentModel,
|
||||
TeacherModel,
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -96,7 +96,7 @@ class DbConnection extends EventEmitter {
|
||||
message: `Sis database connection failed! Attempt ${attempt}/${this.RETRY_ATTEMPTS}`,
|
||||
meta: error,
|
||||
})
|
||||
setTimeout(() => this.connect(attempt + 1), 1000 * attempt)
|
||||
setTimeout(() => void this.connect(attempt + 1), 1000 * attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,14 +107,14 @@ const sequelizeKone = new Sequelize(conf.DB_URL_KONE!, {
|
||||
logging: false,
|
||||
password: conf.KONE_PASSWORD,
|
||||
models: [
|
||||
CustomPopulationSearch,
|
||||
ExcludedCourse,
|
||||
OpenUniPopulationSearch,
|
||||
ProgressCriteria,
|
||||
StudyGuidanceGroupTag,
|
||||
StudyProgrammePin,
|
||||
Tag,
|
||||
TagStudent,
|
||||
CustomPopulationSearchModel,
|
||||
ExcludedCourseModel,
|
||||
OpenUniPopulationSearchModel,
|
||||
ProgressCriteriaModel,
|
||||
StudyGuidanceGroupTagModel,
|
||||
StudyProgrammePinModel,
|
||||
TagModel,
|
||||
TagStudentModel,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -123,7 +123,7 @@ void sequelizeKone.query(`SET SESSION search_path to ${conf.DB_SCHEMA_KONE}`)
|
||||
const sequelizeUser = new Sequelize(conf.DB_URL_USER!, {
|
||||
logging: false,
|
||||
password: conf.USER_PASSWORD,
|
||||
models: [User],
|
||||
models: [UserModel],
|
||||
})
|
||||
|
||||
const initializeDatabaseConnection = async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CronJob } from 'cron'
|
||||
|
||||
import { isProduction, runningInCI, languageCenterViewEnabled } from './config'
|
||||
import { isProduction, runningInCI, languageCenterViewEnabled, CRON_SCHEDULE } from './config'
|
||||
import { getDegreeProgrammesOfFaculty } from './services/faculty/faculty'
|
||||
import { getFaculties } from './services/faculty/facultyHelpers'
|
||||
import { updateFacultyOverview, updateFacultyProgressOverview } from './services/faculty/facultyUpdates'
|
||||
@@ -65,7 +65,7 @@ const refreshProgrammesAndFaculties = async () => {
|
||||
export const refreshProgramme = async (code: string) => {
|
||||
await updateBasicView(code, '')
|
||||
await updateStudyTrackView(code, '')
|
||||
const combinedProgramme = combinedStudyProgrammes[code] || ''
|
||||
const combinedProgramme = combinedStudyProgrammes[code] ?? ''
|
||||
await updateBasicView(code, combinedProgramme)
|
||||
await updateStudyTrackView(code, combinedProgramme)
|
||||
}
|
||||
@@ -105,15 +105,16 @@ 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, () => {
|
||||
logger.info('Running daily jobs from cron')
|
||||
await dailyJobs()
|
||||
void dailyJobs()
|
||||
})
|
||||
schedule('0 4 * * 3', async () => {
|
||||
schedule('0 4 * * 3', () => {
|
||||
logger.info("Deleting users who haven't logged in for 18 months")
|
||||
const [, result] = (await deleteOutdatedUsers()) as { rowCount: number }[]
|
||||
logger.info(`Deleted ${result.rowCount} users`)
|
||||
|
||||
const deletedOutdatedUsers = deleteOutdatedUsers() as Promise<{ rowCount: number }[]>
|
||||
void deletedOutdatedUsers.then(([_, result]) => logger.info(`Deleted ${result.rowCount} users`))
|
||||
})
|
||||
schedule('0 19 * * 1', () => {
|
||||
logger.info('Updating students whose studyplans have not been updated recently')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/consistent-return */
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { Role } from '../shared/types'
|
||||
import { Role } from '@oodikone/shared/types'
|
||||
|
||||
export const roles =
|
||||
(requiredRoles: Role[], requiredIamGroups?: string[]) => (req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
@@ -66,8 +66,9 @@ const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunct
|
||||
if (!username) missingHeaders.push('uid')
|
||||
|
||||
if (missingHeaders.length > 0) {
|
||||
const reqUser = username ?? 'Anonymous user'
|
||||
throw new ApplicationError(
|
||||
`Not enough data in request headers, the following headers were missing: ${missingHeaders.join(', ')}`,
|
||||
`${reqUser} requested ${req.url} without valid request headers. Missing: ${missingHeaders.join(', ')}`,
|
||||
403,
|
||||
{ logoutUrl }
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
@@ -12,29 +11,45 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { ExtentCode, SemesterEnrollment } from '../types'
|
||||
import { Credit, Enrollment, Organization, SISStudyRightElement, Student, Studyplan, StudyrightExtent } from '.'
|
||||
import type {
|
||||
Credit,
|
||||
Enrollment,
|
||||
Organization,
|
||||
SISStudyRight,
|
||||
SISStudyRightElement,
|
||||
Student,
|
||||
Studyplan,
|
||||
} from '@oodikone/shared/models'
|
||||
import { ExtentCode, SemesterEnrollment } from '@oodikone/shared/types'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { EnrollmentModel } from './enrollment'
|
||||
import { OrganizationModel } from './organization'
|
||||
import { SISStudyRightElementModel } from './SISStudyRightElement'
|
||||
import { StudentModel } from './student'
|
||||
import { StudyplanModel } from './studyplan'
|
||||
import { StudyrightExtentModel } from './studyrightExtent'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
tableName: 'sis_study_rights',
|
||||
})
|
||||
export class SISStudyRight extends Model<InferAttributes<SISStudyRight>> {
|
||||
export class SISStudyRightModel extends Model<SISStudyRight> implements SISStudyRight {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
|
||||
@ForeignKey(() => Organization)
|
||||
@ForeignKey(() => OrganizationModel)
|
||||
@Column(DataType.STRING)
|
||||
facultyCode!: string
|
||||
|
||||
@BelongsTo(() => Organization, { foreignKey: 'facultyCode', targetKey: 'code' })
|
||||
@BelongsTo(() => OrganizationModel, { foreignKey: 'facultyCode', targetKey: 'code' })
|
||||
organization!: Organization
|
||||
|
||||
@HasMany(() => Studyplan)
|
||||
@HasMany(() => StudyplanModel)
|
||||
studyPlans!: Studyplan[]
|
||||
|
||||
@HasMany(() => SISStudyRightElement, { foreignKey: 'studyRightId' })
|
||||
@HasMany(() => SISStudyRightElementModel, { foreignKey: 'studyRightId' })
|
||||
studyRightElements!: SISStudyRightElement[]
|
||||
|
||||
@Column(DataType.DATE)
|
||||
@@ -52,10 +67,10 @@ export class SISStudyRight extends Model<InferAttributes<SISStudyRight>> {
|
||||
@Column(DataType.STRING)
|
||||
studentNumber!: string
|
||||
|
||||
@BelongsTo(() => Student, { foreignKey: 'studentNumber', targetKey: 'studentnumber' })
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'studentNumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
|
||||
@ForeignKey(() => StudyrightExtent)
|
||||
@ForeignKey(() => StudyrightExtentModel)
|
||||
@Column(DataType.INTEGER)
|
||||
extentCode!: ExtentCode
|
||||
|
||||
@@ -65,12 +80,18 @@ export class SISStudyRight extends Model<InferAttributes<SISStudyRight>> {
|
||||
@Column(DataType.JSONB)
|
||||
semesterEnrollments!: SemesterEnrollment[] | null
|
||||
|
||||
@HasMany(() => Credit, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
credits!: Credit[]
|
||||
|
||||
@HasMany(() => Enrollment, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
enrollments!: Enrollment[]
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
tvex!: boolean
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
expirationRuleUrns!: string[]
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
@@ -11,15 +10,17 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { DegreeProgrammeType, Phase, StudyTrack } from '../types'
|
||||
import { SISStudyRight } from './SISStudyRight'
|
||||
import type { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import type { Name, StudyTrack } from '@oodikone/shared/types'
|
||||
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
|
||||
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
tableName: 'sis_study_right_elements',
|
||||
})
|
||||
export class SISStudyRightElement extends Model<InferAttributes<SISStudyRightElement>> {
|
||||
export class SISStudyRightElementModel extends Model<SISStudyRightElement> implements SISStudyRightElement {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
@@ -36,11 +37,11 @@ export class SISStudyRightElement extends Model<InferAttributes<SISStudyRightEle
|
||||
@Column(DataType.INTEGER)
|
||||
phase!: Phase
|
||||
|
||||
@ForeignKey(() => SISStudyRight)
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column(DataType.STRING)
|
||||
studyRightId!: string
|
||||
|
||||
@BelongsTo(() => SISStudyRight, { foreignKey: 'studyRightId' })
|
||||
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyRightId' })
|
||||
studyRight!: SISStudyRight
|
||||
|
||||
@Column(DataType.STRING)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
BelongsToMany,
|
||||
@@ -13,41 +12,43 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { CourseProvider } from './courseProvider'
|
||||
import { CourseType } from './courseType'
|
||||
import { Credit } from './credit'
|
||||
import { Enrollment } from './enrollment'
|
||||
import { Organization } from './organization'
|
||||
import type { Course, CourseType, Credit, Enrollment, Organization } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
|
||||
import { CourseProviderModel } from './courseProvider'
|
||||
import { CourseTypeModel } from './courseType'
|
||||
import { CreditModel } from './credit'
|
||||
import { EnrollmentModel } from './enrollment'
|
||||
import { OrganizationModel } from './organization'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'course',
|
||||
tableName: 'course',
|
||||
})
|
||||
export class Course extends Model<InferAttributes<Course>> {
|
||||
export class CourseModel extends Model<Course> implements Course {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
|
||||
@ForeignKey(() => Course)
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
|
||||
@ForeignKey(() => CourseType)
|
||||
@ForeignKey(() => CourseTypeModel)
|
||||
@Column(DataType.STRING)
|
||||
coursetypecode!: string
|
||||
|
||||
@BelongsTo(() => CourseType, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
|
||||
@BelongsTo(() => CourseTypeModel, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
|
||||
courseType!: CourseType
|
||||
|
||||
@HasMany(() => Credit, { foreignKey: 'course_id', sourceKey: 'id' })
|
||||
@HasMany(() => CreditModel, { foreignKey: 'course_id', sourceKey: 'id' })
|
||||
credits!: Credit[]
|
||||
|
||||
@HasMany(() => Enrollment, { foreignKey: 'course_id' })
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'course_id' })
|
||||
enrollments!: Enrollment[]
|
||||
|
||||
@BelongsToMany(() => Organization, () => CourseProvider, 'coursecode')
|
||||
@BelongsToMany(() => OrganizationModel, () => CourseProviderModel, 'coursecode')
|
||||
organizations!: Organization[]
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
@@ -63,17 +64,11 @@ export class Course extends Model<InferAttributes<Course>> {
|
||||
min_attainment_date!: Date
|
||||
|
||||
@CreatedAt
|
||||
@Column({
|
||||
field: 'created_at',
|
||||
type: DataType.DATE,
|
||||
})
|
||||
@Column({ field: 'created_at', type: DataType.DATE })
|
||||
createdAt!: Date
|
||||
|
||||
@UpdatedAt
|
||||
@Column({
|
||||
field: 'updated_at',
|
||||
type: DataType.DATE,
|
||||
})
|
||||
@Column({ field: 'updated_at', type: DataType.DATE })
|
||||
updatedAt!: Date
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
@@ -82,9 +77,6 @@ export class Course extends Model<InferAttributes<Course>> {
|
||||
@Column(DataType.STRING)
|
||||
course_unit_type!: string
|
||||
|
||||
@Column({
|
||||
field: 'main_course_code',
|
||||
type: DataType.STRING,
|
||||
})
|
||||
@Column({ field: 'main_course_code', type: DataType.STRING })
|
||||
mainCourseCode!: string
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Course } from './course'
|
||||
import { Organization } from './organization'
|
||||
import type { CourseProvider } from '@oodikone/shared/models'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
import { OrganizationModel } from './organization'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'course_provider',
|
||||
tableName: 'course_providers',
|
||||
})
|
||||
export class CourseProvider extends Model<InferAttributes<CourseProvider>> {
|
||||
@ForeignKey(() => Course)
|
||||
export class CourseProviderModel extends Model<CourseProvider> implements CourseProvider {
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
coursecode!: string
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
shares!: Array<{ share: number; startDate?: string; endDate?: string }> | null
|
||||
|
||||
@ForeignKey(() => Organization)
|
||||
@ForeignKey(() => OrganizationModel)
|
||||
@Column(DataType.STRING)
|
||||
organizationcode!: string
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { Course } from './course'
|
||||
import type { Course, CourseType } from '@oodikone/shared/models'
|
||||
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { CourseModel } from './course'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'course_type',
|
||||
tableName: 'course_types',
|
||||
})
|
||||
export class CourseType extends Model<InferAttributes<CourseType>> {
|
||||
export class CourseTypeModel extends Model<CourseType> implements CourseType {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
coursetypecode!: string
|
||||
|
||||
@HasMany(() => Course)
|
||||
@HasMany(() => CourseModel)
|
||||
courses!: Course[]
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
BelongsToMany,
|
||||
@@ -12,15 +11,23 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { CreditTypeCode } from '../types'
|
||||
import { Course, CreditTeacher, CreditType, Semester, SISStudyRight, Student, Teacher } from '.'
|
||||
import type { Credit, Course, CreditType, Semester, SISStudyRight, Student, Teacher } from '@oodikone/shared/models'
|
||||
import { CreditTypeCode } from '@oodikone/shared/types'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
import { CreditTeacherModel } from './creditTeacher'
|
||||
import { CreditTypeModel } from './creditType'
|
||||
import { SemesterModel } from './semester'
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
import { StudentModel } from './student'
|
||||
import { TeacherModel } from './teacher'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'credit',
|
||||
tableName: 'credit',
|
||||
})
|
||||
export class Credit extends Model<InferAttributes<Credit>> {
|
||||
export class CreditModel extends Model<Credit> implements Credit {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
@@ -28,24 +35,24 @@ export class Credit extends Model<InferAttributes<Credit>> {
|
||||
@Column(DataType.STRING)
|
||||
grade!: string
|
||||
|
||||
@ForeignKey(() => Student)
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
student_studentnumber!: string
|
||||
|
||||
@BelongsTo(() => Student, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
|
||||
@BelongsTo(() => Semester, { foreignKey: 'semester_composite' })
|
||||
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite' })
|
||||
semester!: Semester
|
||||
|
||||
@Column(DataType.DOUBLE)
|
||||
credits!: number
|
||||
|
||||
@ForeignKey(() => CreditType)
|
||||
@ForeignKey(() => CreditTypeModel)
|
||||
@Column(DataType.INTEGER)
|
||||
credittypecode!: CreditTypeCode
|
||||
|
||||
@BelongsTo(() => CreditType, { foreignKey: 'credittypecode', targetKey: 'credittypecode' })
|
||||
@BelongsTo(() => CreditTypeModel, { foreignKey: 'credittypecode', targetKey: 'credittypecode' })
|
||||
creditType!: CreditType
|
||||
|
||||
@Column(DataType.DATE)
|
||||
@@ -54,16 +61,16 @@ export class Credit extends Model<InferAttributes<Credit>> {
|
||||
@Column(DataType.DATE)
|
||||
attainment_date!: Date
|
||||
|
||||
@BelongsToMany(() => Teacher, () => CreditTeacher, 'credit_id')
|
||||
@BelongsToMany(() => TeacherModel, () => CreditTeacherModel, 'credit_id')
|
||||
teachers!: Teacher[]
|
||||
|
||||
@Column(DataType.STRING)
|
||||
course_code!: string
|
||||
|
||||
@BelongsTo(() => Course, { foreignKey: 'course_id' })
|
||||
@BelongsTo(() => CourseModel, { foreignKey: 'course_id' })
|
||||
course!: Course
|
||||
|
||||
@ForeignKey(() => Course)
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
course_id!: string
|
||||
|
||||
@@ -85,11 +92,11 @@ export class Credit extends Model<InferAttributes<Credit>> {
|
||||
@Column(DataType.BOOLEAN)
|
||||
is_open!: boolean
|
||||
|
||||
@ForeignKey(() => SISStudyRight)
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column(DataType.STRING)
|
||||
studyright_id!: string
|
||||
|
||||
@BelongsTo(() => SISStudyRight)
|
||||
@BelongsTo(() => SISStudyRightModel)
|
||||
studyright!: SISStudyRight
|
||||
|
||||
@CreatedAt
|
||||
@@ -108,3 +115,8 @@ export class Credit extends Model<InferAttributes<Credit>> {
|
||||
|
||||
static notUnnecessary: ({ credits }: { credits: number }) => boolean
|
||||
}
|
||||
|
||||
CreditModel.passed = ({ credittypecode }) => [CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(credittypecode)
|
||||
CreditModel.failed = ({ credittypecode }) => credittypecode === CreditTypeCode.FAILED
|
||||
CreditModel.improved = ({ credittypecode }) => credittypecode === CreditTypeCode.IMPROVED
|
||||
CreditModel.notUnnecessary = ({ credits }) => credits > 0 && credits <= 12
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Credit } from './credit'
|
||||
import { Teacher } from './teacher'
|
||||
import type { CreditTeacher } from '@oodikone/shared/models'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { TeacherModel } from './teacher'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'credit_teacher',
|
||||
tableName: 'credit_teachers',
|
||||
})
|
||||
export class CreditTeacher extends Model<InferAttributes<CreditTeacher>> {
|
||||
@ForeignKey(() => Credit)
|
||||
export class CreditTeacherModel extends Model<CreditTeacher> implements CreditTeacher {
|
||||
@ForeignKey(() => CreditModel)
|
||||
@Column(DataType.STRING)
|
||||
credit_id!: string
|
||||
|
||||
@ForeignKey(() => Teacher)
|
||||
@ForeignKey(() => TeacherModel)
|
||||
@Column(DataType.STRING)
|
||||
teacher_id!: string
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { CreditTypeCode } from '../types'
|
||||
import type { CreditType } from '@oodikone/shared/models'
|
||||
import { CreditTypeCode, Name } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'credit_type',
|
||||
tableName: 'credit_types',
|
||||
})
|
||||
export class CreditType extends Model<InferAttributes<CreditType>> {
|
||||
export class CreditTypeModel extends Model<CreditType> implements CreditType {
|
||||
@PrimaryKey
|
||||
@Column(DataType.INTEGER)
|
||||
credittypecode!: CreditTypeCode
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import type { CurriculumPeriod } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'curriculum_period',
|
||||
tableName: 'curriculum_periods',
|
||||
})
|
||||
export class CurriculumPeriod extends Model<InferAttributes<CurriculumPeriod>> {
|
||||
export class CurriculumPeriodModel extends Model<CurriculumPeriod> implements CurriculumPeriod {
|
||||
@Column({ type: DataType.STRING, primaryKey: true })
|
||||
id!: string
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
@@ -11,24 +10,29 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { EnrollmentState } from '../types'
|
||||
import { Course, Semester, SISStudyRight, Student } from '.'
|
||||
import type { Enrollment, Semester, SISStudyRight, Student } from '@oodikone/shared/models'
|
||||
import { EnrollmentState } from '@oodikone/shared/types'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
import { SemesterModel } from './semester'
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
import { StudentModel } from './student'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'enrollment',
|
||||
tableName: 'enrollment',
|
||||
})
|
||||
export class Enrollment extends Model<InferAttributes<Enrollment>> {
|
||||
export class EnrollmentModel extends Model<Enrollment> implements Enrollment {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
|
||||
@ForeignKey(() => Student)
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
|
||||
@BelongsTo(() => Student, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
|
||||
@Column(DataType.STRING)
|
||||
@@ -40,15 +44,15 @@ export class Enrollment extends Model<InferAttributes<Enrollment>> {
|
||||
@Column(DataType.DATE)
|
||||
enrollment_date_time!: Date
|
||||
|
||||
@ForeignKey(() => Course)
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
course_id!: string
|
||||
|
||||
@ForeignKey(() => Semester)
|
||||
@ForeignKey(() => SemesterModel)
|
||||
@Column(DataType.STRING)
|
||||
semester_composite!: string
|
||||
|
||||
@BelongsTo(() => Semester, { foreignKey: 'semester_composite', targetKey: 'composite' })
|
||||
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite', targetKey: 'composite' })
|
||||
semester!: Semester
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
@@ -57,15 +61,15 @@ export class Enrollment extends Model<InferAttributes<Enrollment>> {
|
||||
@Column(DataType.BOOLEAN)
|
||||
is_open!: boolean
|
||||
|
||||
@ForeignKey(() => SISStudyRight)
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column({ type: DataType.STRING, allowNull: true })
|
||||
studyright_id!: string
|
||||
|
||||
@BelongsTo(() => SISStudyRight, { foreignKey: 'studyright_id', targetKey: 'id', constraints: false })
|
||||
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyright_id', targetKey: 'id', constraints: false })
|
||||
studyright!: SISStudyRight
|
||||
|
||||
@BelongsTo(() => Course, { foreignKey: 'course_id', targetKey: 'id' })
|
||||
course!: Course
|
||||
@BelongsTo(() => CourseModel, { foreignKey: 'course_id', targetKey: 'id' })
|
||||
course!: CourseModel
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
|
||||
@@ -1,45 +1,18 @@
|
||||
import { CreditTypeCode } from '../types'
|
||||
import { Course } from './course'
|
||||
import { CourseProvider } from './courseProvider'
|
||||
import { CourseType } from './courseType'
|
||||
import { Credit } from './credit'
|
||||
import { CreditTeacher } from './creditTeacher'
|
||||
import { CreditType } from './creditType'
|
||||
import { CurriculumPeriod } from './curriculumPeriod'
|
||||
import { Enrollment } from './enrollment'
|
||||
import { Organization } from './organization'
|
||||
import { ProgrammeModule } from './programmeModule'
|
||||
import { ProgrammeModuleChild } from './programmeModuleChild'
|
||||
import { Semester } from './semester'
|
||||
import { SISStudyRight } from './SISStudyRight'
|
||||
import { SISStudyRightElement } from './SISStudyRightElement'
|
||||
import { Student } from './student'
|
||||
import { Studyplan } from './studyplan'
|
||||
import { StudyrightExtent } from './studyrightExtent'
|
||||
import { Teacher } from './teacher'
|
||||
|
||||
Credit.passed = ({ credittypecode }) => [CreditTypeCode.PASSED, CreditTypeCode.APPROVED].includes(credittypecode)
|
||||
Credit.failed = ({ credittypecode }) => credittypecode === CreditTypeCode.FAILED
|
||||
Credit.improved = ({ credittypecode }) => credittypecode === CreditTypeCode.IMPROVED
|
||||
Credit.notUnnecessary = ({ credits }) => credits > 0 && credits <= 12
|
||||
|
||||
export {
|
||||
Course,
|
||||
CourseProvider,
|
||||
CourseType,
|
||||
Credit,
|
||||
CreditTeacher,
|
||||
CreditType,
|
||||
CurriculumPeriod,
|
||||
Enrollment,
|
||||
Organization,
|
||||
ProgrammeModule,
|
||||
ProgrammeModuleChild,
|
||||
Semester,
|
||||
SISStudyRight,
|
||||
SISStudyRightElement,
|
||||
Student,
|
||||
Studyplan,
|
||||
StudyrightExtent,
|
||||
Teacher,
|
||||
}
|
||||
export { CourseModel } from './course'
|
||||
export { CourseProviderModel } from './courseProvider'
|
||||
export { CourseTypeModel } from './courseType'
|
||||
export { CreditModel } from './credit'
|
||||
export { CreditTeacherModel } from './creditTeacher'
|
||||
export { CreditTypeModel } from './creditType'
|
||||
export { CurriculumPeriodModel } from './curriculumPeriod'
|
||||
export { EnrollmentModel } from './enrollment'
|
||||
export { OrganizationModel } from './organization'
|
||||
export { ProgrammeModuleModel } from './programmeModule'
|
||||
export { ProgrammeModuleChildModel } from './programmeModuleChild'
|
||||
export { SemesterModel } from './semester'
|
||||
export { SISStudyRightModel } from './SISStudyRight'
|
||||
export { SISStudyRightElementModel } from './SISStudyRightElement'
|
||||
export { StudentModel } from './student'
|
||||
export { StudyplanModel } from './studyplan'
|
||||
export { StudyrightExtentModel } from './studyrightExtent'
|
||||
export { TeacherModel } from './teacher'
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { CustomPopulationSearch, CustomPopulationSearchCreation } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'custom_population_search',
|
||||
tableName: 'custom_population_searches',
|
||||
})
|
||||
export class CustomPopulationSearch extends Model<
|
||||
InferAttributes<CustomPopulationSearch>,
|
||||
InferCreationAttributes<CustomPopulationSearch>
|
||||
> {
|
||||
export class CustomPopulationSearchModel
|
||||
extends Model<CustomPopulationSearch, CustomPopulationSearchCreation>
|
||||
implements CustomPopulationSearch
|
||||
{
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { Column, DataType, Model, PrimaryKey, Table, AutoIncrement, Default } from 'sequelize-typescript'
|
||||
|
||||
import type { ExcludedCourse, ExcludedCourseCreation } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'excluded_course',
|
||||
tableName: 'excluded_courses',
|
||||
})
|
||||
export class ExcludedCourse extends Model<InferAttributes<ExcludedCourse>, InferCreationAttributes<ExcludedCourse>> {
|
||||
export class ExcludedCourseModel extends Model<ExcludedCourse, ExcludedCourseCreation> implements ExcludedCourse {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.INTEGER)
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import { CustomPopulationSearch } from './customPopulationSearch'
|
||||
import { ExcludedCourse } from './excludedCourse'
|
||||
import { OpenUniPopulationSearch } from './openUniPopulationSearch'
|
||||
import { ProgressCriteria } from './progressCriteria'
|
||||
import { StudyGuidanceGroupTag } from './studyGuidanceGroupTag'
|
||||
import { StudyProgrammePin } from './studyProgrammePin'
|
||||
import { Tag } from './tag'
|
||||
import { TagStudent } from './tagStudent'
|
||||
|
||||
export {
|
||||
CustomPopulationSearch,
|
||||
ExcludedCourse,
|
||||
OpenUniPopulationSearch,
|
||||
ProgressCriteria,
|
||||
StudyGuidanceGroupTag,
|
||||
StudyProgrammePin,
|
||||
Tag,
|
||||
TagStudent,
|
||||
}
|
||||
export { CustomPopulationSearchModel } from './customPopulationSearch'
|
||||
export { ExcludedCourseModel } from './excludedCourse'
|
||||
export { OpenUniPopulationSearchModel } from './openUniPopulationSearch'
|
||||
export { ProgressCriteriaModel } from './progressCriteria'
|
||||
export { StudyGuidanceGroupTagModel } from './studyGuidanceGroupTag'
|
||||
export { StudyProgrammePinModel } from './studyProgrammePin'
|
||||
export { TagModel } from './tag'
|
||||
export { TagStudentModel } from './tagStudent'
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { OpenUniPopulationSearch, OpenUniPopulationSearchCreation } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
modelName: 'open_uni_population_search',
|
||||
tableName: 'open_uni_population_searches',
|
||||
})
|
||||
export class OpenUniPopulationSearch extends Model<
|
||||
InferAttributes<OpenUniPopulationSearch>,
|
||||
InferCreationAttributes<OpenUniPopulationSearch>
|
||||
> {
|
||||
export class OpenUniPopulationSearchModel
|
||||
extends Model<OpenUniPopulationSearch, OpenUniPopulationSearchCreation>
|
||||
implements OpenUniPopulationSearch
|
||||
{
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { ProgressCriteria, ProgressCriteriaCreation } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
modelName: 'progress_criteria',
|
||||
tableName: 'progress_criteria',
|
||||
})
|
||||
export class ProgressCriteria extends Model<
|
||||
InferAttributes<ProgressCriteria>,
|
||||
InferCreationAttributes<ProgressCriteria>
|
||||
> {
|
||||
export class ProgressCriteriaModel
|
||||
extends Model<ProgressCriteria, ProgressCriteriaCreation>
|
||||
implements ProgressCriteria
|
||||
{
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { CreationOptional, InferAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
|
||||
|
||||
import type { StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
modelName: 'study_guidance_group_tag',
|
||||
tableName: 'study_guidance_group_tags',
|
||||
})
|
||||
export class StudyGuidanceGroupTag extends Model<InferAttributes<StudyGuidanceGroupTag>> {
|
||||
export class StudyGuidanceGroupTagModel
|
||||
extends Model<StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation>
|
||||
implements StudyGuidanceGroupTag
|
||||
{
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { StudyProgrammePin } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
modelName: 'study_programme_pin',
|
||||
tableName: 'study_programme_pins',
|
||||
})
|
||||
export class StudyProgrammePin extends Model<InferAttributes<StudyProgrammePin>> {
|
||||
export class StudyProgrammePinModel extends Model<StudyProgrammePin> implements StudyProgrammePin {
|
||||
@PrimaryKey
|
||||
@Column(DataType.INTEGER)
|
||||
userId!: number
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import { TagStudent } from './tagStudent'
|
||||
import type { Tag, TagCreation, TagStudent } from '@oodikone/shared/models/kone'
|
||||
|
||||
import { TagStudentModel } from './tagStudent'
|
||||
|
||||
@Table({
|
||||
modelName: 'tag',
|
||||
tableName: 'tag',
|
||||
})
|
||||
export class Tag extends Model<InferAttributes<Tag>, InferCreationAttributes<Tag>> {
|
||||
export class TagModel extends Model<Tag, TagCreation> implements Tag {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@ForeignKey(() => TagStudent)
|
||||
@ForeignKey(() => TagStudentModel)
|
||||
@Column(DataType.BIGINT)
|
||||
tag_id!: CreationOptional<string>
|
||||
|
||||
@BelongsTo(() => TagStudent)
|
||||
@BelongsTo(() => TagStudentModel)
|
||||
tagStudent!: CreationOptional<TagStudent>
|
||||
|
||||
@Column(DataType.STRING)
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import { Student } from '../student'
|
||||
import { Tag } from './tag'
|
||||
import type { Tag, TagStudent, TagStudentCreation } from '@oodikone/shared/models/kone'
|
||||
|
||||
import { StudentModel } from '../student'
|
||||
import { TagModel } from './tag'
|
||||
|
||||
@Table({
|
||||
modelName: 'tag_student',
|
||||
tableName: 'tag_student',
|
||||
})
|
||||
export class TagStudent extends Model<InferAttributes<TagStudent>, InferCreationAttributes<TagStudent>> {
|
||||
export class TagStudentModel extends Model<TagStudent, TagStudentCreation> implements TagStudent {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@ForeignKey(() => Tag)
|
||||
@ForeignKey(() => TagModel)
|
||||
@Column(DataType.BIGINT)
|
||||
tag_id!: string
|
||||
|
||||
@PrimaryKey
|
||||
@ForeignKey(() => Student)
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
|
||||
@BelongsTo(() => Tag)
|
||||
@BelongsTo(() => TagModel)
|
||||
tag!: CreationOptional<Tag>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsToMany,
|
||||
Column,
|
||||
@@ -11,15 +10,20 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { Course, CourseProvider, ProgrammeModule, SISStudyRight } from '.'
|
||||
import type { Organization, ProgrammeModule, SISStudyRight } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
import { CourseProviderModel } from './courseProvider'
|
||||
import { ProgrammeModuleModel } from './programmeModule'
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'organization',
|
||||
tableName: 'organization',
|
||||
})
|
||||
export class Organization extends Model<InferAttributes<Organization>> {
|
||||
export class OrganizationModel extends Model<Organization> implements Organization {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
@@ -33,17 +37,17 @@ export class Organization extends Model<InferAttributes<Organization>> {
|
||||
@Column(DataType.STRING)
|
||||
parent_id!: string
|
||||
|
||||
@HasMany(() => Organization, { foreignKey: 'parent_id', as: 'children' })
|
||||
@HasMany(() => OrganizationModel, { foreignKey: 'parent_id', as: 'children' })
|
||||
children!: Organization[]
|
||||
|
||||
@HasMany(() => ProgrammeModule, { foreignKey: 'organization_id' })
|
||||
@HasMany(() => ProgrammeModuleModel, { foreignKey: 'organization_id' })
|
||||
programmeModules!: ProgrammeModule[]
|
||||
|
||||
@HasMany(() => SISStudyRight, { foreignKey: 'facultyCode', sourceKey: 'code' })
|
||||
@HasMany(() => SISStudyRightModel, { foreignKey: 'facultyCode', sourceKey: 'code' })
|
||||
SISStudyRights!: SISStudyRight[]
|
||||
|
||||
@BelongsToMany(() => Course, () => CourseProvider, 'organizationcode')
|
||||
courses!: Course[]
|
||||
@BelongsToMany(() => CourseModel, () => CourseProviderModel, 'organizationcode')
|
||||
courses!: CourseModel[]
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
BelongsToMany,
|
||||
@@ -12,25 +11,26 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { DegreeProgrammeType } from '../types'
|
||||
import { Organization } from './organization'
|
||||
import { ProgrammeModuleChild } from './programmeModuleChild'
|
||||
import type { Organization, ProgrammeModule } from '@oodikone/shared/models'
|
||||
import { DegreeProgrammeType, Name } from '@oodikone/shared/types'
|
||||
|
||||
import { OrganizationModel } from './organization'
|
||||
import { ProgrammeModuleChildModel } from './programmeModuleChild'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'programme_module',
|
||||
tableName: 'programme_modules',
|
||||
})
|
||||
export class ProgrammeModule extends Model<InferAttributes<ProgrammeModule>> {
|
||||
export class ProgrammeModuleModel extends Model<ProgrammeModule> implements ProgrammeModule {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
|
||||
@BelongsToMany(() => ProgrammeModule, () => ProgrammeModuleChild, 'child_id')
|
||||
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'child_id')
|
||||
parents!: ProgrammeModule[]
|
||||
|
||||
@BelongsToMany(() => ProgrammeModule, () => ProgrammeModuleChild, 'parent_id')
|
||||
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'parent_id')
|
||||
children!: ProgrammeModule[]
|
||||
|
||||
@Column(DataType.STRING)
|
||||
@@ -51,11 +51,11 @@ export class ProgrammeModule extends Model<InferAttributes<ProgrammeModule>> {
|
||||
@Column(DataType.STRING)
|
||||
studyLevel!: string
|
||||
|
||||
@ForeignKey(() => Organization)
|
||||
@ForeignKey(() => OrganizationModel)
|
||||
@Column(DataType.STRING)
|
||||
organization_id!: string
|
||||
|
||||
@BelongsTo(() => Organization, { foreignKey: 'organization_id' })
|
||||
@BelongsTo(() => OrganizationModel, { foreignKey: 'organization_id' })
|
||||
organization!: Organization
|
||||
|
||||
@Column(DataType.DATE)
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, ForeignKey, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { ProgrammeModule } from './programmeModule'
|
||||
import type { ProgrammeModuleChild } from '@oodikone/shared/models'
|
||||
|
||||
import { ProgrammeModuleModel } from './programmeModule'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'programme_module_child',
|
||||
tableName: 'programme_module_children',
|
||||
})
|
||||
export class ProgrammeModuleChild extends Model<InferAttributes<ProgrammeModuleChild>> {
|
||||
export class ProgrammeModuleChildModel extends Model<ProgrammeModuleChild> implements ProgrammeModuleChild {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
composite!: string
|
||||
|
||||
@ForeignKey(() => ProgrammeModule)
|
||||
@ForeignKey(() => ProgrammeModuleModel)
|
||||
@Column(DataType.STRING)
|
||||
parentId!: string
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import type { Semester } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'semester',
|
||||
tableName: 'semesters',
|
||||
})
|
||||
export class Semester extends Model<InferAttributes<Semester>> {
|
||||
export class SemesterModel extends Model<Semester> implements Semester {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
composite!: string
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Name } from '../shared/types'
|
||||
import { GenderCode } from '../types'
|
||||
import { Credit, Enrollment, SISStudyRight, Studyplan } from '.'
|
||||
import type { Student, Credit, Enrollment, SISStudyRight, Studyplan } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
import { GenderCode } from '@oodikone/shared/types'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { EnrollmentModel } from './enrollment'
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
import { StudyplanModel } from './studyplan'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'student',
|
||||
tableName: 'student',
|
||||
})
|
||||
export class Student extends Model<InferAttributes<Student>> {
|
||||
export class StudentModel extends Model<Student> implements Student {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
@@ -24,16 +28,16 @@ export class Student extends Model<InferAttributes<Student>> {
|
||||
@Column(DataType.STRING)
|
||||
abbreviatedname!: string
|
||||
|
||||
@HasMany(() => Enrollment, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
enrollments!: Enrollment[]
|
||||
|
||||
@HasMany(() => SISStudyRight, { foreignKey: 'studentNumber', sourceKey: 'studentnumber' })
|
||||
@HasMany(() => SISStudyRightModel, { foreignKey: 'studentNumber', sourceKey: 'studentnumber' })
|
||||
studyRights!: SISStudyRight[]
|
||||
|
||||
@HasMany(() => Studyplan, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
@HasMany(() => StudyplanModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
studyplans!: Studyplan[]
|
||||
|
||||
@HasMany(() => Credit, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
|
||||
@HasMany(() => CreditModel, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
|
||||
credits!: Credit[]
|
||||
|
||||
@Column(DataType.DATE)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
@@ -11,30 +10,33 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import { Student, SISStudyRight } from '.'
|
||||
import type { SISStudyRight, Student, Studyplan } from '@oodikone/shared/models'
|
||||
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
import { StudentModel } from './student'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'studyplan',
|
||||
tableName: 'studyplan',
|
||||
})
|
||||
export class Studyplan extends Model<InferAttributes<Studyplan>> {
|
||||
export class StudyplanModel extends Model<Studyplan> implements Studyplan {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
|
||||
@ForeignKey(() => Student)
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
|
||||
@BelongsTo(() => Student, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
|
||||
@ForeignKey(() => SISStudyRight)
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column(DataType.STRING)
|
||||
sis_study_right_id!: string
|
||||
|
||||
@BelongsTo(() => SISStudyRight, { foreignKey: 'sis_study_right_id', targetKey: 'id', as: 'studyRight' })
|
||||
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'sis_study_right_id', targetKey: 'id', as: 'studyRight' })
|
||||
studyRight!: SISStudyRight
|
||||
|
||||
@Column(DataType.STRING)
|
||||
@@ -43,6 +45,9 @@ export class Studyplan extends Model<InferAttributes<Studyplan>> {
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
included_courses!: string[]
|
||||
|
||||
@Column({ type: DataType.ARRAY(DataType.TEXT), field: 'included_modules' })
|
||||
includedModules!: string[]
|
||||
|
||||
@Column(DataType.STRING)
|
||||
sisu_id!: string
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { ExtentCode } from '../types'
|
||||
import type { StudyrightExtent } from '@oodikone/shared/models'
|
||||
import { ExtentCode } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'studyright_extent',
|
||||
tableName: 'studyright_extents',
|
||||
})
|
||||
export class StudyrightExtent extends Model<InferAttributes<StudyrightExtent>> {
|
||||
export class StudyrightExtentModel extends Model<StudyrightExtent> implements StudyrightExtent {
|
||||
@PrimaryKey
|
||||
@Column(DataType.INTEGER)
|
||||
extentcode!: ExtentCode
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { InferAttributes } from 'sequelize'
|
||||
import { BelongsToMany, Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import { Credit } from './credit'
|
||||
import { CreditTeacher } from './creditTeacher'
|
||||
import type { Teacher, Credit } from '@oodikone/shared/models'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { CreditTeacherModel } from './creditTeacher'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'teacher',
|
||||
tableName: 'teacher',
|
||||
})
|
||||
export class Teacher extends Model<InferAttributes<Teacher>> {
|
||||
export class TeacherModel extends Model<Teacher> implements Teacher {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
@@ -17,7 +18,7 @@ export class Teacher extends Model<InferAttributes<Teacher>> {
|
||||
@Column(DataType.STRING)
|
||||
name!: string
|
||||
|
||||
@BelongsToMany(() => Credit, () => CreditTeacher)
|
||||
@BelongsToMany(() => CreditModel, () => CreditTeacherModel)
|
||||
credits!: Credit[]
|
||||
|
||||
@CreatedAt
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { CreationOptional, InferAttributes, InferCreationAttributes } from 'sequelize'
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Default, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
|
||||
|
||||
import { Language } from '../shared/language'
|
||||
import { Role } from '../shared/types'
|
||||
import type { Language } from '@oodikone/shared/language'
|
||||
import type { User, UserCreation } from '@oodikone/shared/models/user'
|
||||
|
||||
import type { Role } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
@@ -10,7 +12,7 @@ import { Role } from '../shared/types'
|
||||
modelName: 'users',
|
||||
tableName: 'users',
|
||||
})
|
||||
export class User extends Model<InferAttributes<User>, InferCreationAttributes<User>> {
|
||||
export class UserModel extends Model<User, UserCreation> implements User {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const { orderBy } = require('lodash')
|
||||
|
||||
// dbConnections must be imported to avoid Sequelize errors even if it's not used
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { dbConnections } = require('../database/connection')
|
||||
const { computeCreditsProduced } = require('../services/providerCredits')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as Sentry from '@sentry/node'
|
||||
import compression from 'compression'
|
||||
import cors from 'cors'
|
||||
import express, { Express } from 'express'
|
||||
import { Express, json as jsonExpress } from 'express'
|
||||
|
||||
import { frontUrl, languageCenterViewEnabled, serviceProvider } from './config'
|
||||
import accessLogger from './middleware/accessLogger'
|
||||
@@ -39,11 +39,11 @@ import usersFd from './routes/usersFd'
|
||||
|
||||
const routes = (app: Express, url: string) => {
|
||||
app.use(cors({ credentials: true, origin: frontUrl }))
|
||||
app.use(express.json())
|
||||
app.use(jsonExpress())
|
||||
app.use(compression())
|
||||
|
||||
app.use(headersMiddleware)
|
||||
app.use(currentUserMiddleware)
|
||||
app.use(currentUserMiddleware) // eslint-disable-line @typescript-eslint/no-misused-promises
|
||||
app.use(accessLogger)
|
||||
app.use(`${url}/login`, login)
|
||||
app.use(url, courses)
|
||||
@@ -53,9 +53,9 @@ const routes = (app: Express, url: string) => {
|
||||
app.use(`${url}/semesters/codes`, semesters)
|
||||
app.use(url, tags)
|
||||
app.use(url, programmeModules)
|
||||
app.use(`${url}/v3/course_exclusions`, courseExclusions)
|
||||
app.use(`${url}/v3/course-exclusions`, courseExclusions)
|
||||
app.use(`${url}/v2/studyprogrammes`, studyProgramme)
|
||||
app.use(`${url}/programmecriteria`, studyProgrammeCriteria)
|
||||
app.use(`${url}/programme-criteria`, studyProgrammeCriteria)
|
||||
app.use(`${url}/openunisearch`, auth.roles(['openUniSearch']), customOpenUniSearch)
|
||||
app.use(`${url}/changelog`, changelog)
|
||||
app.use(`${url}/completedcoursessearch`, completedCoursesSearch)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import axios from 'axios'
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { Release } from '@oodikone/shared/types'
|
||||
import { isDev } from '../config'
|
||||
import { Release } from '../shared/types'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const changelog: { data?: Release[] } = {}
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
export type ChangelogResBody = Release[]
|
||||
|
||||
router.get<never, ChangelogResBody>('/', async (_req: Request, res: Response) => {
|
||||
if (changelog.data) {
|
||||
return res.status(200).send(changelog.data)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getCloseToGraduationData } from '../services/populations/closeToGraduation'
|
||||
import { findStudentsCloseToGraduation, getCloseToGraduationData } from '../services/populations/closeToGraduation'
|
||||
import { getAllStudentsUserHasInGroups } from '../services/studyGuidanceGroups'
|
||||
import { hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
type CloseToGraduationResBody = Awaited<ReturnType<typeof findStudentsCloseToGraduation>>
|
||||
|
||||
router.get<never, CloseToGraduationResBody>('/', async (req, res) => {
|
||||
const { user } = req
|
||||
if (hasFullAccessToStudentData(user.roles)) {
|
||||
const result = await getCloseToGraduationData()
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
import { difference, intersection } from 'lodash'
|
||||
|
||||
import { getCompletedCourses } from '../services/completedCoursesSearch'
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { tryCatch } from '@oodikone/shared/util'
|
||||
import { Courses, FormattedStudent, getCompletedCourses } from '../services/completedCoursesSearch'
|
||||
import {
|
||||
getOpenUniSearches,
|
||||
createNewSearch,
|
||||
deleteSearch,
|
||||
updateSearch,
|
||||
FoundSearch,
|
||||
} from '../services/openUni/openUniManageSearches'
|
||||
import { hasFullAccessToStudentData } from '../util'
|
||||
import { hasFullAccessToStudentData, safeJSONParse } from '../util'
|
||||
import { getImporterClient } from '../util/importerClient'
|
||||
import logger from '../util/logger'
|
||||
|
||||
@@ -16,17 +19,22 @@ const router = Router()
|
||||
|
||||
const importerClient = getImporterClient()
|
||||
|
||||
interface GetSearchRequest extends Request {
|
||||
query: {
|
||||
studentlist: string
|
||||
courselist: string
|
||||
}
|
||||
export type SearchResBody = CanError<
|
||||
{ discardedStudentNumbers: string[] } & { students: Omit<FormattedStudent, 'allEnrollments'>[]; courses: Courses }
|
||||
>
|
||||
export type SearchReqBody = never
|
||||
export type SearchQuery = {
|
||||
studentlist: string
|
||||
courselist: string
|
||||
}
|
||||
|
||||
router.get('/', async (req: GetSearchRequest, res: Response) => {
|
||||
const studentNumbers = JSON.parse(req.query?.studentlist) || []
|
||||
const courseCodes = JSON.parse(req.query?.courselist) || []
|
||||
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
|
||||
const { studentlist, courselist } = req.query
|
||||
const studentNumbers: string[] = (await safeJSONParse(studentlist)) ?? []
|
||||
const courseCodes: string[] = (await safeJSONParse(courselist)) ?? []
|
||||
|
||||
const { roles, studentsUserCanAccess, sisPersonId: teacherId } = req.user
|
||||
|
||||
if (!Array.isArray(studentNumbers)) {
|
||||
return res.status(400).json({ error: 'Student numbers must be of type array' })
|
||||
}
|
||||
@@ -34,48 +42,60 @@ router.get('/', async (req: GetSearchRequest, res: Response) => {
|
||||
return res.status(400).json({ error: 'Courses must be of type array' })
|
||||
}
|
||||
|
||||
const answerTimeout = new Promise(resolve => setTimeout(resolve, 6000))
|
||||
const answerTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), 6000))
|
||||
|
||||
try {
|
||||
// Teachers can also get rights to students via Importer if
|
||||
// the students have enrolled to their courses in last 8 months
|
||||
// (acual logic a bit different, see Importer)
|
||||
const teacherRightsToStudents = (await Promise.race([
|
||||
importerClient!.post('/teacher-rights/', { teacherId, studentNumbers }),
|
||||
answerTimeout,
|
||||
])) as { data: string[] }
|
||||
if (teacherRightsToStudents && Array.isArray(teacherRightsToStudents.data)) {
|
||||
studentsUserCanAccess.push(...teacherRightsToStudents.data)
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Teachers can also get rights to students via Importer if
|
||||
// the students have enrolled to their courses in last 8 months
|
||||
// (acual logic a bit different, see Importer)
|
||||
const { data: teacherRightsToStudents, error } = importerClient
|
||||
? await tryCatch<{ data: string[] }>(
|
||||
Promise.race([
|
||||
importerClient.post('/teacher-rights/', { teacherId, studentNumbers }),
|
||||
answerTimeout as Promise<never>, // This will always reject with an Error
|
||||
])
|
||||
)
|
||||
: { data: null, error: null }
|
||||
|
||||
if (error) {
|
||||
logger.error(`Importer teacher-rights request failed with message: ${error?.message}`)
|
||||
} else if (teacherRightsToStudents && Array.isArray(teacherRightsToStudents.data)) {
|
||||
studentsUserCanAccess.push(...teacherRightsToStudents.data)
|
||||
}
|
||||
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentNumbers
|
||||
: intersection(studentNumbers, studentsUserCanAccess)
|
||||
|
||||
const completedCourses = await getCompletedCourses(filteredStudentNumbers, courseCodes)
|
||||
const discardedStudentNumbers = difference(
|
||||
studentNumbers,
|
||||
completedCourses.students.map(student => student.studentNumber)
|
||||
)
|
||||
|
||||
return res.json({ discardedStudentNumbers, ...completedCourses })
|
||||
})
|
||||
|
||||
router.get('/searches', async (req: Request, res: Response) => {
|
||||
export type GetSearchResBody = FoundSearch[]
|
||||
|
||||
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
|
||||
const userId = req.user.id
|
||||
const foundSearches = await getOpenUniSearches(userId)
|
||||
return res.json(foundSearches)
|
||||
})
|
||||
|
||||
interface CreateSearchRequest extends Request {
|
||||
body: {
|
||||
courselist: string[]
|
||||
name: string
|
||||
}
|
||||
export type CreateSearchResBody = CanError<{
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
courseList: string[]
|
||||
updatedAt: string
|
||||
}>
|
||||
export type CreateSearchReqBody = {
|
||||
courselist: string[]
|
||||
name: string
|
||||
}
|
||||
|
||||
router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
|
||||
router.post<never, CreateSearchResBody, CreateSearchReqBody>('/searches', async (req, res) => {
|
||||
const courseCodes = req.body?.courselist || []
|
||||
const name = req.body?.name
|
||||
const userId = req.user.id
|
||||
@@ -98,26 +118,30 @@ router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
|
||||
})
|
||||
})
|
||||
|
||||
interface UpdateSearchRequest extends Request {
|
||||
body: {
|
||||
courselist: string[]
|
||||
}
|
||||
params: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
export type UpdateSearchParams = { id: string }
|
||||
export type UpdateSearchResBody = CanError<{
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
courseList: string[]
|
||||
updatedAt: string
|
||||
} | void>
|
||||
export type UpdateSearchReqBody = { courselist: string[] }
|
||||
|
||||
router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searches/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { courselist: courseCodes = [] } = req.body
|
||||
const { id: userId } = req.user
|
||||
|
||||
router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
|
||||
const id = req.params?.id
|
||||
const courseCodes = req.body?.courselist || []
|
||||
const userId = req.user.id
|
||||
if (!id || !userId) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const updatedSearch = await updateSearch(userId, id, courseCodes)
|
||||
if (!updatedSearch) {
|
||||
return res.status(404).json({ error: 'Courselist could not be found' })
|
||||
}
|
||||
|
||||
return res.json({
|
||||
id: updatedSearch.id,
|
||||
userId: updatedSearch.userId,
|
||||
@@ -127,22 +151,22 @@ router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
|
||||
})
|
||||
})
|
||||
|
||||
interface DeleteSearchRequest extends Request {
|
||||
params: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
export type DeleteSearchParams = { id: string }
|
||||
export type DeleteSearchResBody = CanError<string | void>
|
||||
|
||||
router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { id: userId } = req.user
|
||||
|
||||
router.delete('/searches/:id', async (req: DeleteSearchRequest, res: Response) => {
|
||||
const id = req.params?.id
|
||||
const userId = req.user.id
|
||||
if (!id || !userId) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const deletedSearch = await deleteSearch(userId, id)
|
||||
if (!deletedSearch) {
|
||||
return res.status(404).json({ error: 'Courselist could not be found' })
|
||||
}
|
||||
|
||||
return res.json(id)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { addExcludedCourses, removeExcludedCourses } from '../services/excludedCourses'
|
||||
|
||||
const router = Router()
|
||||
|
||||
interface ExcludedCoursesRequest extends Request {
|
||||
body: {
|
||||
courseCodes: string[]
|
||||
curriculumVersion: string
|
||||
}
|
||||
params: {
|
||||
code: string
|
||||
}
|
||||
export type ExcludedCoursesParams = { code: string }
|
||||
export type ExcludedCoursesResBody = void
|
||||
export type ExcludedCoursesReqBody = {
|
||||
courseCodes: string[]
|
||||
curriculumVersion: string
|
||||
}
|
||||
|
||||
router.post('/:code', async (req: ExcludedCoursesRequest, res: Response) => {
|
||||
router.post<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/:code', async (req, res) => {
|
||||
const { code: programmeCode } = req.params
|
||||
const { courseCodes, curriculumVersion } = req.body
|
||||
const result = await addExcludedCourses(programmeCode, courseCodes, curriculumVersion)
|
||||
const result = await addExcludedCourses(courseCodes, curriculumVersion, programmeCode)
|
||||
if (!result) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
res.json(result)
|
||||
res.status(201).end()
|
||||
})
|
||||
|
||||
router.delete('/:code', async (req: ExcludedCoursesRequest, res: Response) => {
|
||||
router.delete<ExcludedCoursesParams, ExcludedCoursesResBody, ExcludedCoursesReqBody>('/:code', async (req, res) => {
|
||||
const { code: programmeCode } = req.params
|
||||
const { courseCodes, curriculumVersion } = req.body
|
||||
const result = await removeExcludedCourses(programmeCode, courseCodes, curriculumVersion)
|
||||
const result = await removeExcludedCourses(courseCodes, curriculumVersion, programmeCode)
|
||||
if (!result) {
|
||||
res.status(400).end()
|
||||
return
|
||||
}
|
||||
res.json(result)
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,102 +1,109 @@
|
||||
import crypto from 'crypto'
|
||||
import { Request, Response, Router } from 'express'
|
||||
import moment from 'moment'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { getCourseYearlyStats } from '../services/courses'
|
||||
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
|
||||
import { CourseWithSubsId } from '../types/course'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
interface GetCoursesRequest extends Request {
|
||||
query: {
|
||||
name: string
|
||||
code: string
|
||||
combineSubstitutions: string
|
||||
}
|
||||
type CoursesMultiReqBody = never
|
||||
type CoursesMultiResBody = { courses: CourseWithSubsId[] }
|
||||
type CoursesMultiQuery = {
|
||||
name: string
|
||||
code: string
|
||||
combineSubstitutions: string
|
||||
}
|
||||
|
||||
router.get('/v2/coursesmulti', async (req: GetCoursesRequest, res: Response) => {
|
||||
const { name, code, combineSubstitutions } = req.query
|
||||
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
|
||||
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
|
||||
}
|
||||
|
||||
const courses = await getCoursesByNameAndOrCode(name, code)
|
||||
|
||||
if (combineSubstitutions === 'false') {
|
||||
const courseCodes = courses.map(course => course.code)
|
||||
const substitutions = [
|
||||
...new Set(
|
||||
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
|
||||
),
|
||||
]
|
||||
const substitutionDetails = await getCoursesByCodes(substitutions)
|
||||
for (const substitution of substitutionDetails) {
|
||||
courses.push(substitution.toJSON())
|
||||
router.get<never, CanError<CoursesMultiResBody>, CoursesMultiReqBody, CoursesMultiQuery>(
|
||||
'/v2/coursesmulti',
|
||||
async (req, res) => {
|
||||
const { name, code, combineSubstitutions } = req.query
|
||||
if (!(validateParamLength(name, 5) || validateParamLength(code, 2))) {
|
||||
return res.status(400).json({ error: 'Query parameter name or code is invalid' })
|
||||
}
|
||||
}
|
||||
|
||||
const mergedCourses = {}
|
||||
const courses: CourseWithSubsId[] = await getCoursesByNameAndOrCode(name, code)
|
||||
|
||||
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
|
||||
if (combineSubstitutions === 'false') {
|
||||
const courseCodes = courses.map(course => course.code)
|
||||
const substitutions = [
|
||||
...new Set(
|
||||
courses.flatMap(course => course.substitutions).filter(code => code !== null && !courseCodes.includes(code))
|
||||
),
|
||||
]
|
||||
const substitutionDetails = await getCoursesByCodes(substitutions)
|
||||
for (const substitution of substitutionDetails) {
|
||||
courses.push(substitution.toJSON())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ courses: Object.values(mergedCourses) })
|
||||
})
|
||||
const mergedCourses: Record<string, CourseWithSubsId> = {}
|
||||
|
||||
interface GetCourseYearlyStatsRequest extends Request {
|
||||
query: {
|
||||
codes: string[]
|
||||
separate: string
|
||||
combineSubstitutions: string
|
||||
for (const course of courses) {
|
||||
const groupId = combineSubstitutions === 'true' ? (course.subsId?.toString() ?? '') : course.code
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) {
|
||||
continue
|
||||
}
|
||||
if (!mergedCourses[groupId]) {
|
||||
mergedCourses[groupId] = {
|
||||
...course,
|
||||
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
|
||||
}
|
||||
} else {
|
||||
const mergedCourse = mergedCourses[groupId]
|
||||
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
|
||||
mergedCourse.max_attainment_date = course.max_attainment_date
|
||||
}
|
||||
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
|
||||
mergedCourse.min_attainment_date = course.min_attainment_date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ courses: Object.values(mergedCourses) })
|
||||
}
|
||||
)
|
||||
|
||||
type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
|
||||
type CourseYearlyStatsReqBody = never
|
||||
type CourseYearlyStatsQuery = {
|
||||
codes: string[]
|
||||
separate: string
|
||||
combineSubstitutions: string
|
||||
}
|
||||
|
||||
router.get('/v3/courseyearlystats', async (req: GetCourseYearlyStatsRequest, res: Response) => {
|
||||
const { roles, programmeRights } = req.user
|
||||
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
|
||||
'/v3/courseyearlystats',
|
||||
async (req, res) => {
|
||||
const { roles, programmeRights } = req.user
|
||||
|
||||
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
|
||||
const userHasCorrectRole = userHasFullAccessToStudentData === true || roles.includes('courseStatistics')
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
|
||||
const userHasCorrectRole = userHasFullAccessToStudentData === true || roles.includes('courseStatistics')
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
// If user has rights to see at least one programme, then they are allowed to see all of them
|
||||
if (!userHasCorrectRole && fullStudyProgrammeRights.length === 0) {
|
||||
return res.status(403).json({ error: 'No programmes so no access to course stats' })
|
||||
// If user has rights to see at least one programme, then they are allowed to see all of them
|
||||
if (!userHasCorrectRole && fullStudyProgrammeRights.length === 0) {
|
||||
return res.status(403).json({ error: 'No programmes so no access to course stats' })
|
||||
}
|
||||
|
||||
const { codes } = req.query
|
||||
if (!codes) {
|
||||
return res.status(422).send({ error: 'Missing required query parameters' })
|
||||
}
|
||||
|
||||
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
|
||||
const separate = req.query.separate === 'true'
|
||||
|
||||
// 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)
|
||||
res.json(results)
|
||||
}
|
||||
|
||||
const { codes } = req.query
|
||||
if (!codes) {
|
||||
return res.status(422).send('Missing required query parameters')
|
||||
}
|
||||
|
||||
const combineSubstitutions = req.query.combineSubstitutions !== 'false'
|
||||
const separate = req.query.separate === 'true'
|
||||
|
||||
// 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)
|
||||
res.json(results)
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CurriculumPeriod } from '../models'
|
||||
import { CurriculumPeriod } from '@oodikone/shared/models'
|
||||
import { CurriculumPeriodModel } from '../models'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const result = await CurriculumPeriod.findAll()
|
||||
type CurriculumPeriodsResBody = CurriculumPeriod[]
|
||||
|
||||
router.get<never, CurriculumPeriodsResBody>('/', async (_req, res) => {
|
||||
const result: CurriculumPeriod[] = await CurriculumPeriodModel.findAll({
|
||||
raw: true,
|
||||
})
|
||||
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import {
|
||||
getOpenUniSearches,
|
||||
createNewSearch,
|
||||
@@ -7,59 +8,74 @@ import {
|
||||
updateSearch,
|
||||
} from '../services/openUni/openUniManageSearches'
|
||||
import { getCustomOpenUniCourses } from '../services/openUni/openUniStats'
|
||||
import { safeJSONParse } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
interface GetSearchRequest extends Request {
|
||||
query: {
|
||||
courselist: string
|
||||
startdate: string
|
||||
enddate: string
|
||||
}
|
||||
type SearchResBody = CanError<Awaited<ReturnType<typeof getCustomOpenUniCourses>>>
|
||||
type SearchReqBody = never
|
||||
type SearchQuery = {
|
||||
courselist: string
|
||||
startdate: string
|
||||
enddate: string
|
||||
}
|
||||
|
||||
router.get('/', async (req: GetSearchRequest, res: Response) => {
|
||||
const courseCodes = JSON.parse(req.query?.courselist) || []
|
||||
const startDate = req.query?.startdate !== 'null' ? new Date(req.query.startdate) : new Date(2017, 7, 1, 0, 0, 0)
|
||||
const endDate = req.query?.enddate !== 'null' ? new Date(req.query.enddate) : new Date()
|
||||
if (req.query?.enddate === 'null') {
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
}
|
||||
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
|
||||
const { courselist, startdate, enddate } = req.query
|
||||
|
||||
const courseCodes = (await safeJSONParse(courselist)) ?? []
|
||||
if (!Array.isArray(courseCodes)) {
|
||||
return res.status(400).json({ error: 'Courses must be of type array' })
|
||||
}
|
||||
|
||||
const startDate = startdate !== 'null' ? new Date(startdate) : new Date(2017, 7, 1, 0, 0, 0)
|
||||
const endDate = enddate !== 'null' ? new Date(enddate) : new Date()
|
||||
if (enddate === 'null') {
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
}
|
||||
|
||||
const customOpenUniSearches = await getCustomOpenUniCourses(courseCodes, startDate, endDate)
|
||||
return res.json(customOpenUniSearches)
|
||||
})
|
||||
|
||||
router.get('/searches', async (req: Request, res: Response) => {
|
||||
const userId = req.user.id
|
||||
type GetSearchResBody = Awaited<ReturnType<typeof getOpenUniSearches>>
|
||||
router.get<never, GetSearchResBody>('/searches', async (req, res) => {
|
||||
const { id: userId } = req.user
|
||||
const foundSearches = await getOpenUniSearches(userId)
|
||||
return res.json(foundSearches)
|
||||
})
|
||||
|
||||
interface CreateSearchRequest extends Request {
|
||||
body: {
|
||||
courselist: string[]
|
||||
name: string
|
||||
}
|
||||
type CreateSearchResBody = CanError<{
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
courseList: string[]
|
||||
updatedAt: string
|
||||
}>
|
||||
type CreateSearchReqBody = {
|
||||
courselist: string[]
|
||||
name: string
|
||||
}
|
||||
|
||||
router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
|
||||
const courseCodes = req.body?.courselist || []
|
||||
const name = req.body?.name
|
||||
const userId = req.user.id
|
||||
router.post<never, CreateSearchResBody, CreateSearchReqBody>('/searches', async (req, res) => {
|
||||
const { courselist, name } = req.body
|
||||
const { id: userId } = req.user
|
||||
|
||||
const courseCodes = courselist || []
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name missing' })
|
||||
}
|
||||
|
||||
if (courseCodes && !Array.isArray(courseCodes)) {
|
||||
return res.status(400).json({ error: 'Course codes must be type of array' })
|
||||
}
|
||||
|
||||
const createdSearch = await createNewSearch(userId, name, courseCodes)
|
||||
if (!createdSearch) {
|
||||
return res.status(400).json({ error: 'Failed to create search' })
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
id: createdSearch.id,
|
||||
userId: createdSearch.userId,
|
||||
@@ -69,26 +85,36 @@ router.post('/searches', async (req: CreateSearchRequest, res: Response) => {
|
||||
})
|
||||
})
|
||||
|
||||
interface UpdateSearchRequest extends Request {
|
||||
body: {
|
||||
courselist: string[]
|
||||
}
|
||||
params: {
|
||||
id: string
|
||||
}
|
||||
type UpdateSearchParams = {
|
||||
id: string
|
||||
}
|
||||
type UpdateSearchResBody = CanError<{
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
courseList: string[]
|
||||
updatedAt: string
|
||||
}>
|
||||
type UpdateSearchReqBody = {
|
||||
courselist: string[]
|
||||
}
|
||||
|
||||
router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
|
||||
const id = req.params?.id
|
||||
const courseCodes = req.body?.courselist || []
|
||||
const userId = req.user.id
|
||||
router.put<UpdateSearchParams, UpdateSearchResBody, UpdateSearchReqBody>('/searches/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { courselist } = req.body
|
||||
const { id: userId } = req.user
|
||||
|
||||
const courseCodes = courselist || []
|
||||
|
||||
if (!id || !userId) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const updatedSearch = await updateSearch(userId, id, courseCodes)
|
||||
if (!updatedSearch) {
|
||||
return res.status(404).json({ error: 'Open uni search could not be found' })
|
||||
}
|
||||
|
||||
return res.json({
|
||||
id: updatedSearch.id,
|
||||
userId: updatedSearch.userId,
|
||||
@@ -98,22 +124,24 @@ router.put('/searches/:id', async (req: UpdateSearchRequest, res: Response) => {
|
||||
})
|
||||
})
|
||||
|
||||
interface DeleteSearchRequest extends Request {
|
||||
params: {
|
||||
id: string
|
||||
}
|
||||
type DeleteSearchParams = {
|
||||
id: string
|
||||
}
|
||||
type DeleteSearchResBody = CanError<string>
|
||||
|
||||
router.delete<DeleteSearchParams, DeleteSearchResBody>('/searches/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { id: userId } = req.user
|
||||
|
||||
router.delete('/searches/:id', async (req: DeleteSearchRequest, res: Response) => {
|
||||
const id = req.params?.id
|
||||
const userId = req.user.id
|
||||
if (!id || !userId) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
const deletedSearch = await deleteSearch(userId, id)
|
||||
if (!deletedSearch) {
|
||||
return res.status(404).json({ error: 'Open uni search could not be found' })
|
||||
}
|
||||
|
||||
return res.json(id)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CustomPopulationSearch } from '@oodikone/shared/models/kone'
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import {
|
||||
getCustomPopulationSearchesByUser,
|
||||
createCustomPopulationSearch,
|
||||
@@ -9,65 +11,92 @@ import {
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
type GetCustomPopulationSearchResBody = CustomPopulationSearch[]
|
||||
|
||||
router.get<never, GetCustomPopulationSearchResBody>('/', async (req, res) => {
|
||||
const { id } = req.user
|
||||
const customPopulationSearches = await getCustomPopulationSearchesByUser(id)
|
||||
res.json(customPopulationSearches)
|
||||
})
|
||||
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
const { name, students } = req.body
|
||||
const { id } = req.user
|
||||
type PostCustomPopulationSearchReqBody = {
|
||||
name: string
|
||||
students: string[]
|
||||
}
|
||||
type PostCustomPopulationSearchResBody = CustomPopulationSearch
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name missing' })
|
||||
router.post<never, CanError<PostCustomPopulationSearchResBody>, PostCustomPopulationSearchReqBody>(
|
||||
'/',
|
||||
async (req, res) => {
|
||||
const { name, students } = req.body
|
||||
const { id } = req.user
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name missing' })
|
||||
}
|
||||
if (students && !Array.isArray(students)) {
|
||||
return res.status(400).json({ error: 'Students must be of type array' })
|
||||
}
|
||||
|
||||
const customPopulationSearch = await createCustomPopulationSearch(name, id, students ?? [])
|
||||
res.json(customPopulationSearch)
|
||||
}
|
||||
if (students && !Array.isArray(students)) {
|
||||
return res.status(400).json({ error: 'Students must be of type array' })
|
||||
)
|
||||
|
||||
type PutCustomPopulationSearchReqBody = {
|
||||
students: string[]
|
||||
}
|
||||
type PutCustomPopulationSearchResBody = CustomPopulationSearch
|
||||
|
||||
router.put<never, CanError<PutCustomPopulationSearchResBody>, PutCustomPopulationSearchReqBody>(
|
||||
'/:id',
|
||||
async (req, res) => {
|
||||
const { students } = req.body
|
||||
const { id } = req.params
|
||||
const { id: userId } = req.user
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Id missing' })
|
||||
}
|
||||
if (!students) {
|
||||
return res.status(400).json({ error: 'Students missing' })
|
||||
}
|
||||
if (!Array.isArray(students)) {
|
||||
return res.status(400).json({ error: 'Students must be of type array' })
|
||||
}
|
||||
|
||||
const updatedPopulationSearch = await updateCustomPopulationSearch(userId, id, students)
|
||||
if (!updatedPopulationSearch) {
|
||||
return res.status(404).json({ error: 'Custom population search not found' })
|
||||
}
|
||||
|
||||
res.json(updatedPopulationSearch)
|
||||
}
|
||||
)
|
||||
|
||||
const customPopulationSearch = await createCustomPopulationSearch(name, id, students || [])
|
||||
res.json(customPopulationSearch)
|
||||
})
|
||||
type DeleteCustomPopulationSearchParams = {
|
||||
id: string
|
||||
}
|
||||
type DeleteCustomPopulationSearchResBody = string
|
||||
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
const { students } = req.body
|
||||
const { id } = req.params
|
||||
const userId = req.user.id
|
||||
router.delete<never, CanError<DeleteCustomPopulationSearchResBody>, never, DeleteCustomPopulationSearchParams>(
|
||||
'/:id',
|
||||
async (req, res) => {
|
||||
const { id } = req.params
|
||||
const userId = req.user.id
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Id missing' })
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Id missing' })
|
||||
}
|
||||
|
||||
const deletedSuccessfully = (await deleteCustomPopulationSearch(userId, id)) > 0
|
||||
|
||||
if (!deletedSuccessfully) {
|
||||
return res.status(404).json({ error: 'Custom population search not found' })
|
||||
}
|
||||
|
||||
res.json(id)
|
||||
}
|
||||
if (!students) {
|
||||
return res.status(400).json({ error: 'Students missing' })
|
||||
}
|
||||
if (!Array.isArray(students)) {
|
||||
return res.status(400).json({ error: 'Students must be of type array' })
|
||||
}
|
||||
|
||||
const updatedPopulationSearch = await updateCustomPopulationSearch(userId, id, students)
|
||||
if (!updatedPopulationSearch) {
|
||||
return res.status(404).json({ error: 'Custom population search not found' })
|
||||
}
|
||||
|
||||
res.json(updatedPopulationSearch)
|
||||
})
|
||||
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
const { id } = req.params
|
||||
const userId = req.user.id
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Id missing' })
|
||||
}
|
||||
|
||||
const deletedSuccessfully = (await deleteCustomPopulationSearch(userId, id)) > 0
|
||||
|
||||
if (!deletedSuccessfully) {
|
||||
return res.status(404).json({ error: 'Custom population search not found' })
|
||||
}
|
||||
|
||||
res.json(id)
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '@oodikone/shared/types'
|
||||
import * as auth from '../middleware/auth'
|
||||
import { getDegreeProgrammesOfFaculty, getFacultyCodeById } from '../services/faculty/faculty'
|
||||
import { combineFacultyBasics } from '../services/faculty/facultyBasics'
|
||||
@@ -22,7 +23,6 @@ import { combineFacultyStudentProgress } from '../services/faculty/facultyStuden
|
||||
import { combineFacultyStudents } from '../services/faculty/facultyStudents'
|
||||
import { combineFacultyThesisWriters } from '../services/faculty/facultyThesisWriters'
|
||||
import { updateFacultyOverview, updateFacultyProgressOverview } from '../services/faculty/facultyUpdates'
|
||||
import { Graduated, ProgrammeFilter, SpecialGroups, StatsType, YearType } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
// Faculty uses a lot of tools designed for Study programme.
|
||||
@@ -193,6 +193,7 @@ router.get(
|
||||
|
||||
const { graduated, special_groups: specialGroups } = req.query
|
||||
|
||||
/* Fetch cached data if exists */
|
||||
const data = await getFacultyStudentStats(code, specialGroups, graduated)
|
||||
if (data) {
|
||||
return res.json(data)
|
||||
@@ -203,9 +204,10 @@ router.get(
|
||||
return res.status(422).end()
|
||||
}
|
||||
|
||||
let updatedStats: any = await combineFacultyStudents(code, newProgrammes, specialGroups, graduated)
|
||||
const updatedStats = await combineFacultyStudents(code, newProgrammes, specialGroups, graduated)
|
||||
|
||||
if (updatedStats) {
|
||||
updatedStats = await setFacultyStudentStats(updatedStats, specialGroups, graduated)
|
||||
void setFacultyStudentStats(updatedStats, specialGroups, graduated)
|
||||
}
|
||||
return res.json(updatedStats)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { omit } from 'lodash'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { omitKeys } from '@oodikone/shared/util'
|
||||
import { FormattedUser } from '../types/user'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
type LoginReqBody = never
|
||||
type LoginResBody = {
|
||||
user: Omit<FormattedUser, 'studentsUserCanAccess'>
|
||||
logoutUrl?: string
|
||||
}
|
||||
|
||||
router.get<never, CanError<LoginResBody>, LoginReqBody>('/', (req, res) => {
|
||||
const { user, logoutUrl } = req
|
||||
|
||||
if (!user) {
|
||||
@@ -13,7 +21,7 @@ router.get('/', (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
res.send({
|
||||
user: omit(user, ['studentsUserCanAccess']),
|
||||
user: omitKeys(user, ['studentsUserCanAccess']),
|
||||
logoutUrl,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,300 +1,96 @@
|
||||
import * as Sentry from '@sentry/node'
|
||||
import crypto from 'crypto'
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
import { difference, intersection, uniq } from 'lodash'
|
||||
|
||||
import type { CanError } from '@oodikone/shared/routes'
|
||||
import {
|
||||
type PopulationstatisticsQuery,
|
||||
type PopulationstatisticsReqBody,
|
||||
type PopulationstatisticsResBody,
|
||||
type PopulationstatisticsbycourseResBody,
|
||||
type PopulationstatisticsbycourseReqBody,
|
||||
type PopulationstatisticsbycourseParams,
|
||||
type PostByStudentNumbersResBody,
|
||||
type PostByStudentNumbersReqBody,
|
||||
type PopulationstatisticsStudyprogrammesResBody,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormQuery,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
|
||||
} from '@oodikone/shared/routes/populations'
|
||||
import { GenderCode, Unification, Unarray } from '@oodikone/shared/types'
|
||||
import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { rootOrgId } from '../config'
|
||||
import { SISStudyRight } from '../models'
|
||||
import { SISStudyRightModel } from '../models'
|
||||
import { maxYearsToCreatePopulationFrom, getCourseProvidersForCourses } from '../services/courses'
|
||||
import { encrypt, decrypt } from '../services/encrypt'
|
||||
import { encrypt } from '../services/encrypt'
|
||||
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from '../services/faculty/faculty'
|
||||
import { bottlenecksOf } from '../services/populations/bottlenecksOf'
|
||||
import { optimizedStatisticsOf } from '../services/populations/optimizedStatisticsOf'
|
||||
import { populationStudentsMerger, populationCourseStatsMerger } from '../services/statMerger'
|
||||
import { findByTag, findByCourseAndSemesters } from '../services/students'
|
||||
import { mapToProviders } from '../shared/util'
|
||||
import { GenderCode, ParsedCourse, Unarray, Unification, UnifyStatus } from '../types'
|
||||
import { getStudentTags } from '../services/populations/getStudentData'
|
||||
import { parseDateRangeFromParams } from '../services/populations/shared'
|
||||
import { statisticsOf } from '../services/populations/statisticsOf'
|
||||
import { getStudentNumbersWithStudyRights } from '../services/populations/studentNumbersWithStudyRights'
|
||||
import { findByCourseAndSemesters } from '../services/students'
|
||||
import { ParsedCourse } from '../types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const filterPersonalTags = (population: Record<string, any>, userId: string) => {
|
||||
return {
|
||||
...population,
|
||||
students: population.students.map(student => {
|
||||
student.tags = student.tags.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId)
|
||||
return student
|
||||
}),
|
||||
}
|
||||
}
|
||||
router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
|
||||
'/v3/populationstatistics',
|
||||
async (req, res) => {
|
||||
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights } = req.user
|
||||
const { years, semesters, programme, combinedProgramme, studentStatuses } = req.query
|
||||
|
||||
const isEncryptedStudent = (student?: string | EncryptedStudent) => {
|
||||
return (student as EncryptedStudent)?.encryptedData !== undefined
|
||||
}
|
||||
|
||||
type EncryptedStudent = { iv: string; encryptedData: string }
|
||||
|
||||
interface PostPopulationStatisticsRequest extends Request {
|
||||
body: {
|
||||
year: string
|
||||
semesters: string[]
|
||||
studyRights: string | string[]
|
||||
months: number
|
||||
selectedStudents: string[] | EncryptedStudent[]
|
||||
selectedStudentsByYear?: { [year: string]: string[] }
|
||||
studentStatuses?: string[]
|
||||
years?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
// POST instead of GET because of too long params and "sensitive" data
|
||||
router.post('/v2/populationstatistics/courses', async (req: PostPopulationStatisticsRequest, res: Response) => {
|
||||
if (!req.body.year || !req.body.semesters || !req.body.studyRights) {
|
||||
Sentry.captureException(new Error('The body should have a year, semester and study rights defined'))
|
||||
return res.status(400).json({ error: 'The body should have a year, semester and study rights defined' })
|
||||
}
|
||||
|
||||
if (!Array.isArray(req.body.studyRights)) {
|
||||
req.body.studyRights = [req.body.studyRights]
|
||||
}
|
||||
|
||||
if (req.body.months == null) {
|
||||
req.body.months = 12
|
||||
}
|
||||
|
||||
const encrypted = isEncryptedStudent(req.body.selectedStudents[0])
|
||||
|
||||
if (
|
||||
!encrypted &&
|
||||
!hasFullAccessToStudentData(req.user.roles) &&
|
||||
req.body.selectedStudents.some(student => !req.user.studentsUserCanAccess.includes(student))
|
||||
) {
|
||||
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
|
||||
}
|
||||
|
||||
if (!req.body.semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
|
||||
return res.status(400).json({ error: 'Semester should be either SPRING OR FALL' })
|
||||
}
|
||||
|
||||
if (
|
||||
req.body.studentStatuses &&
|
||||
!req.body.studentStatuses.every(
|
||||
status => status === 'EXCHANGE' || status === 'NONDEGREE' || status === 'TRANSFERRED'
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
|
||||
}
|
||||
|
||||
if (req.body.years) {
|
||||
const upperYearBound = new Date().getFullYear() + 1
|
||||
const multiCourseStatPromises = Promise.all(
|
||||
req.body.years.map(year => {
|
||||
if (req.body.selectedStudentsByYear) {
|
||||
req.body.selectedStudents = encrypted
|
||||
? req.body.selectedStudents.filter(isEncryptedStudent).map(decrypt)
|
||||
: req.body.selectedStudentsByYear[year]
|
||||
}
|
||||
const newMonths = (upperYearBound - Number(year)) * 12
|
||||
const query = { ...req.body, year, months: newMonths, selectedStudents: req.body.selectedStudents as string[] }
|
||||
const coursestatistics = bottlenecksOf(query, null, encrypted)
|
||||
return coursestatistics
|
||||
})
|
||||
)
|
||||
const multiCourseStats = await multiCourseStatPromises
|
||||
const result = populationCourseStatsMerger(multiCourseStats)
|
||||
if (result.error) {
|
||||
return res.status(400).json(result)
|
||||
const requiredFields = [years, semesters, programme]
|
||||
if (requiredFields.some(field => !field)) {
|
||||
return res.status(400).json({ error: 'The query should have years, semesters and a programme defined' })
|
||||
}
|
||||
|
||||
return res.json(result)
|
||||
}
|
||||
|
||||
if (encrypted) {
|
||||
req.body.selectedStudents = req.body.selectedStudents.map(decrypt)
|
||||
}
|
||||
|
||||
const query = { ...req.body, selectedStudents: req.body.selectedStudents as string[] }
|
||||
try {
|
||||
const result = await bottlenecksOf(query, null, encrypted)
|
||||
return res.json(result)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
return res.status(400).end()
|
||||
}
|
||||
})
|
||||
|
||||
interface PostPopulationStatisticsByTagRequest extends Request {
|
||||
body: {
|
||||
tag: string
|
||||
}
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/v2/populationstatistics/coursesbytag',
|
||||
async (req: PostPopulationStatisticsByTagRequest, res: Response) => {
|
||||
const { roles, studentsUserCanAccess } = req.user
|
||||
const { tag } = req.body
|
||||
if (!tag) {
|
||||
return res.status(400).json({ error: 'The body should have a tag defined' })
|
||||
if (!semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
|
||||
return res.status(400).json({ error: 'Semester should be either SPRING OR FALL' })
|
||||
}
|
||||
|
||||
const studentNumbers = await findByTag(tag)
|
||||
const studentNumberList = hasFullAccessToStudentData(roles)
|
||||
? studentNumbers
|
||||
: intersection(studentNumbers, studentsUserCanAccess)
|
||||
|
||||
try {
|
||||
const result = await bottlenecksOf(
|
||||
{
|
||||
year: '1900',
|
||||
studyRights: [],
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
months: 10000,
|
||||
tag,
|
||||
},
|
||||
studentNumberList
|
||||
)
|
||||
return res.json(result)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
return res.status(400).end()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface PostPopulationStatisticsByStudentNumberListRequest extends Request {
|
||||
body: {
|
||||
studentnumberlist: string[]
|
||||
year: string
|
||||
studyRights: string[]
|
||||
semesters: string[]
|
||||
months: number
|
||||
}
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/v2/populationstatistics/coursesbystudentnumberlist',
|
||||
async (req: PostPopulationStatisticsByStudentNumberListRequest, res: Response) => {
|
||||
const { roles, studentsUserCanAccess } = req.user
|
||||
const {
|
||||
body: { studentnumberlist, ...query },
|
||||
} = req
|
||||
|
||||
if (!studentnumberlist) {
|
||||
throw new ApplicationError('The body should have a studentnumberlist defined', 400)
|
||||
const hasCorrectStatus = (studentStatuses: string[]) =>
|
||||
studentStatuses.every(status => ['EXCHANGE', 'NONDEGREE', 'TRANSFERRED'].includes(status))
|
||||
if (studentStatuses && !hasCorrectStatus(studentStatuses)) {
|
||||
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
|
||||
}
|
||||
|
||||
const studentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentnumberlist
|
||||
: intersection(studentnumberlist, studentsUserCanAccess)
|
||||
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
|
||||
|
||||
try {
|
||||
const result = await bottlenecksOf(
|
||||
{
|
||||
year: query?.year ?? 1900,
|
||||
studyRights: query?.studyRights ?? [],
|
||||
semesters: query?.semesters ?? ['FALL', 'SPRING'],
|
||||
months: query?.months ?? 10000,
|
||||
},
|
||||
studentNumbers
|
||||
)
|
||||
return res.json(result)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
return res.status(400).end()
|
||||
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
|
||||
const hasFullRightsToProgramme = userFullProgrammeRights.includes(programme)
|
||||
const hasFullRightsToCombinedProgramme = userFullProgrammeRights.includes(combinedProgramme!)
|
||||
|
||||
const userProgrammeRightsCodes = userProgrammeRights.map(({ code }) => code)
|
||||
const hasAccessToProgramme = userProgrammeRightsCodes.includes(programme)
|
||||
const hasAccessToCombinedProgramme = userProgrammeRightsCodes.includes(combinedProgramme!)
|
||||
|
||||
if (!hasFullAccessToStudents && !hasAccessToProgramme && !hasAccessToCombinedProgramme) {
|
||||
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface GetPopulationStatisticsRequest extends Request {
|
||||
query: {
|
||||
year: string
|
||||
semesters: string[]
|
||||
studyRights: string
|
||||
months: string
|
||||
years?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsRequest, res: Response) => {
|
||||
const { year, semesters, studyRights: studyRightsJSON } = req.query
|
||||
const { roles, programmeRights, id: userId } = req.user
|
||||
if (!year || !semesters || !studyRightsJSON) {
|
||||
res.status(400).json({ error: 'The query should have a year, semester and studyRights defined' })
|
||||
return
|
||||
}
|
||||
|
||||
const studyRights: { programme: string; combinedProgramme: string } = JSON.parse(studyRightsJSON)
|
||||
|
||||
const fullProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeRightsCodes = programmeRights.map(({ code }) => code)
|
||||
|
||||
try {
|
||||
if (
|
||||
!hasFullAccessToStudentData(roles) &&
|
||||
!programmeRightsCodes.includes(studyRights.programme) &&
|
||||
!programmeRightsCodes.includes(studyRights.combinedProgramme)
|
||||
) {
|
||||
return res.status(403).json([])
|
||||
}
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: 'The query had invalid studyRights' })
|
||||
}
|
||||
|
||||
if (req.query.months === null) {
|
||||
req.query.months = '12'
|
||||
}
|
||||
|
||||
if (req.query.years) {
|
||||
const upperYearBound = new Date().getFullYear() + 1
|
||||
const multiPopulationStudentPromises = Promise.all(
|
||||
req.query.years.map(year => {
|
||||
const newMonths = (upperYearBound - Number(year)) * 12
|
||||
const populationStudents = optimizedStatisticsOf({
|
||||
...req.query,
|
||||
year: Number(year),
|
||||
studyRights: { programme: studyRights.programme },
|
||||
months: newMonths,
|
||||
})
|
||||
return populationStudents
|
||||
})
|
||||
)
|
||||
const multiPopulationStudents = await multiPopulationStudentPromises
|
||||
|
||||
const result = populationStudentsMerger(multiPopulationStudents)
|
||||
res.json(filterPersonalTags(result, userId))
|
||||
} else {
|
||||
const result: any = await optimizedStatisticsOf({
|
||||
const { startDate, endDate } = parseDateRangeFromParams({
|
||||
...req.query,
|
||||
year: Number(req.query.year),
|
||||
studyRights: { programme: studyRights.programme },
|
||||
months: Number(req.query.months),
|
||||
years: req.query.years,
|
||||
})
|
||||
|
||||
if ('error' in result && result.error) {
|
||||
Sentry.captureException(new Error(result.error))
|
||||
return res.status(400).end()
|
||||
}
|
||||
const studentNumbers = await getStudentNumbersWithStudyRights({
|
||||
programmeCodes: [programme],
|
||||
startDate,
|
||||
endDate,
|
||||
studentStatuses,
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
const studyRights = [programme]
|
||||
const tagList = await getStudentTags(studyRights, studentNumbers, userId)
|
||||
|
||||
const result = await statisticsOf(studentNumbers, studyRights, tagList, startDate)
|
||||
|
||||
// Obfuscate if user has only limited study programme rights and there are any students
|
||||
if (!hasFullAccessToStudents && !hasFullRightsToProgramme && !hasFullRightsToCombinedProgramme) {
|
||||
result.students = result.students.map(student => {
|
||||
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
|
||||
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0, 1)) // correct year for age distribution calculation but the date is always January 1st
|
||||
// correct year for age distribution calculation but the date is always January 1st
|
||||
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0))
|
||||
return {
|
||||
...student,
|
||||
firstnames: '',
|
||||
@@ -306,7 +102,6 @@ router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsReques
|
||||
email: '',
|
||||
secondaryEmail: '',
|
||||
sis_person_id: '',
|
||||
enrollments: student.enrollments?.map(enrollment => ({ ...enrollment, studentnumber: studentNumber })),
|
||||
tags: [],
|
||||
birthdate: obfuscatedBirthDate,
|
||||
obfuscated: true,
|
||||
@@ -314,58 +109,42 @@ router.get('/v3/populationstatistics', async (req: GetPopulationStatisticsReques
|
||||
})
|
||||
}
|
||||
|
||||
res.json(filterPersonalTags(result, userId))
|
||||
return res.json(result)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
interface GetPopulationStatisticsByCourseRequest extends Request {
|
||||
query: {
|
||||
coursecodes: string
|
||||
from: string
|
||||
to: string
|
||||
separate: string
|
||||
unifyCourses: UnifyStatus
|
||||
}
|
||||
}
|
||||
router.get<
|
||||
never,
|
||||
CanError<PopulationstatisticsbycourseResBody>,
|
||||
PopulationstatisticsbycourseReqBody,
|
||||
PopulationstatisticsbycourseParams
|
||||
>('/v3/populationstatisticsbycourse', async (req, res) => {
|
||||
const { id: userId, roles, studentsUserCanAccess: allStudentsUserCanAccess, programmeRights } = req.user
|
||||
const { coursecodes: coursecodeJSON, from, to, separate, unifyCourses } = req.query
|
||||
|
||||
router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisticsByCourseRequest, res: Response) => {
|
||||
const { coursecodes, from, to, separate: separateString, unifyCourses } = req.query
|
||||
const { id, roles, studentsUserCanAccess: allStudentsUserCanAccess, programmeRights } = req.user
|
||||
|
||||
if (!coursecodes || !from || !to) {
|
||||
if (!coursecodeJSON || !from || !to) {
|
||||
return res.status(400).json({ error: 'The body should have a yearcode and coursecode defined' })
|
||||
}
|
||||
|
||||
const maxYearsForPopulation = await maxYearsToCreatePopulationFrom(JSON.parse(coursecodes), Unification.REGULAR)
|
||||
const isSeparate = separate === 'true'
|
||||
const coursecodes = JSON.parse(coursecodeJSON)
|
||||
|
||||
const toFromDiff = Math.abs(Number(to) - Number(from) + 1)
|
||||
const separate = separateString === 'true'
|
||||
const requestedYearsToCreatePopulationFrom = Math.ceil(separate ? toFromDiff / 2 : toFromDiff) // 2 semesters = 1 year
|
||||
const requestedYearsToCreatePopulationFrom = Math.ceil(isSeparate ? toFromDiff / 2 : toFromDiff) // 2 semesters = 1 year
|
||||
|
||||
const maxYearsForPopulation = await maxYearsToCreatePopulationFrom(coursecodes, Unification.REGULAR)
|
||||
if (requestedYearsToCreatePopulationFrom > maxYearsForPopulation) {
|
||||
return res.status(400).json({ error: `Max years to create population from is ${maxYearsForPopulation}` })
|
||||
}
|
||||
|
||||
const studentNumbers = await findByCourseAndSemesters(
|
||||
JSON.parse(coursecodes),
|
||||
Number(from),
|
||||
Number(to),
|
||||
separate,
|
||||
unifyCourses
|
||||
)
|
||||
const result: any = await optimizedStatisticsOf(
|
||||
{
|
||||
// Useless, because studentnumbers are already filtered above by from & to.
|
||||
// We should probably refactor this to avoid more confusement.
|
||||
year: 1900,
|
||||
studyRights: [],
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
months: 10000,
|
||||
},
|
||||
studentNumbers
|
||||
)
|
||||
let courseProviders: string[] = []
|
||||
if (!hasFullAccessToStudentData(roles)) {
|
||||
courseProviders = await getCourseProvidersForCourses(JSON.parse(coursecodes))
|
||||
}
|
||||
const studentNumbers = await findByCourseAndSemesters(coursecodes, Number(from), Number(to), isSeparate, unifyCourses)
|
||||
|
||||
const studyRights = []
|
||||
const tagList = await getStudentTags(studyRights, studentNumbers, userId)
|
||||
const result: any = await statisticsOf(studentNumbers, studyRights, tagList)
|
||||
const courseProviders: string[] = !hasFullAccessToStudentData(roles)
|
||||
? await getCourseProvidersForCourses(coursecodes)
|
||||
: []
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const rightsMappedToProviders = mapToProviders(fullStudyProgrammeRights)
|
||||
const found = courseProviders.some(provider => rightsMappedToProviders.includes(provider))
|
||||
@@ -382,7 +161,7 @@ router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisti
|
||||
courses,
|
||||
gender_code,
|
||||
}: {
|
||||
studyRights: SISStudyRight[]
|
||||
studyRights: SISStudyRightModel[]
|
||||
studentNumber: string
|
||||
courses: ParsedCourse[]
|
||||
gender_code: GenderCode
|
||||
@@ -402,99 +181,80 @@ router.get('/v3/populationstatisticsbycourse', async (req: GetPopulationStatisti
|
||||
obfuscated: true,
|
||||
})
|
||||
|
||||
if ('error' in result && result.error) {
|
||||
Sentry.captureException(new Error(result.error))
|
||||
return res.status(400).end()
|
||||
}
|
||||
|
||||
if ('students' in result) {
|
||||
result.students = result.students.map(student =>
|
||||
studentsUserCanAccess.has(student.studentNumber) ? student : obfuscateStudent(student)
|
||||
)
|
||||
}
|
||||
|
||||
res.json(filterPersonalTags(result, id))
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
interface PostByStudentNumbersRequest extends Request {
|
||||
body: {
|
||||
studentnumberlist: string[]
|
||||
tags?: {
|
||||
studyProgramme: string | null
|
||||
year: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
router.post<never, PostByStudentNumbersResBody, PostByStudentNumbersReqBody>(
|
||||
'/v3/populationstatisticsbystudentnumbers',
|
||||
async (req, res) => {
|
||||
const { studentnumberlist, tags } = req.body
|
||||
const { id: userId, roles, studentsUserCanAccess } = req.user
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentnumberlist
|
||||
: intersection(studentnumberlist, studentsUserCanAccess)
|
||||
|
||||
router.post('/v3/populationstatisticsbystudentnumbers', async (req: PostByStudentNumbersRequest, res: Response) => {
|
||||
const { studentnumberlist, tags } = req.body
|
||||
const { roles, id, studentsUserCanAccess } = req.user
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentnumberlist
|
||||
: intersection(studentnumberlist, studentsUserCanAccess)
|
||||
const studyProgrammeCode = tags?.studyProgramme?.includes('+')
|
||||
? tags?.studyProgramme.split('+')[0]
|
||||
: tags?.studyProgramme
|
||||
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
|
||||
|
||||
const result = await optimizedStatisticsOf(
|
||||
{
|
||||
year: tags?.year ? Number(tags.year) : 1900,
|
||||
studyRights: studyProgrammeCode ? [studyProgrammeCode] : [],
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
months: 10000,
|
||||
},
|
||||
filteredStudentNumbers
|
||||
)
|
||||
if ('error' in result && result.error) {
|
||||
Sentry.captureException(new Error(result.error))
|
||||
return res.status(500).json({ error: result.error })
|
||||
}
|
||||
const studyRights = [studyProgrammeCode].filter(value => value !== undefined)
|
||||
const tagList = await getStudentTags(studyRights, filteredStudentNumbers, userId)
|
||||
|
||||
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
|
||||
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
|
||||
const { startDate } = tags?.year
|
||||
? parseDateRangeFromParams({ semesters: ['FALL', 'SPRING'], years: [tags?.year] })
|
||||
: { startDate: undefined }
|
||||
|
||||
res.status(200).json({ ...filterPersonalTags(resultWithStudyProgramme, id), discardedStudentNumbers })
|
||||
})
|
||||
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagList, startDate)
|
||||
|
||||
router.get('/v3/populationstatistics/studyprogrammes', async (req: Request, res: Response) => {
|
||||
const { roles, programmeRights } = req.user
|
||||
const programmes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
|
||||
const allRights = uniq(programmeRights.map(programme => programme.code))
|
||||
if (allRights.includes('KH90_001') || allRights.includes('MH90_001')) {
|
||||
allRights.push('KH90_001', 'MH90_001')
|
||||
}
|
||||
const filteredProgrammes = hasFullAccessToStudentData(roles)
|
||||
? programmes
|
||||
: programmes.filter(programme => allRights.includes(programme.code))
|
||||
const formattedProgrammes = filteredProgrammes.reduce<Record<string, Unarray<ProgrammesOfOrganization>>>(
|
||||
(acc, curr) => {
|
||||
acc[curr.code] = curr
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
res.json(formattedProgrammes)
|
||||
})
|
||||
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
|
||||
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
|
||||
|
||||
interface GetMaxYearsRequest extends Request {
|
||||
query: {
|
||||
courseCodes: string
|
||||
}
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/v3/populationstatistics/maxYearsToCreatePopulationFrom',
|
||||
async (req: GetMaxYearsRequest, res: Response) => {
|
||||
const courseCodes = JSON.parse(req.query.courseCodes) as string[]
|
||||
const maxYearsToCreatePopulationFromOpen = await maxYearsToCreatePopulationFrom(courseCodes, Unification.OPEN)
|
||||
const maxYearsToCreatePopulationFromUni = await maxYearsToCreatePopulationFrom(courseCodes, Unification.REGULAR)
|
||||
const maxYearsToCreatePopulationFromBoth = await maxYearsToCreatePopulationFrom(courseCodes, Unification.UNIFY)
|
||||
return res.json({
|
||||
openCourses: maxYearsToCreatePopulationFromOpen,
|
||||
uniCourses: maxYearsToCreatePopulationFromUni,
|
||||
unifyCourses: maxYearsToCreatePopulationFromBoth,
|
||||
})
|
||||
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
|
||||
}
|
||||
)
|
||||
|
||||
router.get<never, PopulationstatisticsStudyprogrammesResBody>(
|
||||
'/v3/populationstatistics/studyprogrammes',
|
||||
async (req, res) => {
|
||||
const { roles, programmeRights } = req.user
|
||||
const programmes = await getDegreeProgrammesOfOrganization(rootOrgId, false)
|
||||
const allRights = uniq(programmeRights.map(programme => programme.code))
|
||||
if (allRights.includes('KH90_001') || allRights.includes('MH90_001')) {
|
||||
allRights.push('KH90_001', 'MH90_001')
|
||||
}
|
||||
const filteredProgrammes = hasFullAccessToStudentData(roles)
|
||||
? programmes
|
||||
: programmes.filter(programme => allRights.includes(programme.code))
|
||||
const formattedProgrammes = filteredProgrammes.reduce<Record<string, Unarray<ProgrammesOfOrganization>>>(
|
||||
(acc, curr) => {
|
||||
acc[curr.code] = curr
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
res.json(formattedProgrammes)
|
||||
}
|
||||
)
|
||||
|
||||
router.get<
|
||||
never,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
|
||||
never,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormQuery
|
||||
>('/v3/populationstatistics/maxYearsToCreatePopulationFrom', async (req, res) => {
|
||||
const courseCodes = JSON.parse(req.query.courseCodes) as string[]
|
||||
const maxYearsToCreatePopulationFromOpen = await maxYearsToCreatePopulationFrom(courseCodes, Unification.OPEN)
|
||||
const maxYearsToCreatePopulationFromUni = await maxYearsToCreatePopulationFrom(courseCodes, Unification.REGULAR)
|
||||
const maxYearsToCreatePopulationFromBoth = await maxYearsToCreatePopulationFrom(courseCodes, Unification.UNIFY)
|
||||
return res.json({
|
||||
openCourses: maxYearsToCreatePopulationFromOpen,
|
||||
uniCourses: maxYearsToCreatePopulationFromUni,
|
||||
unifyCourses: maxYearsToCreatePopulationFromBoth,
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { getCoursesAndModules, getCurriculumVersions } from '../services/programmeModules'
|
||||
import { getCoursesAndModules, getCurriculumOptions } from '../services/programmeModules'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/v3/curriculum-options/:code', async (req: Request, res: Response) => {
|
||||
const { code } = req.params
|
||||
const result = await getCurriculumVersions(code)
|
||||
const result = await getCurriculumOptions(code)
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.get('/v3/curriculum/:code/:periodIds', async (req: Request, res: Response) => {
|
||||
const { code, periodIds } = req.params
|
||||
const result = await getCoursesAndModules(code, periodIds)
|
||||
const { defaultProgrammeCourses, secondProgrammeCourses } = await getCoursesAndModules(code, periodIds)
|
||||
|
||||
res.json({
|
||||
defaultProgrammeCourses: result.defaultProgrammeCourses.courses,
|
||||
defaultProgrammeModules: result.defaultProgrammeCourses.modules,
|
||||
secondProgrammeCourses: result.secondProgrammeCourses?.courses,
|
||||
secondProgrammeModules: result.secondProgrammeCourses?.modules,
|
||||
defaultProgrammeCourses: defaultProgrammeCourses.courses,
|
||||
defaultProgrammeModules: defaultProgrammeCourses.modules,
|
||||
secondProgrammeCourses: secondProgrammeCourses.courses,
|
||||
secondProgrammeModules: secondProgrammeCourses.modules,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
|
||||
import {
|
||||
getBasicStats,
|
||||
setBasicStats,
|
||||
@@ -18,7 +19,6 @@ import { getGraduationStatsForStudyTrack } from '../services/studyProgramme/stud
|
||||
import { updateBasicView, updateStudyTrackView } from '../services/studyProgramme/studyProgrammeUpdates'
|
||||
import { getStudyRightsInProgramme, getStudyTracksForProgramme } from '../services/studyProgramme/studyRightFinders'
|
||||
import { getStudyTrackStatsForStudyProgramme } from '../services/studyProgramme/studyTrackStats'
|
||||
import { Graduated, SpecialGroups, YearType } from '../shared/types'
|
||||
import logger from '../util/logger'
|
||||
import { logInfoForGrafana } from '../util/logInfoForGrafana'
|
||||
|
||||
@@ -27,16 +27,16 @@ const router = Router()
|
||||
interface GetCreditStatsRequest extends Request {
|
||||
query: {
|
||||
codes: string
|
||||
isAcademicYear: string
|
||||
includeSpecials: string
|
||||
specialGroups: SpecialGroups
|
||||
yearType: YearType
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/creditstats', async (req: GetCreditStatsRequest, res: Response) => {
|
||||
const { codes, isAcademicYear, includeSpecials } = req.query
|
||||
const { codes, specialGroups, yearType } = req.query
|
||||
const stats = {}
|
||||
for (const code of JSON.parse(codes)) {
|
||||
stats[code] = await getCreditsProduced(code, isAcademicYear !== 'false', includeSpecials !== 'false')
|
||||
stats[code] = await getCreditsProduced(code, yearType === 'ACADEMIC_YEAR', specialGroups === 'SPECIAL_INCLUDED')
|
||||
}
|
||||
return res.json({ stats })
|
||||
})
|
||||
@@ -105,20 +105,19 @@ router.get('/:id/graduationstats', async (req: GetStatsRequest, res: Response) =
|
||||
|
||||
interface GetCourseStatsRequest extends Request {
|
||||
query: {
|
||||
academicyear: string
|
||||
combined_programme: string
|
||||
combinedProgramme: string
|
||||
yearType: YearType
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/:id/coursestats', async (req: GetCourseStatsRequest, res: Response) => {
|
||||
const code = req.params.id
|
||||
const showByYear = req.query.academicyear
|
||||
const combinedProgramme = req.query?.combined_programme
|
||||
const { combinedProgramme, yearType } = req.query
|
||||
const date = new Date()
|
||||
date.setHours(23, 59, 59, 999)
|
||||
void logInfoForGrafana(code, combinedProgramme)
|
||||
try {
|
||||
const data = await getStudyProgrammeCoursesForStudyTrack(date.getTime(), code, showByYear, combinedProgramme)
|
||||
const data = await getStudyProgrammeCoursesForStudyTrack(date.getTime(), code, yearType, combinedProgramme)
|
||||
return res.json(data)
|
||||
} catch (error) {
|
||||
logger.error({ message: `Failed to get code ${code} programme courses stats`, meta: `${error}` })
|
||||
|
||||
@@ -10,12 +10,12 @@ const router = Router()
|
||||
|
||||
interface StudyProgrammeCriteriaRequest extends Request {
|
||||
query: {
|
||||
programmecode: string
|
||||
programmeCode: string
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/', async (req: StudyProgrammeCriteriaRequest, res: Response) => {
|
||||
const studyProgramme = req.query.programmecode
|
||||
const studyProgramme = req.query.programmeCode
|
||||
if (studyProgramme !== '' && !studyProgramme) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
@@ -37,7 +37,10 @@ router.post('/courses', async (req: CriteriaCoursesRequest, res: Response) => {
|
||||
return res.status(400).end()
|
||||
}
|
||||
const studyProgrammeCriteria = await saveYearlyCourseCriteria(code, courses, year)
|
||||
return res.json(studyProgrammeCriteria)
|
||||
if (!studyProgrammeCriteria) {
|
||||
return res.status(404).end()
|
||||
}
|
||||
return res.status(201).end()
|
||||
})
|
||||
|
||||
interface CriteriaCreditsRequest extends Request {
|
||||
@@ -53,7 +56,10 @@ router.post('/credits', async (req: CriteriaCreditsRequest, res: Response) => {
|
||||
return res.status(400).end()
|
||||
}
|
||||
const studyProgrammeCriteria = await saveYearlyCreditCriteria(code, credits)
|
||||
return res.json(studyProgrammeCriteria)
|
||||
if (!studyProgrammeCriteria) {
|
||||
return res.status(404).end()
|
||||
}
|
||||
return res.status(201).end()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
import { difference } from 'lodash'
|
||||
|
||||
import { Tag, TagStudent } from '../models/kone'
|
||||
import { filterStudentnumbersByAccessrights } from '../services/students'
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { NewTag, Role, StudentTag, Tag } from '@oodikone/shared/types'
|
||||
import { filterStudentNumbersByAccessRights } from '../services/students'
|
||||
import {
|
||||
findTagsByStudyTrack,
|
||||
createNewTag,
|
||||
@@ -11,20 +12,17 @@ import {
|
||||
createMultipleStudentTags,
|
||||
findTagsFromStudyTrackById,
|
||||
deleteMultipleStudentTags,
|
||||
TagFromFrontend,
|
||||
StudentTagFromFrontend,
|
||||
} from '../services/tags'
|
||||
import { Role } from '../shared/types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const filterRelevantTags = (tags: Tag[], userId: string) => {
|
||||
return tags.filter(tag => !tag.personal_user_id || tag.personal_user_id === userId)
|
||||
return tags.filter(tag => !tag.personalUserId || tag.personalUserId === userId)
|
||||
}
|
||||
|
||||
const filterRelevantStudentTags = (studentTags: TagStudent[], userId: string) => {
|
||||
return studentTags.filter(({ tag }) => !tag.personal_user_id || tag.personal_user_id === userId)
|
||||
const filterRelevantStudentTags = (studentTags: StudentTag[], userId: string) => {
|
||||
return studentTags.filter(({ tag }) => !tag?.personalUserId || tag.personalUserId === userId)
|
||||
}
|
||||
|
||||
const userIsUnauthorized = (programmeRights: string[], programmeCodes: string[], roles: Role[]) => {
|
||||
@@ -39,17 +37,14 @@ const getStudyTrackCode = (studyTrack: string, combinedProgramme: string) => {
|
||||
return combinedProgramme ? `${studyTrack}-${combinedProgramme}` : studyTrack
|
||||
}
|
||||
|
||||
interface GetTagsByStudyTrackRequest extends Request {
|
||||
params: {
|
||||
studytrack: string
|
||||
}
|
||||
}
|
||||
export type GetTagsByStudyTrackParams = { studyTrack: string }
|
||||
export type GetTagsByStudyTrackResBody = Tag[] | null
|
||||
|
||||
router.get('/tags/:studytrack', async (req: GetTagsByStudyTrackRequest, res: Response) => {
|
||||
const { studytrack } = req.params
|
||||
router.get<GetTagsByStudyTrackParams, GetTagsByStudyTrackResBody>('/tags/:studyTrack', async (req, res) => {
|
||||
const { studyTrack } = req.params
|
||||
const { roles, id, programmeRights } = req.user
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeCodes = getProgrammeCodes(studytrack)
|
||||
const programmeCodes = getProgrammeCodes(studyTrack)
|
||||
|
||||
// Respond with null and 200 instead of 403 if the user isn't authorized to view the tags
|
||||
// This is to avoid unnecessary noise in Sentry
|
||||
@@ -57,149 +52,144 @@ router.get('/tags/:studytrack', async (req: GetTagsByStudyTrackRequest, res: Res
|
||||
return res.json(null)
|
||||
}
|
||||
|
||||
const tags = await findTagsByStudyTrack(studytrack)
|
||||
const tags = await findTagsByStudyTrack(studyTrack)
|
||||
res.status(200).json(filterRelevantTags(tags, id))
|
||||
})
|
||||
|
||||
interface PostTagRequest extends Request {
|
||||
body: {
|
||||
tag: TagFromFrontend
|
||||
}
|
||||
}
|
||||
export type PostTagResBody = CanError<Tag[] | void>
|
||||
export type PostTagReqBody = { tag: NewTag }
|
||||
|
||||
router.post('/tags', async (req: PostTagRequest, res: Response) => {
|
||||
router.post<never, PostTagResBody, PostTagReqBody>('/tags', async (req, res) => {
|
||||
const {
|
||||
tag: { studytrack, tagname, year, personal_user_id },
|
||||
tag: { name, personalUserId, studyTrack, year },
|
||||
} = req.body
|
||||
const { roles, id, programmeRights } = req.user
|
||||
|
||||
if (!tagname || !studytrack || !year) {
|
||||
if (!name || !studyTrack) {
|
||||
return res.status(400).json({ error: 'Missing required fields' })
|
||||
}
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeCodes = getProgrammeCodes(studytrack)
|
||||
const programmeCodes = getProgrammeCodes(studyTrack)
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
await createNewTag({ studytrack, tagname, year, personal_user_id })
|
||||
const tags = await findTagsByStudyTrack(studytrack)
|
||||
await createNewTag({ name, personalUserId, studyTrack, year })
|
||||
const tags = await findTagsByStudyTrack(studyTrack)
|
||||
res.status(200).json(filterRelevantTags(tags, id))
|
||||
})
|
||||
|
||||
interface DeleteTagRequest extends Request {
|
||||
body: {
|
||||
tag: {
|
||||
studytrack: string
|
||||
tag_id: string
|
||||
personal_user_id: string
|
||||
}
|
||||
}
|
||||
}
|
||||
export type DeleteTagResBody = Tag[] | void
|
||||
export type DeleteTagReqBody = { tag: Tag }
|
||||
|
||||
router.delete('/tags', async (req: DeleteTagRequest, res: Response) => {
|
||||
const { tag } = req.body
|
||||
const { roles, id, programmeRights } = req.user
|
||||
router.delete<never, DeleteTagResBody, DeleteTagReqBody>('/tags', async (req, res) => {
|
||||
const {
|
||||
tag: { id, personalUserId, studyTrack },
|
||||
} = req.body
|
||||
const { roles, id: userId, programmeRights } = req.user
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeCodes = getProgrammeCodes(tag.studytrack)
|
||||
const programmeCodes = getProgrammeCodes(studyTrack)
|
||||
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles) && !(tag.personal_user_id === id)) {
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, programmeCodes, roles) && !(personalUserId === userId)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
await deleteTag(tag.tag_id)
|
||||
const tags = await findTagsByStudyTrack(tag.studytrack)
|
||||
await deleteTag(id)
|
||||
const tags = await findTagsByStudyTrack(studyTrack)
|
||||
res.status(200).json(filterRelevantTags(tags, id))
|
||||
})
|
||||
|
||||
interface GetStudentTagsByStudyTrackRequest extends Request {
|
||||
params: {
|
||||
studytrack: string
|
||||
export type GetStudentTagsByStudyTrackParams = { studyTrack: string }
|
||||
export type GetStudentTagsByStudyTrackResBody = StudentTag[] | null
|
||||
|
||||
router.get<GetStudentTagsByStudyTrackParams, GetStudentTagsByStudyTrackResBody>(
|
||||
'/studenttags/:studyTrack',
|
||||
async (req, res) => {
|
||||
const { studyTrack } = req.params
|
||||
const { roles, id, programmeRights } = req.user
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeCodes = getProgrammeCodes(studyTrack)
|
||||
const lacksProgrammeAccess = programmeCodes.every(code => !fullStudyProgrammeRights.includes(code))
|
||||
if (lacksProgrammeAccess && !roles?.includes('admin')) {
|
||||
return res.json(null)
|
||||
}
|
||||
|
||||
const tags = await getStudentTagsByStudyTrack(studyTrack)
|
||||
const relevantTags = filterRelevantStudentTags(tags, id)
|
||||
res.status(200).json(relevantTags)
|
||||
}
|
||||
)
|
||||
|
||||
export type PostStudentTagsResBody = CanError<void>
|
||||
export type PostStudentTagsReqBody = {
|
||||
combinedProgramme: string
|
||||
studentTags: StudentTag[]
|
||||
studyTrack: string
|
||||
}
|
||||
|
||||
router.get('/studenttags/:studytrack', async (req: GetStudentTagsByStudyTrackRequest, res: Response) => {
|
||||
const { studytrack } = req.params
|
||||
const { roles, id, programmeRights } = req.user
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const programmeCodes = getProgrammeCodes(studytrack)
|
||||
const lacksProgrammeAccess = programmeCodes.every(code => !fullStudyProgrammeRights.includes(code))
|
||||
if (lacksProgrammeAccess && !roles?.includes('admin')) {
|
||||
return res.json(null)
|
||||
}
|
||||
|
||||
const tags = await getStudentTagsByStudyTrack(studytrack)
|
||||
res.status(200).json(filterRelevantStudentTags(tags, id))
|
||||
})
|
||||
|
||||
interface PostStudentTagsRequest extends Request {
|
||||
body: {
|
||||
tags: StudentTagFromFrontend[]
|
||||
studytrack: string
|
||||
combinedProgramme: string
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/studenttags', async (req: PostStudentTagsRequest, res: Response) => {
|
||||
const { tags, studytrack, combinedProgramme } = req.body
|
||||
router.post<never, PostStudentTagsResBody, PostStudentTagsReqBody>('/studenttags', async (req, res) => {
|
||||
const { combinedProgramme, studentTags, studyTrack } = req.body
|
||||
const { roles, programmeRights } = req.user
|
||||
|
||||
if (!studentTags || !studyTrack) {
|
||||
return res.status(400).json({ error: 'Missing required fields' })
|
||||
}
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) {
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studyTrack, combinedProgramme], roles)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
const studyTrackCode = getStudyTrackCode(studytrack, combinedProgramme)
|
||||
const studyTrackCode = getStudyTrackCode(studyTrack, combinedProgramme)
|
||||
const existingTags = await findTagsByStudyTrack(studyTrackCode)
|
||||
const existingTagids = existingTags.map(tag => tag.tag_id)
|
||||
const tagIds = [...new Set(tags.map(tag => tag.tag_id))]
|
||||
if (!tagIds.find(tag => existingTagids.includes(tag))) {
|
||||
const existingTagIds = existingTags.map(tag => tag.id)
|
||||
const tagIds = [...new Set(studentTags.map(studentTag => studentTag.tagId))]
|
||||
if (!tagIds.find(tag => existingTagIds.includes(tag))) {
|
||||
return res.status(400).json({ error: 'The tag does not exist' })
|
||||
}
|
||||
|
||||
const studentNumbers = tags.map(tag => tag.studentnumber)
|
||||
const studentFromProgrammes = combinedProgramme ? [studytrack, combinedProgramme] : [studytrack]
|
||||
const students = await filterStudentnumbersByAccessrights(studentNumbers, studentFromProgrammes)
|
||||
const studentNumbers = studentTags.map(studentTag => studentTag.studentNumber)
|
||||
const studentFromProgrammes = combinedProgramme ? [studyTrack, combinedProgramme] : [studyTrack]
|
||||
const students = await filterStudentNumbersByAccessRights(studentNumbers, studentFromProgrammes)
|
||||
const missingStudents = difference(studentNumbers, students)
|
||||
if (missingStudents.length !== 0) {
|
||||
const errorMessage = `Could not find the following students from the programme: ${missingStudents.join(', ')}`
|
||||
return res.status(400).json({ error: errorMessage })
|
||||
}
|
||||
|
||||
await createMultipleStudentTags(tags)
|
||||
await createMultipleStudentTags(studentTags)
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
interface DeleteStudentTagsRequest extends Request {
|
||||
body: {
|
||||
tagId: string
|
||||
studentnumbers: string[]
|
||||
studytrack: string
|
||||
combinedProgramme: string
|
||||
}
|
||||
export type DeleteStudentTagsResBody = CanError<void>
|
||||
export type DeleteStudentTagsReqBody = {
|
||||
combinedProgramme: string
|
||||
tagId: string
|
||||
studentNumbers: string[]
|
||||
studyTrack: string
|
||||
}
|
||||
|
||||
router.delete('/studenttags', async (req: DeleteStudentTagsRequest, res: Response) => {
|
||||
const { tagId, studentnumbers, studytrack, combinedProgramme } = req.body
|
||||
router.delete<never, DeleteStudentTagsResBody, DeleteStudentTagsReqBody>('/studenttags', async (req, res) => {
|
||||
const { combinedProgramme, tagId, studentNumbers, studyTrack } = req.body
|
||||
const { roles, programmeRights } = req.user
|
||||
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studytrack, combinedProgramme], roles)) {
|
||||
if (userIsUnauthorized(fullStudyProgrammeRights, [studyTrack, combinedProgramme], roles)) {
|
||||
return res.status(403).end()
|
||||
}
|
||||
|
||||
const studyTrackCode = getStudyTrackCode(studytrack, combinedProgramme)
|
||||
const studyTrackCode = getStudyTrackCode(studyTrack, combinedProgramme)
|
||||
const tags = await findTagsFromStudyTrackById(studyTrackCode, [tagId])
|
||||
if (tags.length === 0) {
|
||||
return res.status(403).json({ error: 'The tag does not exist' })
|
||||
}
|
||||
|
||||
await deleteMultipleStudentTags(tagId, studentnumbers)
|
||||
await deleteMultipleStudentTags(tagId, studentNumbers)
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { serviceProvider } from '../config'
|
||||
import * as auth from '../middleware/auth'
|
||||
import { getProvidersOfFaculty, isFaculty } from '../services/organizations'
|
||||
import { getTeachersBySearchTerm, getTeacherStatistics, getYearlyStatistics } from '../services/teachers'
|
||||
import { CategoryID, getTeacherStats, findAndSaveTeachers, getCategoriesAndYears } from '../services/teachers/top'
|
||||
import { mapToProviders } from '../shared/util'
|
||||
import { getFullStudyProgrammeRights, splitByEmptySpace } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user