mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5af4ffcae | ||
|
|
cab573eebb | ||
|
|
5d3c5c99c5 | ||
|
|
577de97741 | ||
|
|
60d95e45d2 | ||
|
|
34ae6f279b | ||
|
|
1c91554a3b | ||
|
|
e53c0c2de4 | ||
|
|
25deee6016 | ||
|
|
f8cc05afda | ||
|
|
9b46c9f43f | ||
|
|
654a2a6604 | ||
|
|
27a9ac8225 | ||
|
|
b8d93af641 | ||
|
|
33ee9d142d | ||
|
|
25ece19e36 | ||
|
|
39ac7a2665 | ||
|
|
fb35bc0c2b | ||
|
|
ce08567bb4 | ||
|
|
89f08dabc5 | ||
|
|
d6f414dc71 | ||
|
|
d9a8a87c2f | ||
|
|
2bab5c6ef0 | ||
|
|
8cf9eae693 | ||
|
|
535cd08593 | ||
|
|
f3fd84598a | ||
|
|
ceb813636a | ||
|
|
c51f90ab1a | ||
|
|
12a0a38ce0 | ||
|
|
ef25eeae40 | ||
|
|
70b10480a4 | ||
|
|
11bfd0b8c5 | ||
|
|
a45ec5537e | ||
|
|
165fff0163 | ||
|
|
a6df2e8d50 | ||
|
|
bfeb3ef9b1 | ||
|
|
4fdbba9fe1 | ||
|
|
fe52b06696 | ||
|
|
54e60279f7 | ||
|
|
2ef35ced6b | ||
|
|
386290288f | ||
|
|
b1d33dc64c | ||
|
|
7d4c8c99bf | ||
|
|
ed7e03169a | ||
|
|
eaa9a17a49 | ||
|
|
6c463b6b3b | ||
|
|
79c04ba2a6 | ||
|
|
b0c4d8bb8c | ||
|
|
cf0796bb88 | ||
|
|
9a23799972 | ||
|
|
010829b96c | ||
|
|
b921bc0891 | ||
|
|
aa6ce4ab28 | ||
|
|
b5b211e750 | ||
|
|
c114121a7a | ||
|
|
31eb9b2101 | ||
|
|
55c1a5f93b | ||
|
|
2311a9c027 | ||
|
|
62b7864ffe | ||
|
|
f1000a8f25 | ||
|
|
4e566827f7 | ||
|
|
56f5f730c4 | ||
|
|
556999614c | ||
|
|
1c4e3721c6 | ||
|
|
7032902c75 | ||
|
|
e3928ebd5f | ||
|
|
917931378a | ||
|
|
ff93d54470 | ||
|
|
402ee672a1 | ||
|
|
d5fc584d93 | ||
|
|
11a390128f | ||
|
|
f80ef6fb40 | ||
|
|
78e31f0a3e | ||
|
|
651b3b5c0d | ||
|
|
66e666c99a | ||
|
|
464cdff246 | ||
|
|
58c35f21b0 | ||
|
|
ae1c7765a1 | ||
|
|
59e2258da3 | ||
|
|
5f8dc44958 | ||
|
|
c8b4586ea1 | ||
|
|
b04859639f | ||
|
|
8fba294dd8 | ||
|
|
714f3bc953 | ||
|
|
7247049564 | ||
|
|
ff6646ebb4 | ||
|
|
d77c735030 | ||
|
|
aaefbfa9fe | ||
|
|
7ec487412f | ||
|
|
f7c84ca06f | ||
|
|
bc46a18151 | ||
|
|
fcd8eb9284 | ||
|
|
d0c52f301f | ||
|
|
a606c21cd3 | ||
|
|
dc496618f3 | ||
|
|
099e60b617 | ||
|
|
c39fdcd77a | ||
|
|
907e24e26e | ||
|
|
664d8fb83a | ||
|
|
3618d318eb | ||
|
|
cc09af092f | ||
|
|
48a77d2b12 | ||
|
|
99a598e773 | ||
|
|
c9455d2fde | ||
|
|
a343b2460a | ||
|
|
7ca612364b | ||
|
|
6bed41dd9e | ||
|
|
392d5ea3b0 | ||
|
|
537ab19fb6 | ||
|
|
74b566f574 | ||
|
|
509fdeadfa | ||
|
|
1f13389954 | ||
|
|
e699139f4b | ||
|
|
9f9786a3bb | ||
|
|
7c79d23a47 | ||
|
|
85eb3c9634 | ||
|
|
25f7f46a36 | ||
|
|
569e411f7c | ||
|
|
d7f8024d27 | ||
|
|
ca429a7b68 | ||
|
|
01bc1f11f4 | ||
|
|
50aad14a39 | ||
|
|
76624da6c6 | ||
|
|
f7eba6b2d7 | ||
|
|
6aa7762acb | ||
|
|
1f61ab7b81 | ||
|
|
5681ef9f97 | ||
|
|
7e903456ae | ||
|
|
ece089b5b6 | ||
|
|
177b2c39a5 | ||
|
|
f399041163 | ||
|
|
7f238c5ccb | ||
|
|
ce41a9a3e3 | ||
|
|
a117ce0fd4 | ||
|
|
fe5418575e | ||
|
|
08adaed6d7 | ||
|
|
fc8d59af72 | ||
|
|
9e7c84d053 | ||
|
|
3e69cfae20 | ||
|
|
69b9301c17 | ||
|
|
4cae8eaac2 | ||
|
|
6ea3f9a922 | ||
|
|
ea7d4b1554 | ||
|
|
426658f8b0 | ||
|
|
72a6778ae5 | ||
|
|
8c2a2d113e | ||
|
|
27dbf0b575 | ||
|
|
2ff9ae7843 | ||
|
|
ee8a7b016a | ||
|
|
c7831b7f66 | ||
|
|
5895021a48 | ||
|
|
ced54e7a9b | ||
|
|
25cd18e025 | ||
|
|
c08b2cf37c | ||
|
|
2da42ed93e | ||
|
|
f2e7893c60 | ||
|
|
5b348c37db | ||
|
|
bb6ca028c7 | ||
|
|
340d46c739 | ||
|
|
52f77935fc | ||
|
|
d35cbc1085 | ||
|
|
6f17bb002a | ||
|
|
692e704108 | ||
|
|
31c45581e0 | ||
|
|
cb3ed86231 | ||
|
|
de85a38abe | ||
|
|
e28a1e5e6f | ||
|
|
66bd4be4ed | ||
|
|
37fbb5cbb3 | ||
|
|
c31fac4419 | ||
|
|
7760f60ff5 | ||
|
|
7b65539873 | ||
|
|
fe4bda7ed8 | ||
|
|
6ccaec1643 | ||
|
|
df01c3b693 | ||
|
|
b7235ff4b4 | ||
|
|
84d8a6bfec | ||
|
|
24ccb9de58 | ||
|
|
faac3e968e | ||
|
|
3407af8215 | ||
|
|
94d289194a | ||
|
|
39c21b261a | ||
|
|
74ae6f3d46 | ||
|
|
3f9ce90834 | ||
|
|
b235fa7883 | ||
|
|
e2070248be | ||
|
|
97068be78e | ||
|
|
53dd55f874 | ||
|
|
3c99a30a8c | ||
|
|
5fd94fd0d0 | ||
|
|
c36ce33f7f | ||
|
|
ff6bcd6658 | ||
|
|
53e75b6164 | ||
|
|
a65c4d448b | ||
|
|
f9a4d85ce2 | ||
|
|
c610a53168 | ||
|
|
ad40e02442 | ||
|
|
6b6e070deb | ||
|
|
253f31d7d0 | ||
|
|
551775bcec | ||
|
|
70e8d6f033 | ||
|
|
ea505b5d84 | ||
|
|
344a3c00ef | ||
|
|
b29984f589 | ||
|
|
0ad6d6f9a2 | ||
|
|
6bf1d9a590 | ||
|
|
430fc97917 | ||
|
|
7ba2d3489e | ||
|
|
1e68c959b9 | ||
|
|
55b900d1f6 | ||
|
|
1ff637ab9d | ||
|
|
cff045fd3b | ||
|
|
7fce9fd5be | ||
|
|
a2569d1525 | ||
|
|
70e220ba58 | ||
|
|
9482f58930 | ||
|
|
fbc92b6949 | ||
|
|
2b452429c9 | ||
|
|
2eb542a61a | ||
|
|
10ba9153ed | ||
|
|
4eb3946d4f | ||
|
|
691233a91e | ||
|
|
cda44b321c | ||
|
|
bdbed16047 | ||
|
|
ec6dd9d672 | ||
|
|
95ff21a09b | ||
|
|
a464d8cd49 | ||
|
|
d97e1757b1 | ||
|
|
e91239c4e4 | ||
|
|
b0d358db78 | ||
|
|
22643fa590 | ||
|
|
9c5665c216 | ||
|
|
5549202299 | ||
|
|
31004bed07 | ||
|
|
5cdfea1bc2 | ||
|
|
cbb17ef00f | ||
|
|
a75b3b1d5a | ||
|
|
b34f42efd9 | ||
|
|
34e2293bc8 | ||
|
|
71ae263e40 | ||
|
|
b87b129766 | ||
|
|
76c707c322 | ||
|
|
1d0d9f91bf | ||
|
|
da22043197 | ||
|
|
db567235a9 | ||
|
|
08d42c8efd | ||
|
|
53003b2c93 | ||
|
|
9049160b02 | ||
|
|
b40ba0846a | ||
|
|
cae1fe1550 | ||
|
|
180bea21c1 | ||
|
|
f861d55aab | ||
|
|
9f689d5ec7 | ||
|
|
43d67713a6 | ||
|
|
ff15862aaf | ||
|
|
2d00381b0d | ||
|
|
9186ca5b40 | ||
|
|
7b43b7bb06 | ||
|
|
533b6b17bd | ||
|
|
1ffa846243 | ||
|
|
9f7f037e90 | ||
|
|
795bae945d | ||
|
|
c2b0dc46ca | ||
|
|
f22fe44f5a | ||
|
|
2139286a49 | ||
|
|
4e1ad9441a | ||
|
|
3a021e07a7 | ||
|
|
8b14c21f3c | ||
|
|
6bbd53ade9 | ||
|
|
e4e20c8e3c | ||
|
|
3337192b8a | ||
|
|
61ee4475e7 | ||
|
|
43c3dd2998 | ||
|
|
de5e23fdc6 | ||
|
|
355b034a4a | ||
|
|
2321ab7242 | ||
|
|
ecc610bf6d | ||
|
|
bef5e39d22 | ||
|
|
b7d3fbab09 | ||
|
|
c0d7538cab | ||
|
|
7858845b60 | ||
|
|
4c87250206 | ||
|
|
8c52ff707a | ||
|
|
92d9040260 | ||
|
|
874e75670e | ||
|
|
9f60d026ad | ||
|
|
0bb2da9137 | ||
|
|
0977eefbd3 | ||
|
|
99d2e803b6 | ||
|
|
5a4998ce84 | ||
|
|
316c17745d | ||
|
|
bfad75d03e | ||
|
|
c802e3827f | ||
|
|
78f1b2a3e4 | ||
|
|
6b7b9da7c8 | ||
|
|
dfadf975d0 | ||
|
|
3809d0e284 | ||
|
|
b2338981a9 | ||
|
|
be56f8b388 | ||
|
|
bf4c8fa2fb | ||
|
|
d9b8bad314 | ||
|
|
2149cad79c | ||
|
|
718393cb25 | ||
|
|
bb56f3e801 | ||
|
|
08a3f50a5a | ||
|
|
e04315e72b | ||
|
|
56a8463871 | ||
|
|
3895ca5143 | ||
|
|
dbb89ad2d8 | ||
|
|
422f6464fc | ||
|
|
84cd9956ff | ||
|
|
3473d8e546 | ||
|
|
d5e44d4df4 | ||
|
|
843f9d9716 | ||
|
|
521ec7e302 | ||
|
|
7217f9666d | ||
|
|
a40cd6a38c | ||
|
|
8258fa3cf8 | ||
|
|
65b0c83778 | ||
|
|
7eb5511a77 | ||
|
|
378df9cfcd | ||
|
|
0ba959b99e | ||
|
|
609fd2e3bc | ||
|
|
7a43f3f522 | ||
|
|
61959c25b9 | ||
|
|
5146ff9c48 | ||
|
|
481acc3f27 | ||
|
|
df7761e6e7 | ||
|
|
7c2c6478b5 | ||
|
|
284a24c937 | ||
|
|
eedfe5a971 | ||
|
|
e958ba7a5e | ||
|
|
f5ce505a34 | ||
|
|
d596db6f15 | ||
|
|
a8da08bd82 | ||
|
|
f4cf3f2c71 | ||
|
|
d2190579fe | ||
|
|
3805ed1ffe | ||
|
|
14d24dd320 | ||
|
|
da54f92261 | ||
|
|
248627d809 | ||
|
|
bc177a124c | ||
|
|
b5592f4a6c | ||
|
|
31c2ecfbd5 | ||
|
|
57dca3f6c4 | ||
|
|
501604ee7e | ||
|
|
257d2c313c | ||
|
|
3f51d6c073 | ||
|
|
fe5269f0be | ||
|
|
1b8cc1c69f | ||
|
|
fca3c11191 | ||
|
|
b2f0976e14 | ||
|
|
07b8b813ce | ||
|
|
0c45f7f480 | ||
|
|
29112f11e4 | ||
|
|
817eff0490 | ||
|
|
f8c665ca5e | ||
|
|
5abac85e81 | ||
|
|
cf15dfab72 | ||
|
|
9ca60cacc5 | ||
|
|
8747161f0c | ||
|
|
03c13cedd2 | ||
|
|
086d1f89f4 | ||
|
|
3335891b5a | ||
|
|
1b36ac9dfe | ||
|
|
60d08ed9eb | ||
|
|
dc51480e27 | ||
|
|
aa30506df2 | ||
|
|
c929cd2325 | ||
|
|
0fef278dd8 | ||
|
|
1e9cab4b46 | ||
|
|
2beea33a4c | ||
|
|
6d0acb4091 | ||
|
|
6d8d41dbe1 | ||
|
|
e3f6c4a244 | ||
|
|
818b9e4e1c | ||
|
|
b8bbe7634e | ||
|
|
6e8e63b38d | ||
|
|
04deef0c2b | ||
|
|
a8e75754cd | ||
|
|
c350adac64 | ||
|
|
59b79d26d2 | ||
|
|
cd8f5b11b1 | ||
|
|
b8e110e39a | ||
|
|
95b60ed378 | ||
|
|
9dac180b95 | ||
|
|
908985410b | ||
|
|
9f686fafd5 | ||
|
|
59963e4961 | ||
|
|
1f1dc624e4 | ||
|
|
3e374f29d0 | ||
|
|
9c060874eb | ||
|
|
3a04397e5d | ||
|
|
6ae174a1c1 | ||
|
|
738ff03d2c | ||
|
|
b11d45da60 | ||
|
|
e2bc22fdab | ||
|
|
1433e949bc | ||
|
|
7040d78de8 | ||
|
|
79ff2887fb | ||
|
|
eac2789951 | ||
|
|
9688747773 | ||
|
|
27616be349 | ||
|
|
d330b84d1c | ||
|
|
9275058e31 | ||
|
|
de8a090898 | ||
|
|
ae393fa857 | ||
|
|
e46faa0904 | ||
|
|
ffa0120bd7 | ||
|
|
ed8f282cf5 | ||
|
|
f0e9a66a7e | ||
|
|
0e8c3696eb | ||
|
|
db986ee0fb | ||
|
|
a35f9cc6d3 | ||
|
|
d60232b954 | ||
|
|
5ef73f0094 | ||
|
|
433570f124 | ||
|
|
7bf8c54efd | ||
|
|
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 |
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ description: 'Runs ESLint and Stylelint for frontend and backend'
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
- name: 'Install dependencies'
|
||||
shell: bash
|
||||
run: npm ci && npm ci --prefix services/shared && npm ci --prefix services/frontend && npm ci --prefix services/backend
|
||||
|
||||
@@ -38,7 +38,7 @@ runs:
|
||||
CYPRESS_baseUrl: http://localhost:1337
|
||||
|
||||
- name: 'Upload video if tests fail'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: failure()
|
||||
with:
|
||||
name: 'Cypress videos - ${{ inputs.test_name }}'
|
||||
|
||||
@@ -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
|
||||
@@ -100,9 +100,9 @@ jobs:
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_URL: https://toska.cs.helsinki.fi/
|
||||
SENTRY_URL: https://toska.it.helsinki.fi/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
|
||||
SENTRY_ORG: toska
|
||||
SENTRY_ORG: sentry
|
||||
SENTRY_PROJECT: oodikone-frontend
|
||||
with:
|
||||
environment: production
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
- release_backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Extract release version
|
||||
id: extract_version
|
||||
@@ -134,9 +134,9 @@ jobs:
|
||||
- name: Create Sentry release
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_URL: https://toska.cs.helsinki.fi/
|
||||
SENTRY_URL: https://toska.it.helsinki.fi/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }}
|
||||
SENTRY_ORG: toska
|
||||
SENTRY_ORG: sentry
|
||||
SENTRY_PROJECT: oodikone-backend
|
||||
with:
|
||||
environment: production
|
||||
@@ -157,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:
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
name: 'Lint packages'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Lint backend, frontend and shared
|
||||
uses: ./.github/actions/lint
|
||||
|
||||
@@ -41,8 +41,8 @@ jobs:
|
||||
spec: cypress/e2e/Students.js
|
||||
- name: 'Test completed courses'
|
||||
spec: cypress/e2e/Completed_courses.js
|
||||
- name: 'Test study programme overview'
|
||||
spec: cypress/e2e/Study_programme_overview.js
|
||||
- name: 'Test degree programme overview'
|
||||
spec: cypress/e2e/Degree_programme_overview.js
|
||||
- name: 'Test teachers and users'
|
||||
spec: |
|
||||
cypress/e2e/Teachers.js
|
||||
@@ -59,7 +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:
|
||||
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/test.yaml
|
||||
uses: UniversityOfHelsinkiCS/oodikone/.github/workflows/test.yaml@master
|
||||
secrets:
|
||||
username: ${{ secrets.TOSKAREGISTRY_USERNAME }}
|
||||
password: ${{ secrets.TOSKAREGISTRY_PASSWORD }}
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@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:
|
||||
|
||||
+18
-15
@@ -16,7 +16,7 @@ jobs:
|
||||
name: 'Lint packages'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Lint backend, frontend and shared
|
||||
uses: ./.github/actions/lint
|
||||
|
||||
@@ -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
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
- name: 'Upload image for other jobs'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: backend-image
|
||||
path: /tmp/backend.tar
|
||||
@@ -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@v6
|
||||
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@v6
|
||||
with:
|
||||
name: backend-image
|
||||
- name: 'Load built backend image'
|
||||
@@ -176,7 +176,6 @@ jobs:
|
||||
|
||||
# === End-to-end tests ===
|
||||
test_end_to_end:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build_and_push_anon_sis_db
|
||||
- build_and_push_anon_kone_db
|
||||
@@ -199,8 +198,8 @@ jobs:
|
||||
spec: cypress/e2e/Students.js
|
||||
- name: 'Test completed courses'
|
||||
spec: cypress/e2e/Completed_courses.js
|
||||
- name: 'Test study programme overview'
|
||||
spec: cypress/e2e/Study_programme_overview.js
|
||||
- name: 'Test degree programme overview'
|
||||
spec: cypress/e2e/Degree_programme_overview.js
|
||||
- name: 'Test teachers and users'
|
||||
spec: |
|
||||
cypress/e2e/Teachers.js
|
||||
@@ -215,9 +214,13 @@ jobs:
|
||||
spec: cypress/e2e/Study_guidance_groups.js
|
||||
- name: 'Test university'
|
||||
spec: cypress/e2e/University.js
|
||||
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: 'Run tests'
|
||||
uses: ./.github/actions/run-tests
|
||||
with:
|
||||
@@ -236,7 +239,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,10 +20,10 @@ jobs:
|
||||
name: 'Lint scheduler and worker'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 24
|
||||
- name: 'Install dependencies'
|
||||
run: npm ci && npm ci --prefix updater/sis-updater-scheduler && npm ci --prefix updater/sis-updater-worker
|
||||
- name: 'Run linter'
|
||||
@@ -33,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
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
- name: 'Upload image for other jobs'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: updater-worker-image
|
||||
path: /tmp/worker.tar
|
||||
@@ -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
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
- name: 'Upload image for other jobs'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: updater-scheduler-image
|
||||
path: /tmp/scheduler.tar
|
||||
@@ -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@v6
|
||||
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,9 +1,10 @@
|
||||
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',
|
||||
@@ -13,7 +14,6 @@ module.exports = {
|
||||
Dockerfile: files =>
|
||||
`${dockerCmdBase} hadolint/hadolint:v2.12.0 hadolint --ignore DL3006 ${relativeFilePaths(files)}`,
|
||||
'*.sh': files => `${dockerCmdBase} koalaman/shellcheck:v0.7.2 ${relativeFilePaths(files)} -x`,
|
||||
'.github/{workflows,actions}/*': files => `npm run actionlint ${relativeFilePaths(files)}`,
|
||||
'docker-compose*': files => {
|
||||
const composeFiles = file => {
|
||||
if (['docker-compose.ci.yml', 'docker-compose.test.yml'].some(f => file.includes(f))) {
|
||||
+2
-1
@@ -6,5 +6,6 @@
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "es5"
|
||||
"trailingComma": "es5",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"extends": ["stylelint-config-standard"],
|
||||
"rules": {
|
||||
"selector-class-pattern": "^[a-z][a-zA-Z-]*$"
|
||||
"selector-class-pattern": "^(?:[a-z][a-zA-Z-]*|Mui[A-Za-z-]+)$"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -111,8 +112,6 @@ The `documentation` directory contains some notes on the sis-db schema and is me
|
||||
|
||||
For internal meeting notes and sensitive information, see Toska GitLab.
|
||||
|
||||
[SortableTable documentation](./services/frontend/src/components/SortableTable/README.md)
|
||||
|
||||
### Basics
|
||||
|
||||
The development environment is entirely configured in the `docker-compose.yml` file located in this repository. The file defines the services for Oodikone's two main components: **oodikone** and **updater**.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,32 +17,32 @@ const openSearch = () => {
|
||||
}
|
||||
|
||||
const selectSavedCourselist = name => {
|
||||
cy.get('[data-cy=history-search]').click()
|
||||
cy.get('[data-cy=history-search]').type(name)
|
||||
cy.get('[data-cy=history-search]').type('{downarrow}')
|
||||
cy.get('[data-cy=history-search]').type('{enter}')
|
||||
cy.cs('history-search').click()
|
||||
cy.cs('history-search').type(name)
|
||||
cy.cs('history-search').type('{downarrow}')
|
||||
cy.cs('history-search').type('{enter}')
|
||||
}
|
||||
|
||||
const createCourseList = (courseCodes, courseListName) => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy=course-list-input]').type(courseCodes.join('\n'))
|
||||
cy.get('[data-cy=search-name]').type(courseListName)
|
||||
cy.get('[data-cy=save-courselist]').click()
|
||||
cy.cs('course-list-input').type(courseCodes.join('\n'))
|
||||
cy.cs('search-name').type(courseListName)
|
||||
cy.cs('save-courselist').click()
|
||||
}
|
||||
|
||||
const generateCourseListName = () => `TEST-course-list-${new Date().getTime()}`
|
||||
|
||||
const deleteAllPreviousSearches = () => {
|
||||
cy.get('[data-cy=history-search]').click()
|
||||
cy.cs('history-search').click()
|
||||
|
||||
cy.get('.MuiAutocomplete-popper li').then($options => {
|
||||
if ($options.length > 0) {
|
||||
cy.wrap($options).each(($option, index, $list) => {
|
||||
cy.wrap($option).click()
|
||||
cy.get('[data-cy=delete-courselist]').click()
|
||||
cy.cs('delete-courselist').click()
|
||||
if (index < $list.length - 1) {
|
||||
cy.get('[data-cy=history-search]').click()
|
||||
cy.cs('history-search').click()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -57,7 +57,7 @@ describe('When search modal is opened', () => {
|
||||
hasLanded()
|
||||
cy.url().should('include', '/completedcoursessearch')
|
||||
cy.contains('Search completed courses of students')
|
||||
cy.get('[data-cy=open-completed-courses-modal-button]').click()
|
||||
cy.cs('open-completed-courses-modal-button').click()
|
||||
})
|
||||
|
||||
it('Modal opens correctly', () => {
|
||||
@@ -74,8 +74,8 @@ describe('When search modal is opened', () => {
|
||||
cy.init('/completedcoursessearch?courseList=TKT10001&courseList=TKT10002&studentList=433237&studentList=457144')
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.contains('[data-cy=student-no-input]', '433237, 457144')
|
||||
cy.contains('[data-cy=course-list-input]', 'TKT10001, TKT10002')
|
||||
cy.cs('student-no-input').contains('433237, 457144')
|
||||
cy.cs('course-list-input').contains('TKT10001, TKT10002')
|
||||
})
|
||||
|
||||
describe('When a search is executed with invalid input', () => {
|
||||
@@ -83,16 +83,16 @@ describe('When search modal is opened', () => {
|
||||
cy.init('/completedcoursessearch')
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.get('[data-cy=student-no-input]').type('1')
|
||||
cy.get('[data-cy=course-list-input]').type('1')
|
||||
cy.get('[data-cy=completed-courses-search-button]').click()
|
||||
cy.get('[data-cy=rights-notification]').should(
|
||||
cy.cs('student-no-input').type('1')
|
||||
cy.cs('course-list-input').type('1')
|
||||
cy.cs('completed-courses-search-button').click()
|
||||
cy.cs('rights-notification').should(
|
||||
'contain.text',
|
||||
'The information for the following students could not be displayed'
|
||||
)
|
||||
cy.contains('[data-cy=rights-notification]', '1')
|
||||
cy.contains('[data-cy=completed-courses-table-div]', 'Student number')
|
||||
cy.contains('[data-cy=completed-courses-table-div]', '1').should('not.exist')
|
||||
cy.cs('rights-notification').contains('1')
|
||||
cy.cs('completed-courses-table-div').contains('Student number')
|
||||
cy.cs('completed-courses-table-div').contains('1').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,9 +102,9 @@ describe('When search modal is opened', () => {
|
||||
hasLanded()
|
||||
openCompletedCoursesModal()
|
||||
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
|
||||
cy.get('[data-cy=student-no-input]').type(studentSet1.join('\n'))
|
||||
cy.get('[data-cy=course-list-input]').type(coursesSet1.join('\n'))
|
||||
cy.get('[data-cy=completed-courses-search-button]').click()
|
||||
cy.cs('student-no-input').type(studentSet1.join('\n'))
|
||||
cy.cs('course-list-input').type(coursesSet1.join('\n'))
|
||||
cy.cs('completed-courses-search-button').click()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,10 +120,10 @@ describe('When search modal is opened', () => {
|
||||
it('Finds correct students and courses', () => {
|
||||
cy.fixture('completedCoursesData').then(({ studentSet1, coursesSet1 }) => {
|
||||
for (const studentNumber of studentSet1) {
|
||||
cy.contains('[data-cy=completed-courses-table-div]', studentNumber)
|
||||
cy.cs('completed-courses-table-div').contains(studentNumber)
|
||||
}
|
||||
for (const courseCode of coursesSet1) {
|
||||
cy.contains('[data-cy=completed-courses-table-div]', courseCode)
|
||||
cy.cs('completed-courses-table-div').contains(courseCode)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -150,7 +150,7 @@ describe('Courselist saving-related functions work', () => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.contains('[data-cy=course-list-input]', courses.join(', '))
|
||||
cy.cs('course-list-input').contains(courses.join(', '))
|
||||
})
|
||||
|
||||
it('Course list can be deleted', () => {
|
||||
@@ -159,7 +159,7 @@ describe('Courselist saving-related functions work', () => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.get('[data-cy=delete-courselist]').click()
|
||||
cy.cs('delete-courselist').click()
|
||||
cy.contains('You have no previous searches.')
|
||||
})
|
||||
|
||||
@@ -169,11 +169,11 @@ describe('Courselist saving-related functions work', () => {
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.get('[data-cy=course-list-input]').type(',TKT10001')
|
||||
cy.get('[data-cy=save-courselist]').click()
|
||||
cy.cs('course-list-input').type(',TKT10001')
|
||||
cy.cs('save-courselist').click()
|
||||
openSearch()
|
||||
openCompletedCoursesModal()
|
||||
selectSavedCourselist(courseList)
|
||||
cy.contains('[data-cy=course-list-input]', 'CSM14204, TKT10004, TKT10001')
|
||||
cy.cs('course-list-input').contains('CSM14204, TKT10004, TKT10001')
|
||||
})
|
||||
})
|
||||
|
||||
+1035
-915
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,9 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const openCustomPopupForm = () => {
|
||||
cy.get('[data-cy="custom-pop-search-button"]').click()
|
||||
}
|
||||
|
||||
const fillName = () => {
|
||||
const name = `TEST-${new Date().getTime()}`
|
||||
cy.contains('Insert name for this custom population if you wish to save it')
|
||||
cy.get('[data-cy="custom-population-name-input"]').type(name)
|
||||
cy.contains('Insert a name for this custom population if you wish to save it')
|
||||
cy.cs('custom-population-name-input').type(name)
|
||||
return name
|
||||
}
|
||||
|
||||
@@ -16,38 +12,33 @@ const save = () => {
|
||||
}
|
||||
|
||||
const selectSavedPopulation = name => {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(`${name}{enter}`)
|
||||
cy.cs('history-search').click().type(`${name}{enter}`)
|
||||
}
|
||||
|
||||
const deleteAllSearches = () => {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).click()
|
||||
cy.contains('Saved populations')
|
||||
.parent()
|
||||
.parent()
|
||||
.parent()
|
||||
.parent()
|
||||
.get('.dropdown')
|
||||
.then(dropdown => {
|
||||
const searchItems = dropdown.find('div[role=option] > span[class=text]')
|
||||
for (let i = 0; i < searchItems.length; i++) {
|
||||
if (searchItems[i].textContent.includes('TEST-')) {
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(`${searchItems[i].textContent}{enter}`)
|
||||
cy.get('button').contains('Delete').click()
|
||||
}
|
||||
}
|
||||
cy.cs('history-search').click()
|
||||
cy.get('.MuiAutocomplete-popper > .MuiPaper-root')
|
||||
.find('li[role=option]')
|
||||
.then($lis => {
|
||||
const labels = [...$lis].map(li => li.textContent?.trim() || '')
|
||||
|
||||
labels.forEach((label, index) => {
|
||||
if (index !== 0) cy.cs('history-search').click()
|
||||
cy.contains('.MuiAutocomplete-popper li[role=option]', label).click()
|
||||
cy.contains('button', 'Delete').click()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const fillForm = (content, separator) => {
|
||||
cy.get('[data-cy="student-number-input"]').type(content.join(separator))
|
||||
cy.cs('student-number-input').type(content.join(separator))
|
||||
}
|
||||
|
||||
const search = () => {
|
||||
cy.get('[data-cy="search-button"]').click()
|
||||
cy.cs('search-button').click()
|
||||
}
|
||||
|
||||
const searchFor = (studentnumbers, separator) => {
|
||||
openCustomPopupForm()
|
||||
fillForm(studentnumbers, separator)
|
||||
search()
|
||||
}
|
||||
@@ -74,7 +65,7 @@ const containsSpecificStudents = (studentnumbers = []) => {
|
||||
}
|
||||
|
||||
const checkRightsNotification = studentNumbers => {
|
||||
cy.get('[data-cy="rights-notification"]').within(() => {
|
||||
cy.cs('rights-notification').within(() => {
|
||||
cy.contains(
|
||||
'The following students information could not be displayed. This could be either because they do not exist, or you do not have the right to view their information.'
|
||||
)
|
||||
@@ -96,7 +87,6 @@ describe('Custom population tests', () => {
|
||||
cy.visit('/custompopulation')
|
||||
cy.url().should('include', '/custompopulation')
|
||||
cy.contains('Custom population')
|
||||
cy.get('button').contains('Custom population').click()
|
||||
deleteAllSearches()
|
||||
})
|
||||
|
||||
@@ -126,7 +116,7 @@ describe('Custom population tests', () => {
|
||||
cy.contains('Credit accumulation').should('not.exist')
|
||||
cy.contains('Programme distribution').should('not.exist')
|
||||
cy.contains('Courses of population').should('not.exist')
|
||||
cy.get('button').contains('Custom population')
|
||||
cy.get('button').contains('Back to search form')
|
||||
})
|
||||
|
||||
it("Doesn't return students user has no right to", () => {
|
||||
@@ -138,16 +128,15 @@ describe('Custom population tests', () => {
|
||||
cy.contains('Credit accumulation').should('not.exist')
|
||||
cy.contains('Programme distribution').should('not.exist')
|
||||
cy.contains('Courses of population').should('not.exist')
|
||||
cy.get('button').contains('Custom population')
|
||||
cy.get('button').contains('Back to search form')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom population search saving', () => {
|
||||
it('Saves a custom population search', { retries: 2 }, () => {
|
||||
cy.fixture('customPopulations').then(({ studentNumbersForCSStudentsSet1: students }) => {
|
||||
openCustomPopupForm()
|
||||
cy.contains(
|
||||
'Insert student numbers you wish to use for population. Separate each number with a comma, semicolon, space, or newline.'
|
||||
'Insert student numbers to use for the population. Each student number needs to be separated with a comma, a semicolon, a space, or a line break.'
|
||||
)
|
||||
const name = fillName()
|
||||
// A comma and a newline on purpose, the page should be able to handle it
|
||||
@@ -157,17 +146,16 @@ describe('Custom population tests', () => {
|
||||
// Round 1
|
||||
selectSavedPopulation(name)
|
||||
search()
|
||||
cy.contains(`Custom population "${name}"`)
|
||||
cy.contains(`Custom population: ${name}`)
|
||||
containsAmountOfStudents(students.length)
|
||||
containsSpecificStudents(students)
|
||||
|
||||
// Round 2
|
||||
cy.visit('/custompopulation')
|
||||
cy.contains('Custom population')
|
||||
openCustomPopupForm()
|
||||
selectSavedPopulation(name)
|
||||
search()
|
||||
cy.contains(`Custom population "${name}"`)
|
||||
cy.contains(`Custom population: ${name}`)
|
||||
containsAmountOfStudents(students.length)
|
||||
containsSpecificStudents(students)
|
||||
})
|
||||
@@ -176,28 +164,25 @@ describe('Custom population tests', () => {
|
||||
it('Updates a custom population search', () => {
|
||||
cy.fixture('customPopulations').then(
|
||||
({ studentNumbersForCSStudentsSet1: students1, studentNumbersForCSStudentsSet2: students2 }) => {
|
||||
openCustomPopupForm()
|
||||
const name = fillName()
|
||||
fillForm(students1, ' ')
|
||||
save()
|
||||
|
||||
selectSavedPopulation(name)
|
||||
search()
|
||||
cy.contains(`Custom population "${name}"`)
|
||||
cy.contains(`Custom population: ${name}`)
|
||||
containsAmountOfStudents(students1.length)
|
||||
containsSpecificStudents(students1)
|
||||
cy.contains('Custom population')
|
||||
openCustomPopupForm()
|
||||
cy.contains('Back to search form').click()
|
||||
selectSavedPopulation(name)
|
||||
fillForm(['\n', ...students2], ', ')
|
||||
save()
|
||||
|
||||
cy.visit('/custompopulation')
|
||||
cy.contains('Custom population')
|
||||
openCustomPopupForm()
|
||||
selectSavedPopulation(name)
|
||||
search()
|
||||
cy.contains(`Custom population "${name}"`)
|
||||
cy.contains(`Custom population: ${name}`)
|
||||
containsAmountOfStudents(students1.length + students2.length)
|
||||
containsSpecificStudents([...students1, ...students2])
|
||||
}
|
||||
|
||||
+45
-27
@@ -1,7 +1,19 @@
|
||||
/* eslint-disable cypress/unsafe-to-chain-command */
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const { getEmptyYears } = require('../support/commands')
|
||||
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()}`
|
||||
|
||||
@@ -18,8 +30,8 @@ const deleteTag = tagName => {
|
||||
cy.cs('confirm-delete-tag-button').click()
|
||||
}
|
||||
|
||||
describe('Study programme overview', () => {
|
||||
describe('Study programme selector', () => {
|
||||
describe('Degree programme overview', () => {
|
||||
describe('Degree programme selector', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme', 'admin')
|
||||
})
|
||||
@@ -31,7 +43,7 @@ describe('Study programme overview', () => {
|
||||
cy.contains('Doctoral programmes')
|
||||
})
|
||||
|
||||
it('Study programme search filter', () => {
|
||||
it('Degree programme search filter', () => {
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').should('exist')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').should('exist')
|
||||
cy.cs('study-programme-filter').type('Tietojenkäsittelytieteen')
|
||||
@@ -272,9 +284,13 @@ describe('Study programme overview', () => {
|
||||
|
||||
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()
|
||||
cy.cs('StudyTracksAndClassStatisticsTab').click()
|
||||
})
|
||||
|
||||
describe('Info boxes', () => {
|
||||
@@ -301,7 +317,7 @@ describe('Study programme overview', () => {
|
||||
cy.cs('average-graduation-times-section')
|
||||
})
|
||||
|
||||
it('Students of the study programme are shown correctly', () => {
|
||||
it('Students of the degree programme are shown correctly', () => {
|
||||
const tableContents = [
|
||||
// [Year, All, Started studying, Present, Absent, Inactive, Graduated, Men, Women, Other/Unknown, Finland, Other]
|
||||
['2023 - 2024', 8, 8, 0, 0, 8, 0, 5, 3, 0, 8, 1],
|
||||
@@ -357,13 +373,13 @@ describe('Study programme overview', () => {
|
||||
it('a single year', () => {
|
||||
cy.cs('2023-population-link-button').first().click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2023 - 2024')
|
||||
cy.contains('class size 8 students')
|
||||
cy.contains('Class size 8 students')
|
||||
})
|
||||
|
||||
it('total', () => {
|
||||
cy.cs('total-population-link-button').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('class size 227 students')
|
||||
cy.contains('Class size 227 students')
|
||||
})
|
||||
|
||||
it('Links to class statistics page with study track info included work', () => {
|
||||
@@ -373,9 +389,9 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2022 - 2023')
|
||||
cy.contains('studytrack MAT-MAT')
|
||||
cy.contains('class size 26 students')
|
||||
cy.contains('3 students out of 26 shown')
|
||||
cy.contains('Studytrack MAT-MAT')
|
||||
cy.contains('Class size 26 students')
|
||||
cy.contains('Showing 3 out of 26 students')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -437,8 +453,9 @@ describe('Study programme overview', () => {
|
||||
|
||||
describe('Study track can be changed', () => {
|
||||
beforeEach(() => {
|
||||
cy.cs('study-track-select').click()
|
||||
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')
|
||||
@@ -464,14 +481,14 @@ describe('Study programme overview', () => {
|
||||
cy.cs('2020-population-link-button').first().click()
|
||||
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma 2020 - 2021')
|
||||
cy.contains('studytrack MAT-MAT')
|
||||
cy.contains('class size 30 students')
|
||||
cy.contains('10 students out of 30 shown')
|
||||
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.')
|
||||
cy.contains('Progress data is currently only available for all students of the degree programme.')
|
||||
})
|
||||
|
||||
it.skip('Average graduation times are displayed correctly', () => {
|
||||
@@ -502,7 +519,7 @@ describe('Study programme overview', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('programme-courses-tab').click()
|
||||
cy.cs('ProgrammeCoursesTab').click()
|
||||
|
||||
cy.cs('by-credit-type-section')
|
||||
cy.cs('by-credit-type-tab')
|
||||
@@ -664,6 +681,7 @@ describe('Study programme overview', () => {
|
||||
cy.cs('by-semester-tab').click()
|
||||
})
|
||||
|
||||
// FIXME: Flaky
|
||||
it('test', () => {
|
||||
// TODO: Implement some real tests for this tab
|
||||
cy.contains('From')
|
||||
@@ -675,7 +693,7 @@ describe('Study programme overview', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('degree-courses-tab').click()
|
||||
cy.cs('DegreeCoursesTab').click()
|
||||
})
|
||||
|
||||
it('Curriculum section', () => {
|
||||
@@ -711,7 +729,7 @@ describe('Study programme overview', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/study-programme')
|
||||
cy.contains('a', 'Matemaattisten tieteiden kandiohjelma').click({ force: true })
|
||||
cy.cs('tags-tab').click()
|
||||
cy.cs('TagsTab').click()
|
||||
})
|
||||
|
||||
it('info box', () => {
|
||||
@@ -721,13 +739,13 @@ describe('Study programme overview', () => {
|
||||
|
||||
describe('Adding tags to populations and removing them works', () => {
|
||||
beforeEach(() => {
|
||||
cy.contains('This study programme does not have any tags yet')
|
||||
cy.contains('This degree programme does not have any tags yet')
|
||||
cy.cs('tag-name-text-field').type(tagName)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
deleteTag(tagName)
|
||||
cy.contains('This study programme does not have any tags yet')
|
||||
cy.contains('This degree programme does not have any tags yet')
|
||||
})
|
||||
|
||||
it('can create and delete tags without start year', () => {
|
||||
@@ -838,7 +856,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('can access basic information', () => {
|
||||
cy.cs('basic-information-tab').click()
|
||||
cy.cs('BasicInformationTab').click()
|
||||
|
||||
cy.cs('students-of-the-study-programme-section')
|
||||
cy.cs('credits-produced-by-the-study-programme-section')
|
||||
@@ -848,7 +866,7 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it('can access study tracks', () => {
|
||||
cy.cs('study-tracks-and-class-statistics-tab').click()
|
||||
cy.cs('StudyTracksAndClassStatisticsTab').click()
|
||||
|
||||
cy.cs('study-track-overview-section')
|
||||
cy.cs('progress-of-students-section')
|
||||
@@ -856,9 +874,9 @@ describe('Study programme overview', () => {
|
||||
})
|
||||
|
||||
it("doesn't see other tabs", () => {
|
||||
cy.cs('degree-courses-tab').should('not.exist')
|
||||
cy.cs('tags-tab').should('not.exist')
|
||||
cy.cs('update-statistics-tab').should('not.exist')
|
||||
cy.cs('DegreeCoursesTab').should('not.exist')
|
||||
cy.cs('TagsTab').should('not.exist')
|
||||
cy.cs('UpdateStatisticsTab').should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -273,7 +273,7 @@ describe('Faculty statistics', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
it.skip('Study programme toggle', () => {
|
||||
it.skip('Degree programme toggle', () => {
|
||||
// TODO: Implement
|
||||
})
|
||||
|
||||
|
||||
+396
-392
@@ -1,464 +1,468 @@
|
||||
/// <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) => {
|
||||
cy.cs(`${filterName}-filter-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', () => {
|
||||
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').selectFromDropdown(0)
|
||||
checkFilteringResult(15)
|
||||
cy.cs('StudyTrack-filter-card').cs('StudyTrack-filter-dropdown').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', () => {
|
||||
cy.cs('studyRightTypeFilter-filter-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("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.')
|
||||
|
||||
runTestStepWithPreAndPostParts('TransferredToProgramme', () => {
|
||||
cy.cs('TransferredToProgramme-filter-card').get('[data-cy="option-havenot"] input').should('be.checked')
|
||||
})
|
||||
})
|
||||
|
||||
it('Graduation filter works', { retries: 3 }, () => {
|
||||
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
|
||||
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
it('Enrollment filter works', () => {
|
||||
runTestStepWithPreAndPostParts('EnrollmentStatus', () => {
|
||||
cy.cs('enrollmentStatusFilter-status').selectFromDropdown(0)
|
||||
cy.cs('enrollmentStatusFilter-semesters').selectFromDropdown(5)
|
||||
checkFilteringResult(25)
|
||||
clearSemanticUIMultipleDropDownSelection('enrollmentStatusFilter-semesters')
|
||||
})
|
||||
})
|
||||
|
||||
it('Credit filter works', { retries: 3 }, () => {
|
||||
runTestStepWithPreAndPostParts('CreditsEarned', () => {
|
||||
testRangeFilter('CreditsEarned-filter-card', 50, 150, 12)
|
||||
})
|
||||
})
|
||||
|
||||
it('Age filter works', { retries: 3 }, () => {
|
||||
cy.clock(MOCKED_DATE, ['Date'])
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
testRangeFilter('Age-filter-card', 23, 30, 21)
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
cy.contains(`${courses[0].code} - ${courses[0].name}`).click()
|
||||
checkFilteringResult(10)
|
||||
cy.cs(`courseFilter-${courses[0].code}-dropdown`).selectFromDropdown(1) // Passed students
|
||||
checkFilteringResult(8)
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[1].code} - ${courses[1].name}`).click()
|
||||
checkFilteringResult(6)
|
||||
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(1)
|
||||
checkFilteringResult(5)
|
||||
courses.forEach(({ code }) => {
|
||||
cy.cs(`courseFilter-${code}-clear`).click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
cy.cs('reset-all-filters').click()
|
||||
checkFilteringResult(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Study right type filter is not visible', () => {
|
||||
it.skip('Study right type filter is not visible', () => {
|
||||
cy.contains('Study right type').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Population Statistics with Bachelor + Master', () => {
|
||||
const pathToMathBSc2017 =
|
||||
'/populations?months=91&semesters=FALL&semesters=SPRING&showBachelorAndMaster=true&studyRights=%7B%22programme%22%3A%22KH50_001%22%2C%22combinedProgramme%22%3A%22%22%7D&year=2017'
|
||||
const defaultAmountOfStudents = 47
|
||||
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
|
||||
// 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)
|
||||
|
||||
beforeEach(() => {
|
||||
cy.init(pathToMathBSc2017)
|
||||
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', () => {
|
||||
runTestStepWithPreAndPostParts('GraduatedFromProgramme', () => {
|
||||
const getCard = () => cy.cs('GraduatedFromProgramme-filter-card')
|
||||
cy.cs('graduatedFromProgrammeFilter-header').click()
|
||||
|
||||
getCard().cs('option-graduated-bachelor').click()
|
||||
checkFilteringResult(42)
|
||||
getCard().cs('option-graduated-master').click()
|
||||
checkFilteringResult(18)
|
||||
getCard().cs('option-not-graduated-bachelor').click()
|
||||
checkFilteringResult(5)
|
||||
getCard().cs('option-not-graduated-master').click()
|
||||
checkFilteringResult(29)
|
||||
getCard().cs('option-all').click()
|
||||
})
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').click()
|
||||
cy.get('input[name="graduatedFromProgrammeFilter"][value="1"]').should('be.checked')
|
||||
checkFilteredStudentCount(graduated)
|
||||
|
||||
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('Study right status filter works', () => {
|
||||
runTestStepWithPreAndPostParts('studyRightStatusFilter', () => {
|
||||
const getCard = () => cy.cs('studyRightStatusFilter-filter-card')
|
||||
it('Enrollment filter works', () => {
|
||||
cy.cs('enrollmentStatusFilter-header').click()
|
||||
cy.selectFromDropdown('enrollmentStatusFilter-status', 0)
|
||||
cy.selectFromDropdown('enrollmentStatusFilter-semester', 'Syksy 2023', true)
|
||||
checkFilteredStudentCount(25)
|
||||
|
||||
// First two are 0 because there are no semester enrollments for the current semester
|
||||
getCard().cs('option-active').click()
|
||||
checkFilteringResult(0)
|
||||
getCard().cs('option-active-combined').click()
|
||||
checkFilteringResult(0)
|
||||
getCard().cs('option-inactive').click()
|
||||
checkFilteringResult(5)
|
||||
getCard().cs('option-inactive-combined').click()
|
||||
checkFilteringResult(24)
|
||||
getCard().cs('option-activity-status-all').click()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Course Statistics', () => {
|
||||
const pathToLimits2021 =
|
||||
'/coursepopulation?coursecodes=%5B"MAT11003"%2C"57116"%2C"57016"%2C"AYMAT11003"%5D&from=72&separate=false&to=72&unifyCourses=unifyStats&years=2021-2022'
|
||||
const defaultAmountOfStudents = 48
|
||||
const runTestStepWithPreAndPostParts = createRunTestStepWithPreAndPostPartsFunction(defaultAmountOfStudents)
|
||||
|
||||
beforeEach(() => {
|
||||
cy.init(pathToLimits2021)
|
||||
cy.cs('enrollmentStatusFilter-clear').click()
|
||||
cy.cs('enrollmentStatusFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
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('Programme defaults to "Attainment" mode', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
cy.cs('Programme-filter-mode-selector').children().eq(0).should('contain', 'Attainment')
|
||||
})
|
||||
})
|
||||
it('Age filter works', () => {
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 21)
|
||||
|
||||
it('Programme filter works', () => {
|
||||
runTestStepWithPreAndPostParts('Programme', () => {
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(33)
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').get('i.delete').click()
|
||||
})
|
||||
})
|
||||
|
||||
it('Age filter works', { 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(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(10)
|
||||
checkFilteringResult(24)
|
||||
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', () => {
|
||||
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', () => {
|
||||
const courses = [
|
||||
{ code: 'MAT21012', name: 'Differentiaaliyhtälöt I' },
|
||||
{ code: 'MFK-M204', name: 'Matematiikkaa kaikkialla' },
|
||||
]
|
||||
|
||||
cy.cs('courseFilter-header').click()
|
||||
cy.selectFromAutocomplete('courseFilter', `${courses[0].code} - ${courses[0].name}`)
|
||||
checkFilteredStudentCount(9)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
|
||||
checkFilteredStudentCount(7)
|
||||
cy.selectFromAutocomplete('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('Grade', () => {
|
||||
runTestStepWithPreAndPostParts('Age', () => {
|
||||
cy.cs('gradeFilter-5').click()
|
||||
checkFilteringResult(23)
|
||||
testRangeFilter('Age-filter-card', 21, 30, 17)
|
||||
cy.cs('gradeFilter-5').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', () => {
|
||||
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()
|
||||
cy.cs('genderFilter-header').click()
|
||||
cy.cs('startYearAtUniFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Population Statistics', () => {
|
||||
let runTestStepWithPreAndPostParts
|
||||
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(() => {
|
||||
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', () => {
|
||||
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 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('Age filter works', () => {
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
cy.setRangeSelect('ageFilter', 23, 30, 36)
|
||||
|
||||
cy.cs('ageFilter-clear').click()
|
||||
cy.cs('ageFilter-header').click()
|
||||
checkFilteredStudentCount(defaultAmountOfStudents)
|
||||
})
|
||||
|
||||
it('Gender filter works', () => {
|
||||
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', () => {
|
||||
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.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', { testIsolation: false }, () => {
|
||||
let defaultAmountOfStudents
|
||||
|
||||
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)
|
||||
defaultAmountOfStudents = students.length
|
||||
cy.cs('student-number-input').click()
|
||||
cy.cs('student-number-input').type(students.join('{enter}'))
|
||||
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', () => {
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').selectFromDropdown(1)
|
||||
checkFilteringResult(3)
|
||||
cy.cs('Programme-filter-card').cs('Programme-filter-dropdown').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()
|
||||
cy.contains(`${courses[0].code} - ${courses[0].name}`).click()
|
||||
checkFilteringResult(7)
|
||||
cy.cs(`courseFilter-${courses[0].code}-dropdown`).selectFromDropdown(1)
|
||||
checkFilteringResult(6)
|
||||
cy.cs('courseFilter-course-dropdown').click()
|
||||
cy.contains(`${courses[1].code} - ${courses[1].name}`).click()
|
||||
checkFilteringResult(5)
|
||||
cy.cs(`courseFilter-${courses[1].code}-dropdown`).selectFromDropdown(3)
|
||||
checkFilteringResult(2)
|
||||
courses.forEach(({ code }) => {
|
||||
cy.cs(`courseFilter-${code}-clear`).click()
|
||||
})
|
||||
})
|
||||
const courses = [
|
||||
{ code: 'MAT11005', name: 'Integraalilaskenta' },
|
||||
{ code: 'MAT21007', name: 'Mitta ja integraali' },
|
||||
]
|
||||
|
||||
cy.cs('courseFilter-header').click()
|
||||
cy.selectFromAutocomplete('courseFilter', `${courses[0].code} - ${courses[0].name}`)
|
||||
checkFilteredStudentCount(7)
|
||||
cy.selectFromDropdown(`courseFilter-${courses[0].code}`, 1)
|
||||
checkFilteredStudentCount(6)
|
||||
cy.selectFromAutocomplete('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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,43 +1,23 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const hasLanded = () => {
|
||||
cy.contains('This view displays amounts')
|
||||
cy.contains('Show number of')
|
||||
}
|
||||
|
||||
const chooseSemester = (semester, fromOrTo) => {
|
||||
cy.get(`[data-cy="semester-${fromOrTo}"]`)
|
||||
.click()
|
||||
.within(() => {
|
||||
cy.get('div.visible.menu.transition').within(() => {
|
||||
cy.contains('div.item', semester).click()
|
||||
})
|
||||
cy.get('div.menu.transition').should('not.have.class', 'visible')
|
||||
})
|
||||
cy.cs(`semester-${fromOrTo}`).click()
|
||||
cy.cs(`select-opt-${semester}`).click()
|
||||
cy.cs(`select-opt-${semester}`).should('not.have.class', 'visible')
|
||||
}
|
||||
|
||||
const checkNumbers = (numbers, numberOfColumns, mode) => {
|
||||
const offset = mode === 'semesters' ? 2 : 1
|
||||
cy.get('table > tbody > tr:first').within(() => {
|
||||
cy.get('td').should('have.length', numberOfColumns)
|
||||
cy.get('td').eq(0).contains('All courses total')
|
||||
numbers.forEach((number, index) => {
|
||||
cy.get('td')
|
||||
.eq(index + offset)
|
||||
.contains(number)
|
||||
})
|
||||
})
|
||||
}
|
||||
const offset = 1 // skip first col with codes / names
|
||||
cy.cs(`ooditable-${mode}`).find('table').find('tbody').as('tbody')
|
||||
cy.get('@tbody').find('tr').first().as('totalRow')
|
||||
cy.get('@totalRow').find('td').should('have.length', numberOfColumns)
|
||||
cy.get('@totalRow').find('td').eq(0).should('contain', 'All courses total')
|
||||
|
||||
const checkStyle = (styles, numberOfColumns) => {
|
||||
cy.get('table > tbody > tr:first').within(() => {
|
||||
cy.get('td').should('have.length', numberOfColumns)
|
||||
styles.forEach((style, index) => {
|
||||
cy.get('td')
|
||||
.eq(index + 2)
|
||||
.should('have.attr', 'style')
|
||||
.and('include', style)
|
||||
})
|
||||
numbers.forEach((number, index) => {
|
||||
cy.get('@totalRow')
|
||||
.find('td')
|
||||
.eq(index + offset)
|
||||
.should('contain', number)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,8 +25,7 @@ describe('When language center is opened', () => {
|
||||
describe('as an admin user', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/languagecenterview', 'admin')
|
||||
cy.get('[data-cy="completions-button"]').click()
|
||||
hasLanded()
|
||||
cy.cs('completions-button').click()
|
||||
})
|
||||
|
||||
describe('Faculties tab', () => {
|
||||
@@ -65,7 +44,7 @@ describe('When language center is opened', () => {
|
||||
cy.get('table > tbody > tr:first').within(() => {
|
||||
cy.get('td').should('have.length', 15)
|
||||
cy.get('td').eq(0).contains('All courses total')
|
||||
const numbers = [36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0, 2076]
|
||||
const numbers = [2076, 36, 9, 33, 756, 15, 40, 6, 48, 1, 24, 1059, 49, 0]
|
||||
numbers.forEach((number, index) => {
|
||||
cy.get('td')
|
||||
.eq(index + 1)
|
||||
@@ -75,13 +54,13 @@ describe('When language center is opened', () => {
|
||||
})
|
||||
|
||||
it('Faculties tab "exceeding" button works', () => {
|
||||
cy.get('[data-cy="difference-button"]').click()
|
||||
checkNumbers([...new Array(12).fill(0), 1, 221], 15)
|
||||
cy.cs('difference-button').click()
|
||||
checkNumbers([65, 2, 0, 3, 13, 0, 1, 1, 2, 0, 1, 30, 11, 1], 15, 'faculties')
|
||||
})
|
||||
|
||||
it('Faculties tab semester selector changes numbers', () => {
|
||||
chooseSemester('Syksy 2020', 'from')
|
||||
checkNumbers([28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0, 1184], 15)
|
||||
checkNumbers([1184, 28, 6, 27, 496, 4, 10, 2, 33, 0, 9, 535, 34, 0], 15, 'faculties')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,20 +72,7 @@ describe('When language center is opened', () => {
|
||||
})
|
||||
|
||||
it('Semester tab shows numbers', () => {
|
||||
checkNumbers([69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1, 2076], 17, 'semesters')
|
||||
})
|
||||
|
||||
it('Coloring mode works on semester tab', () => {
|
||||
cy.contains('Compare to average of course').click()
|
||||
const expectedAlphas = [0.04, 0.016, 0.17, 0.03, 0.192, 0.055, 0.247, 0.08, 0.173, 0.05, 0.07, 0.043, 0.004, 0]
|
||||
|
||||
checkStyle(
|
||||
expectedAlphas.map(alpha => `background-color: rgba(0, 170, 0, ${alpha})`),
|
||||
17
|
||||
)
|
||||
cy.get('table > tbody > tr:first').within(() => {
|
||||
cy.get('td').eq(16).should('have.attr', 'style').and('not.include', 'background-color')
|
||||
})
|
||||
checkNumbers([2076, 69, 26, 298, 58, 343, 98, 438, 138, 310, 90, 123, 74, 10, 1], 16, 'semesters')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+38
-28
@@ -1,24 +1,23 @@
|
||||
/// <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', () => {
|
||||
// Skipping the whole test suite as it is unclear whether the feature has any use or will be
|
||||
// removed alltogether.
|
||||
describe.skip('Open uni population tests', () => {
|
||||
describe('Open uni form can be used', () => {
|
||||
// When openUniUser is created, change it here
|
||||
beforeEach(() => {
|
||||
@@ -26,27 +25,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 +82,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 +102,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,20 +2,24 @@
|
||||
// Now "Class statistics" in UI
|
||||
|
||||
const selectStudyProgramme = programme => {
|
||||
cy.contains('Select study programme')
|
||||
cy.get('[data-cy=select-study-programme]').click()
|
||||
cy.get('[data-cy=select-study-programme]').children().contains(programme).click()
|
||||
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()
|
||||
cy.get('[data-cy=select-study-track]').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')
|
||||
@@ -23,37 +27,40 @@ describe('Population statistics tests', () => {
|
||||
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')
|
||||
@@ -61,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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,7 +95,7 @@ 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')
|
||||
@@ -128,7 +139,6 @@ describe('Population statistics tests', () => {
|
||||
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()
|
||||
|
||||
@@ -143,8 +153,9 @@ describe('Population statistics tests', () => {
|
||||
}))
|
||||
}
|
||||
|
||||
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.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')
|
||||
@@ -192,34 +203,34 @@ describe('Population statistics tests', () => {
|
||||
.then($parentDiv => {
|
||||
if (!$parentDiv.hasClass('active')) cy.contains('Credit statistics').click()
|
||||
})
|
||||
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").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'],
|
||||
data: ['6136.50', '227.28', '197.00', '117.94', '60', '533'],
|
||||
size: 27,
|
||||
},
|
||||
{
|
||||
selector: 'Muu',
|
||||
data: ['359.00', '119.67', '111.00', '43.71', '71', '177'],
|
||||
data: ['580.00', '193.33', '192.00', '36.75', '149', '239'],
|
||||
size: 3,
|
||||
},
|
||||
{
|
||||
selector: 'Todistusvalinta',
|
||||
data: ['2742.50', '152.36', '178.00', '65.75', '19', '268'],
|
||||
data: ['3657.50', '203.19', '193.50', '98.35', '60', '460'],
|
||||
size: 18,
|
||||
},
|
||||
{
|
||||
selector: 'Avoin väylä',
|
||||
data: ['672.00', '112.00', '104.00', '29.40', '82', '169'],
|
||||
data: ['1899.00', '316.50', '257.00', '150.43', '150', '533'],
|
||||
size: 6,
|
||||
},
|
||||
]
|
||||
|
||||
for (const { selector, data, size } of categories) {
|
||||
cy.get(`[data-cy='statistics-table-${selector}']`).within(() => {
|
||||
cy.contains('h3', selector)
|
||||
cy.contains('h5', selector)
|
||||
cy.contains("[data-cy='credit-stats-population-size']", `n = ${size}`)
|
||||
cy.get('table tbody').within(() => {
|
||||
rows.forEach((text, index) => {
|
||||
@@ -241,8 +252,8 @@ describe('Population statistics tests', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v3/courseyearlystats**').as('coursePage')
|
||||
cy.get('[data-cy=toggle-group-module-MAT110]').click()
|
||||
cy.contains('td', 'MAT11001').siblings().find('i.level.up').click()
|
||||
cy.cs('toggle-group-module-MAT110').click()
|
||||
cy.contains('td', 'MAT11001').siblings().find('[data-testid="NorthEastIcon"]').click()
|
||||
cy.wait('@coursePage')
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains('MAT11001 • Johdatus yliopistomatematiikkaan')
|
||||
@@ -261,47 +272,39 @@ describe('Population statistics tests', () => {
|
||||
cy.get('[data-cy=toggle-group-module-MAT-tyo]').should('not.exist')
|
||||
})
|
||||
|
||||
it('New fetch of courses data is done when curriculum is changed', () => {
|
||||
it('Courses data is changed when curriculum is changed', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v4/populationstatistics/courses').as('courseData')
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2020–2023').click({ force: true })
|
||||
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(true)
|
||||
})
|
||||
cy.get('[data-cy=curriculum-picker]').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
expect(response.body.coursestatistics.some(stat => stat.course.code === 'DIGI-100')).to.equal(false)
|
||||
})
|
||||
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('New fetch of courses data is done when filtered students change', () => {
|
||||
it('Courses data is changed when filtered students change', () => {
|
||||
cy.visit(pathToMathBSc2020)
|
||||
cy.contains('Courses of class').click()
|
||||
cy.intercept('/api/v4/populationstatistics/courses').as('courseData')
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(27)
|
||||
})
|
||||
cy.get('[data-cy=GraduatedFromProgramme-filter-card]').within(() => {
|
||||
cy.get('[data-cy=GraduatedFromProgramme-header]').click()
|
||||
cy.get('[data-cy=option-graduated-true]').click()
|
||||
cy.wait('@courseData').then(({ response }) => {
|
||||
expect(response.body).to.have.property('allStudents')
|
||||
expect(response.body).to.have.property('coursestatistics')
|
||||
expect(response.body.allStudents).to.equal(16)
|
||||
})
|
||||
|
||||
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)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -316,7 +319,7 @@ describe('Population statistics tests', () => {
|
||||
})
|
||||
|
||||
it("'General tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.cs('ooditable-general').within(() => {
|
||||
cy.contains('522142')
|
||||
cy.contains('Tilastotiede')
|
||||
cy.contains('Matematiikka')
|
||||
@@ -330,14 +333,16 @@ describe('Population statistics tests', () => {
|
||||
it("'Courses tab' is usable", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Courses').click()
|
||||
})
|
||||
cy.cs('ooditable-courses').within(() => {
|
||||
cy.contains('MAT12001')
|
||||
cy.contains('MAT21001')
|
||||
})
|
||||
})
|
||||
|
||||
it("'Modules tab' Displays correct modules based on the selected programme", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Modules').click()
|
||||
cy.cs('student-table-tabs').contains('Modules').click()
|
||||
cy.cs('ooditable-modules').within(() => {
|
||||
cy.contains('MAT011')
|
||||
cy.contains('MAT110')
|
||||
})
|
||||
@@ -345,18 +350,17 @@ describe('Population statistics tests', () => {
|
||||
cy.cs('curriculum-picker').click()
|
||||
cy.contains('2023–2026').click()
|
||||
cy.contains('Courses of class').click()
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.cs('ooditable-modules').within(() => {
|
||||
cy.contains('MAT011').should('not.exist')
|
||||
cy.contains('MAT110')
|
||||
})
|
||||
})
|
||||
|
||||
it.skip("Empty 'tags' tab has a link to the page where tags can be created", () => {
|
||||
// TODO: This fails in the pipeline, but works locally. Investigate.
|
||||
it("Empty 'tags' tab has a link to the page where tags can be created", () => {
|
||||
cy.cs('student-table-tabs').within(() => {
|
||||
cy.contains('Tags').click()
|
||||
cy.contains('No tags defined. You can define them here.').find('a').click()
|
||||
})
|
||||
cy.contains('No tags defined. You can define them here.').find('a').click()
|
||||
cy.url().should('include', '/study-programme/KH50_001?tab=4')
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.contains('Create new tag')
|
||||
@@ -379,17 +383,15 @@ describe('Population statistics tests', () => {
|
||||
cy.contains(nonExisting).should('not.exist')
|
||||
cy.contains('button', 'Check student numbers').click()
|
||||
cy.contains('Check for student numbers')
|
||||
cy.get('textarea').type(existing)
|
||||
cy.get('textarea').type('{enter}')
|
||||
cy.get('textarea').type(nonExisting)
|
||||
cy.cs('check-student-numbers').click().type(`${existing}{enter}${nonExisting}`)
|
||||
cy.contains('button', 'Check students').click()
|
||||
cy.contains('#checkstudentsresults', 'Results').within(() => {
|
||||
cy.contains('Student numbers in list and in Sisu').click()
|
||||
cy.contains('#found', existing)
|
||||
cy.contains('Student numbers in list but not in Sisu').click()
|
||||
cy.contains('#notfound', nonExisting)
|
||||
cy.contains('Student numbers in Sisu but not in list').click()
|
||||
cy.contains('#notsearched', '457144')
|
||||
cy.get('#checkstudentsresults').within(() => {
|
||||
cy.cs('found-title').click()
|
||||
cy.cs('found-data').contains(existing)
|
||||
cy.cs('not-found-title').click()
|
||||
cy.cs('not-found-data').contains(nonExisting)
|
||||
cy.cs('not-searched-title').click()
|
||||
cy.cs('not-searched-data').contains('457144')
|
||||
})
|
||||
cy.contains('button', 'Close').click()
|
||||
cy.contains('Student numbers in list and in Sisu').should('not.exist')
|
||||
@@ -400,29 +402,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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -20,12 +20,13 @@ const typeStudentNumberAndClick = studentNumber => {
|
||||
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)
|
||||
cy.contains('Student number')
|
||||
@@ -159,12 +160,10 @@ describe('Students tests', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('Study guidance group tests', () => {
|
||||
|
||||
const studyGuidanceGroupsTableContent = [
|
||||
['MAT kandit 2020', 3, 'Matemaattisten tieteiden kandiohjelma', '2020 - 2021'],
|
||||
['Oma ohjausryhmä', 2, 'Add study programme', 'Add year'],
|
||||
['Oma ohjausryhmä', 2, 'Add degree programme', 'Add starting year'],
|
||||
]
|
||||
|
||||
cy.checkTableStats(studyGuidanceGroupsTableContent, 'study-guidance-group-overview')
|
||||
@@ -52,11 +52,11 @@ describe('Study guidance group tests', () => {
|
||||
it('has a working back button', () => {
|
||||
cy.init('/studyguidancegroups/sgg-cypress-1', 'onlystudyguidancegroups')
|
||||
|
||||
cy.get('button.ui.button').contains('Back').click()
|
||||
cy.get('button').contains('Back to groups').click()
|
||||
cy.url().should('eq', `${Cypress.config().baseUrl}/studyguidancegroups`)
|
||||
})
|
||||
|
||||
describe('without associated study programme and year', () => {
|
||||
describe('without associated degree programme and year', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/studyguidancegroups/sgg-cypress-2', 'onlystudyguidancegroups')
|
||||
})
|
||||
@@ -66,43 +66,43 @@ 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', () => {
|
||||
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',
|
||||
'Degree programmes',
|
||||
'Tags',
|
||||
]
|
||||
cy.cs('Students (2)').click()
|
||||
cy.contains('Students (2)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
|
||||
colHeaders.every(heading => cy.contains(heading))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with associated study programme and year', () => {
|
||||
describe('with associated degree programme and year', () => {
|
||||
beforeEach(() => {
|
||||
cy.init('/studyguidancegroups/sgg-cypress-1', 'onlystudyguidancegroups')
|
||||
})
|
||||
|
||||
it('shows the correct labels', () => {
|
||||
cy.get('.ui.medium.header').contains('MAT kandit 2020')
|
||||
cy.get('.ui.blue.tag.label:first').contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.get('.ui.blue.tag.label:last').contains('2020 - 2021')
|
||||
cy.get('h4').contains('MAT kandit 2020')
|
||||
cy.get('.MuiChip-root:first').contains('Matemaattisten tieteiden kandiohjelma')
|
||||
cy.get('.MuiChip-root:last').contains('2020 - 2021')
|
||||
})
|
||||
|
||||
it('shows the correct panes', () => {
|
||||
@@ -110,28 +110,31 @@ 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('.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')
|
||||
it("clicking the 'Show credits starting from the associated academic year' filter", () => {
|
||||
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
|
||||
cy.cs('Credit accumulation (for 3 students)').click()
|
||||
cy.get('label').contains('Show credits starting from the associated academic year').click()
|
||||
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="true"]')
|
||||
cy.get('.date-picker')
|
||||
.eq(0)
|
||||
.within(() => cy.get('input').should('have.value', '01.08.2020'))
|
||||
cy.contains('Reset All Filters')
|
||||
cy.get('.ui.primary.button').contains('Show all credits').click()
|
||||
cy.get('[data-cy="CreditDate-filter-card"][data-open="false"]')
|
||||
cy.get('label').contains('Show credits starting from the associated academic year').click()
|
||||
cy.get('[data-cy="ParticipationDate-filter-card"][data-active="false"]')
|
||||
})
|
||||
|
||||
it('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', 'Modules', 'Progress'])
|
||||
})
|
||||
})
|
||||
|
||||
it('general tab of the students table has the correct columns', () => {
|
||||
it.skip('general tab of the students table has the correct columns', () => {
|
||||
const colHeaders = [
|
||||
'Student number',
|
||||
'All credits',
|
||||
@@ -151,7 +154,7 @@ describe('Study guidance group tests', () => {
|
||||
'Latest attainment date',
|
||||
'Tags',
|
||||
]
|
||||
cy.cs('Students (3)').click()
|
||||
cy.contains('Students (3)').click()
|
||||
cy.get('[data-cy="student-table-tabs"] table thead tr').within(() => {
|
||||
colHeaders.every(heading => cy.contains(heading))
|
||||
})
|
||||
|
||||
+19
-19
@@ -10,17 +10,19 @@ describe('Teachers page tests', () => {
|
||||
const teacher1 = 'Luokkanen Liisa Viljami'
|
||||
const teacher2 = 'Perälä Juhani Susanna'
|
||||
|
||||
const statisticsHeaders = ['Name', 'Credits', 'Credits transferred', 'Passed']
|
||||
|
||||
it('Check Statistics', () => {
|
||||
cy.get(':nth-child(1) > .ui > .search').click()
|
||||
cy.cs('semester-start').click()
|
||||
cy.contains('Syksy 2020').click()
|
||||
cy.cs('course-providers').click()
|
||||
cy.contains('Matemaattisten tieteiden kandiohjelma').click()
|
||||
cy.cs('course-providers').children('.icon').click()
|
||||
cy.contains('button', 'Search').click()
|
||||
cy.get('body').click(0, 0) // Click outside of the select
|
||||
cy.cs('search-statistics').click()
|
||||
|
||||
cy.contains('Teacher')
|
||||
const headers = ['Name', 'Credits', 'Credits transferred', 'Passed']
|
||||
cy.get('table thead tr th').should('have.length', 4)
|
||||
headers.forEach((header, index) => {
|
||||
statisticsHeaders.forEach((header, index) => {
|
||||
cy.get('table thead tr th').eq(index).should('contain', header)
|
||||
})
|
||||
cy.contains('td', teacher1).siblings().eq(0).contains('235')
|
||||
@@ -30,15 +32,15 @@ describe('Teachers page tests', () => {
|
||||
})
|
||||
|
||||
it('Teacher search works', () => {
|
||||
cy.get('.borderless > :nth-child(3)').click()
|
||||
cy.get('.prompt').type(teacher1.split(' ')[0])
|
||||
cy.cs('Search').click()
|
||||
cy.cs('teacher-search').type(teacher1.split(' ')[0])
|
||||
cy.get('table tbody tr').should('have.length', 4)
|
||||
cy.get('table tbody tr').eq(2).contains('td', teacher1)
|
||||
cy.get('table tbody tr').eq(3).contains('td', teacher1)
|
||||
})
|
||||
|
||||
it('Can check teacher page', () => {
|
||||
cy.get('.borderless > :nth-child(3)').click()
|
||||
cy.get('.prompt').type(teacher2)
|
||||
cy.cs('Search').click()
|
||||
cy.cs('teacher-search').type(teacher2)
|
||||
|
||||
// Prevent opening in new tab
|
||||
cy.contains('a', teacher2).invoke('removeAttr', 'target').click()
|
||||
@@ -46,19 +48,17 @@ describe('Teachers page tests', () => {
|
||||
cy.contains(teacher2)
|
||||
cy.contains('Syksy 2023').click()
|
||||
cy.contains('Kevät 2019').click()
|
||||
cy.get('.ok-sortable-table tbody tr')
|
||||
.eq(0)
|
||||
.within(() => {
|
||||
const rowContent = ['MAT12004', 'Tilastollinen päättely I', '120', '0', '92.31%']
|
||||
rowContent.forEach((content, index) => {
|
||||
cy.get('td').eq(index).contains(content)
|
||||
})
|
||||
cy.contains('tr', 'MAT12004').within(() => {
|
||||
const rowContent = ['MAT12004', 'Tilastollinen päättely I', '120', '0', '92.31%']
|
||||
rowContent.forEach((content, index) => {
|
||||
cy.get('td').eq(index).contains(content)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Check leaderboad works', () => {
|
||||
cy.get('.borderless > :nth-child(2)').click()
|
||||
cy.get(':nth-child(1) > .ui > .search').click()
|
||||
cy.cs('Leaderboard').click()
|
||||
cy.cs('academic-year').click()
|
||||
cy.contains('2020-2021').click()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,7 +51,8 @@ describe('University view', () => {
|
||||
checkProgressTables()
|
||||
})
|
||||
|
||||
it('years in the tables can be clicked to show faculty level breakdown', () => {
|
||||
// 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 })
|
||||
|
||||
+12
-7
@@ -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
|
||||
|
||||
+72
-43
@@ -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,27 +107,72 @@ 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()
|
||||
})
|
||||
|
||||
/**
|
||||
* Select item from FilterSearchableSelect dropdown.
|
||||
* If the index is a number, select index-th item.
|
||||
* Otherwise select the item containing index text.
|
||||
*/
|
||||
Cypress.Commands.add('selectFromAutocomplete', { prevSubject: false }, (label, index) => {
|
||||
cy.cs(`${label}-selector`).click()
|
||||
cy.get(`#${label}-popper.MuiAutocomplete-popper [role="listbox"]`).within(() => {
|
||||
if (typeof index === 'number') cy.get('li').eq(index).click()
|
||||
else cy.get('li').contains(index).click()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Set range selector values FilterRange.
|
||||
* If the reset flag is set, the function tries to reset the original values.
|
||||
*/
|
||||
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) => {
|
||||
@@ -142,22 +190,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
|
||||
|
||||
@@ -120,6 +120,7 @@ services:
|
||||
- redis
|
||||
environment:
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
SECRET_TOKEN: dev
|
||||
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
|
||||
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
|
||||
@@ -148,6 +149,7 @@ services:
|
||||
environment:
|
||||
DB_URL: postgres://postgres@sis-db:5432/sis-db
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
SIS_IMPORTER_HOST: ${SIS_IMPORTER_DB_HOST:-host.docker.internal}
|
||||
SIS_IMPORTER_PORT: ${SIS_IMPORTER_DB_PORT:-15234}
|
||||
SIS_IMPORTER_DATABASE: importer-db
|
||||
|
||||
@@ -21,7 +21,7 @@ The roles `facultyStatistics`, `openUniSearch`, and `studyGuidanceGroups` simply
|
||||
|
||||
### teachers
|
||||
|
||||
- By default, the role only allows seeing the statistics of course providers (study programmes) they have full rights to (see [Programmes](#programmes))
|
||||
- By default, the role only allows seeing the statistics of course providers (degree programmes) they have full rights to (see [Programmes](#programmes))
|
||||
- This role always needs to be given and removed manually
|
||||
- If the user is a member of the `hy-dekaanit` or `hy-varadekaanit-opetus` IAM group (or is an admin), they can see leaderboards, information about individual teachers, and statistics of all course providers. They don't need to be given the `teachers` role manually.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ The made changes do not have any effects on faculty or trends views. **Needs man
|
||||
|
||||
Basic rule of thumb is that for the master/licentiate programme I used names `combinedProgramme` and in some rare cases `secondProgramme` as whole or part of the variable names.
|
||||
|
||||
## Study programme
|
||||
## Degree programme
|
||||
|
||||
**Needs manual work** in the function `getCombinedProgrammeId` in `services/frontend/src/common/index.js` if new programmes is added. For showing the both programme data correctly, the filtering mechanism is changed to add both programmes and it is not caring for which programme user has acual rights or IAMrights. However, the links for the class stats level are hidden in studytrack tables, if the user has no rights for the programme.
|
||||
|
||||
@@ -15,7 +15,7 @@ Basic rule of thumb is that for the master/licentiate programme I used names `co
|
||||
- Overview
|
||||
- **Needs manual work**
|
||||
|
||||
**Redis nightly study programme update**
|
||||
**Redis nightly degree programme update**
|
||||
|
||||
- Updates automatically combined programme stats. **Needs manual work** here `services/backend/src/services/studyprogrammeHelpers.js` in the constant `combinedStudyprogrammes`. This might needs refactoring if bachelor programme can have many separate master programmes. Then remember to change function `refreshNewOverview` in `services/backend/src/events.js` accordingly to handle lists.
|
||||
|
||||
@@ -91,6 +91,6 @@ All things listed below should work automatically, if new combinations are done.
|
||||
|
||||
## Study guidance groups
|
||||
|
||||
- Backend: student data is fetched by bachelor study programme code in `/v3/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
|
||||
- Backend: student data is fetched by bachelor degree programme code in `/v3/populationstatisticsbystudentnumbers`. Separation from code 'KHxx_xxx+MHxx_xxx' is done in backend.
|
||||
- Frontend: things are done in the same way as with class stats.
|
||||
- **Need manual work**: `./oodikone/services/frontend/src/redux/elementdetails.js` add combined programme codes (both bachelor and master) to `combinedProgrammeCodes` and modify `combinedOptions` list accordingly in `useFilterAndFormattedElementDetails()` function.
|
||||
|
||||
@@ -53,7 +53,7 @@ erDiagram
|
||||
open_uni_population_searches
|
||||
progress_criteria
|
||||
study_guidance_group_tags
|
||||
study_programme_pins
|
||||
degree_programme_pins
|
||||
tag ||--o{ tag_student : "belongs to"
|
||||
tag
|
||||
tag_student
|
||||
|
||||
@@ -46,7 +46,6 @@ A collection of useful tools, documentation and other resources.
|
||||
- [Material Icons](https://mui.com/material-ui/material-icons/)
|
||||
- [MUI X Date pickers](https://mui.com/x/react-date-pickers/)
|
||||
- [Material React Table documentation](https://www.material-react-table.com/docs)
|
||||
- [Semantic UI React documentation](https://react.semantic-ui.com)
|
||||
|
||||
<!--
|
||||
|
||||
|
||||
+208
-135
@@ -1,183 +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-unused-vars': 'off', // Disabled in favor of @typescript-eslint
|
||||
'object-shorthand': ['error', 'always'],
|
||||
'prefer-const': 'error',
|
||||
'prefer-destructuring': ['error', { VariableDeclarator: { object: true } }],
|
||||
"no-restricted-imports": ['error', { 'patterns': [{ 'regex': '^@mui/[^/]+$' }] }],
|
||||
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
|
||||
'react/display-name': 'off', // TODO: Delete this override
|
||||
'react/function-component-definition': [
|
||||
'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',
|
||||
},
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
'import/internal-regex': '^@oodikone/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
+3120
-3303
File diff suppressed because it is too large
Load Diff
+19
-19
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"name": "oodikone",
|
||||
"description": "An application for analyzing university data.",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"cypress": "cypress",
|
||||
"cypress:open": "cypress open",
|
||||
@@ -9,7 +12,6 @@
|
||||
"eslint": "eslint",
|
||||
"lint:all": "eslint services/ updater/",
|
||||
"stylelint": "stylelint",
|
||||
"actionlint": "node-actionlint",
|
||||
"prettier": "prettier",
|
||||
"cli": "./cli.sh",
|
||||
"oodikone": "./run.sh oodikone anon up --detach",
|
||||
@@ -41,30 +43,28 @@
|
||||
},
|
||||
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
|
||||
"devDependencies": {
|
||||
"@cypress/code-coverage": "^3.13.12",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@cypress/code-coverage": "^3.14.4",
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"cypress": "^14.1.0",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"eslint-import-resolver-typescript": "^3.8.3",
|
||||
"eslint-plugin-cypress": "^4.2.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"cypress": "^15.0.0",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-import-resolver-typescript": "^4.4.3",
|
||||
"eslint-plugin-cypress": "^5.1.1",
|
||||
"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.0.0",
|
||||
"globals": "^16.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.4.3",
|
||||
"node-actionlint": "^1.2.2",
|
||||
"lint-staged": "^16.1.0",
|
||||
"nyc": "^17.1.0",
|
||||
"prettier": "3.5.3",
|
||||
"stylelint": "^16.15.0",
|
||||
"stylelint-config-standard": "^37.0.0",
|
||||
"typescript-eslint": "^8.26.0"
|
||||
"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
|
||||
}
|
||||
@@ -242,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 "No username provided, config file is required. The \".s3cfg\" config file can be found on 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"
|
||||
}
|
||||
|
||||
@@ -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
+2415
-1464
File diff suppressed because it is too large
Load Diff
@@ -6,41 +6,41 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@oodikone/shared": "file:../shared",
|
||||
"@sentry/node": "^8.55.0",
|
||||
"@sentry/profiling-node": "^8.55.0",
|
||||
"axios": "^1.8.1",
|
||||
"bullmq": "^5.41.8",
|
||||
"@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": "^4.1.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",
|
||||
"lru-cache": "^11.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
"papaparse": "^5.5.2",
|
||||
"pg": "^8.13.3",
|
||||
"redis": "^4.7.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.16.0",
|
||||
"redis": "^4.7.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"sequelize": "^6.37.6",
|
||||
"sequelize": "^6.37.7",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"umzug": "^3.8.2",
|
||||
"unfuck-utf8-headers-middleware": "^1.0.1",
|
||||
"vite": "^7.1.12",
|
||||
"winston": "^3.17.0",
|
||||
"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.16",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/validator": "^13.12.2",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2"
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "^24.10.1",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"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,105 +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 CRON_SCHEDULE = process.env.CRON_SCHEDULE || '0 23 * * *' // Default to 23:00 daily
|
||||
|
||||
const redis = process.env.REDIS
|
||||
const DB_SCHEMA_KONE = process.env.DB_SCHEMA_KONE || 'public'
|
||||
|
||||
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,
|
||||
CRON_SCHEDULE,
|
||||
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,7 +2,7 @@ import EventEmitter from 'events'
|
||||
import { Sequelize } from 'sequelize-typescript'
|
||||
import { SequelizeStorage, Umzug } from 'umzug'
|
||||
|
||||
import conf from '../config'
|
||||
import * as conf from '../config'
|
||||
import {
|
||||
CourseModel,
|
||||
CourseProviderModel,
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +65,13 @@ 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)
|
||||
}
|
||||
|
||||
export const refreshProgrammes = async () => {
|
||||
logger.info('Refreshing study programme and study track overview statistics for all programmes')
|
||||
logger.info('Refreshing degree programme and study track overview statistics for all programmes')
|
||||
const facultyCodes = (await getFaculties()).map(faculty => faculty.code)
|
||||
const programmeCodes: string[] = []
|
||||
for (const faculty of facultyCodes) {
|
||||
@@ -106,14 +106,15 @@ const dailyJobs = async () => {
|
||||
export const startCron = () => {
|
||||
if (isProduction && !runningInCI) {
|
||||
logger.info(`Cronjob for refreshing stats started: runs at "${CRON_SCHEDULE}"`)
|
||||
schedule(CRON_SCHEDULE, async () => {
|
||||
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,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/consistent-return */
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
|
||||
import { Role } from '@oodikone/shared/types'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { intersection } from 'lodash'
|
||||
|
||||
import { configLogoutUrl, isDev, requiredGroup, serviceProvider } from '../config'
|
||||
import { getMockedUser, getMockedUserFd, getOrganizationAccess, getUserFd, getUserToska } from '../services/userService'
|
||||
import { FormattedUser, IamAccess } from '../types'
|
||||
import { FormattedUser } from '../types'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
import logger from '../util/logger'
|
||||
|
||||
@@ -14,30 +14,6 @@ const hasRequiredIamGroup = (iamGroups: string[], iamRights: string[]) => {
|
||||
return intersection(iamGroups, requiredGroup).length > 0 || iamRights.length > 0
|
||||
}
|
||||
|
||||
const getUser = async (
|
||||
showAsUser: string | undefined,
|
||||
username: string,
|
||||
name: string,
|
||||
email: string,
|
||||
iamGroups: string[],
|
||||
specialGroup: Record<string, boolean>,
|
||||
sisId: string,
|
||||
iamAccess: IamAccess
|
||||
) => {
|
||||
if (showAsUser && specialGroup?.superAdmin) {
|
||||
return await getMockedUser({ userToMock: showAsUser, mockedBy: username })
|
||||
}
|
||||
return await getUserToska({
|
||||
username,
|
||||
name,
|
||||
email,
|
||||
iamGroups,
|
||||
specialGroup,
|
||||
sisId,
|
||||
iamAccess,
|
||||
})
|
||||
}
|
||||
|
||||
type Headers = {
|
||||
displayname?: string
|
||||
hygroupcn?: string
|
||||
@@ -49,7 +25,7 @@ type Headers = {
|
||||
'x-show-as-user'?: string
|
||||
}
|
||||
|
||||
const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunction) => {
|
||||
const toskaUserMiddleware = async (req: Request, _: Response, next: NextFunction) => {
|
||||
const {
|
||||
'shib-session-id': sessionId,
|
||||
'x-show-as-user': showAsUser,
|
||||
@@ -61,19 +37,15 @@ const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunct
|
||||
uid: username,
|
||||
} = req.headers as Headers
|
||||
|
||||
const missingHeaders: string[] = []
|
||||
if (!sessionId) missingHeaders.push('shib-session-id')
|
||||
if (!username) missingHeaders.push('uid')
|
||||
|
||||
if (missingHeaders.length > 0) {
|
||||
if (!username || !sessionId)
|
||||
throw new ApplicationError(
|
||||
`Not enough data in request headers, the following headers were missing: ${missingHeaders.join(', ')}`,
|
||||
`${username ?? 'Anonymous user'} requested ${req.url} without authorization headers.`,
|
||||
403,
|
||||
{ logoutUrl }
|
||||
)
|
||||
}
|
||||
|
||||
const iamGroups = parseIamGroups(hygroupcn!)
|
||||
|
||||
const { iamAccess = {}, specialGroup = {} } = await getOrganizationAccess(sisId!, iamGroups)
|
||||
const iamRights = Object.keys(iamAccess)
|
||||
|
||||
@@ -82,15 +54,26 @@ const toskaUserMiddleware = async (req: Request, _res: Response, next: NextFunct
|
||||
message: 'User does not have required iam group',
|
||||
meta: { username, name, email, iamGroups, iamRights },
|
||||
})
|
||||
|
||||
throw new ApplicationError(`User '${username}' does not have required iam group`, 403, { logoutUrl })
|
||||
}
|
||||
|
||||
const user = await getUser(showAsUser, username!, name!, email!, iamGroups, specialGroup, sisId!, iamAccess)
|
||||
if (!user) {
|
||||
throw new ApplicationError(`Username ${username} not found.`, 403, { logoutUrl })
|
||||
}
|
||||
const user =
|
||||
showAsUser && specialGroup?.superAdmin
|
||||
? await getMockedUser({ userToMock: showAsUser, mockedBy: username })
|
||||
: await getUserToska({
|
||||
username,
|
||||
name: name!,
|
||||
email: email!,
|
||||
iamGroups,
|
||||
specialGroup,
|
||||
sisId: sisId!,
|
||||
iamAccess,
|
||||
})
|
||||
|
||||
Sentry.setUser({ username: user.mockedBy ?? username! })
|
||||
// Set username just-in-case there is error for invalid user
|
||||
Sentry.setUser({ username: user?.mockedBy ?? username })
|
||||
if (!user) throw new ApplicationError(`Username ${username} not found.`, 403, { logoutUrl })
|
||||
|
||||
req.user = user
|
||||
req.logoutUrl = logoutUrl!
|
||||
|
||||
@@ -11,16 +11,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type {
|
||||
Credit,
|
||||
Enrollment,
|
||||
Organization,
|
||||
SISStudyRight,
|
||||
SISStudyRightElement,
|
||||
Student,
|
||||
Studyplan,
|
||||
} from '@oodikone/shared/models'
|
||||
import { ExtentCode, SemesterEnrollment } from '@oodikone/shared/types'
|
||||
import type { SISStudyRight } from '@oodikone/shared/models'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { EnrollmentModel } from './enrollment'
|
||||
@@ -34,63 +25,69 @@ import { StudyrightExtentModel } from './studyrightExtent'
|
||||
underscored: true,
|
||||
tableName: 'sis_study_rights',
|
||||
})
|
||||
export class SISStudyRightModel extends Model<SISStudyRight> implements SISStudyRight {
|
||||
export class SISStudyRightModel extends Model implements SISStudyRight {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: SISStudyRight['id']
|
||||
|
||||
@ForeignKey(() => OrganizationModel)
|
||||
@Column(DataType.STRING)
|
||||
facultyCode!: string
|
||||
declare facultyCode: SISStudyRight['facultyCode']
|
||||
|
||||
@BelongsTo(() => OrganizationModel, { foreignKey: 'facultyCode', targetKey: 'code' })
|
||||
organization!: Organization
|
||||
declare organization: SISStudyRight['organization']
|
||||
|
||||
@HasMany(() => StudyplanModel)
|
||||
studyPlans!: Studyplan[]
|
||||
declare studyPlans: SISStudyRight['studyPlans']
|
||||
|
||||
@HasMany(() => SISStudyRightElementModel, { foreignKey: 'studyRightId' })
|
||||
studyRightElements!: SISStudyRightElement[]
|
||||
declare studyRightElements: SISStudyRight['studyRightElements']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
startDate!: Date
|
||||
declare startDate: SISStudyRight['startDate']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
endDate!: Date
|
||||
declare endDate: SISStudyRight['endDate']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
studyStartDate!: Date
|
||||
declare studyStartDate: SISStudyRight['studyStartDate']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
cancelled!: boolean
|
||||
declare cancelled: SISStudyRight['cancelled']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
studentNumber!: string
|
||||
declare studentNumber: SISStudyRight['studentNumber']
|
||||
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'studentNumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
declare student: SISStudyRight['student']
|
||||
|
||||
@ForeignKey(() => StudyrightExtentModel)
|
||||
@Column(DataType.INTEGER)
|
||||
extentCode!: ExtentCode
|
||||
declare extentCode: SISStudyRight['extentCode']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
admissionType!: string
|
||||
declare admissionType: SISStudyRight['admissionType']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
semesterEnrollments!: SemesterEnrollment[] | null
|
||||
declare semesterEnrollments: SISStudyRight['semesterEnrollments']
|
||||
|
||||
@HasMany(() => CreditModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
credits!: Credit[]
|
||||
declare credits: SISStudyRight['credits']
|
||||
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'studyright_id', sourceKey: 'id' })
|
||||
enrollments!: Enrollment[]
|
||||
declare enrollments: SISStudyRight['enrollments']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
declare tvex: SISStudyRight['tvex']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
declare expirationRuleUrns: SISStudyRight['expirationRuleUrns']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: SISStudyRight['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: SISStudyRight['updatedAt']
|
||||
}
|
||||
|
||||
@@ -10,57 +10,54 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { SISStudyRight, SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import type { Name, StudyTrack } from '@oodikone/shared/types'
|
||||
import { DegreeProgrammeType, Phase } from '@oodikone/shared/types'
|
||||
|
||||
import type { SISStudyRightElement } from '@oodikone/shared/models'
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
tableName: 'sis_study_right_elements',
|
||||
})
|
||||
export class SISStudyRightElementModel extends Model<SISStudyRightElement> implements SISStudyRightElement {
|
||||
export class SISStudyRightElementModel extends Model implements SISStudyRightElement {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: SISStudyRightElement['id']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
startDate!: Date
|
||||
declare startDate: SISStudyRightElement['startDate']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
endDate!: Date
|
||||
declare endDate: SISStudyRightElement['endDate']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
graduated!: boolean
|
||||
declare graduated: SISStudyRightElement['graduated']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
phase!: Phase
|
||||
declare phase: SISStudyRightElement['phase']
|
||||
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column(DataType.STRING)
|
||||
studyRightId!: string
|
||||
declare studyRightId: SISStudyRightElement['studyRightId']
|
||||
|
||||
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyRightId' })
|
||||
studyRight!: SISStudyRight
|
||||
declare studyRight: SISStudyRightElement['studyRight']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
declare code: SISStudyRightElement['code']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Name
|
||||
declare name: SISStudyRightElement['name']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
studyTrack!: StudyTrack | null
|
||||
declare studyTrack: SISStudyRightElement['studyTrack']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
degreeProgrammeType!: DegreeProgrammeType
|
||||
declare degreeProgrammeType: SISStudyRightElement['degreeProgrammeType']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: SISStudyRightElement['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: SISStudyRightElement['updatedAt']
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { Course, CourseType, Credit, Enrollment, Organization } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
import type { Course } from '@oodikone/shared/models'
|
||||
|
||||
import { CourseProviderModel } from './courseProvider'
|
||||
import { CourseTypeModel } from './courseType'
|
||||
@@ -26,57 +25,57 @@ import { OrganizationModel } from './organization'
|
||||
modelName: 'course',
|
||||
tableName: 'course',
|
||||
})
|
||||
export class CourseModel extends Model<Course> implements Course {
|
||||
export class CourseModel extends Model implements Course {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: Course['id']
|
||||
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
declare code: Course['code']
|
||||
|
||||
@ForeignKey(() => CourseTypeModel)
|
||||
@Column(DataType.STRING)
|
||||
coursetypecode!: string
|
||||
declare coursetypecode: Course['coursetypecode']
|
||||
|
||||
@BelongsTo(() => CourseTypeModel, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
|
||||
courseType!: CourseType
|
||||
declare courseType: Course['courseType']
|
||||
|
||||
@HasMany(() => CreditModel, { foreignKey: 'course_id', sourceKey: 'id' })
|
||||
credits!: Credit[]
|
||||
declare credits: Course['credits']
|
||||
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'course_id' })
|
||||
enrollments!: Enrollment[]
|
||||
declare enrollments: Course['enrollments']
|
||||
|
||||
@BelongsToMany(() => OrganizationModel, () => CourseProviderModel, 'coursecode')
|
||||
organizations!: Organization[]
|
||||
declare organizations: Course['organizations']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
is_study_module!: boolean
|
||||
declare is_study_module: Course['is_study_module']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Name
|
||||
declare name: Course['name']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
max_attainment_date!: Date
|
||||
declare max_attainment_date: Course['max_attainment_date']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
min_attainment_date!: Date
|
||||
declare min_attainment_date: Course['min_attainment_date']
|
||||
|
||||
@CreatedAt
|
||||
@Column({ field: 'created_at', type: DataType.DATE })
|
||||
createdAt!: Date
|
||||
declare createdAt: Course['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column({ field: 'updated_at', type: DataType.DATE })
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Course['updatedAt']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
substitutions!: string[]
|
||||
declare substitutions: Course['substitutions']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
course_unit_type!: string
|
||||
declare course_unit_type: Course['course_unit_type']
|
||||
|
||||
@Column({ field: 'main_course_code', type: DataType.STRING })
|
||||
mainCourseCode!: string
|
||||
declare mainCourseCode: Course['mainCourseCode']
|
||||
}
|
||||
|
||||
@@ -10,23 +10,23 @@ import { OrganizationModel } from './organization'
|
||||
modelName: 'course_provider',
|
||||
tableName: 'course_providers',
|
||||
})
|
||||
export class CourseProviderModel extends Model<CourseProvider> implements CourseProvider {
|
||||
export class CourseProviderModel extends Model implements CourseProvider {
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
coursecode!: string
|
||||
declare coursecode: CourseProvider['coursecode']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
shares!: Array<{ share: number; startDate?: string; endDate?: string }> | null
|
||||
declare shares: CourseProvider['shares']
|
||||
|
||||
@ForeignKey(() => OrganizationModel)
|
||||
@Column(DataType.STRING)
|
||||
organizationcode!: string
|
||||
declare organizationcode: CourseProvider['organizationcode']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: CourseProvider['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: CourseProvider['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { Course, CourseType } from '@oodikone/shared/models'
|
||||
import type { CourseType } from '@oodikone/shared/models'
|
||||
|
||||
import { Name } from '@oodikone/shared/types'
|
||||
import { CourseModel } from './course'
|
||||
|
||||
@Table({
|
||||
@@ -10,22 +9,22 @@ import { CourseModel } from './course'
|
||||
modelName: 'course_type',
|
||||
tableName: 'course_types',
|
||||
})
|
||||
export class CourseTypeModel extends Model<CourseType> implements CourseType {
|
||||
export class CourseTypeModel extends Model implements CourseType {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
coursetypecode!: string
|
||||
declare coursetypecode: CourseType['coursetypecode']
|
||||
|
||||
@HasMany(() => CourseModel)
|
||||
courses!: Course[]
|
||||
declare courses: CourseType['courses']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Required<Name>
|
||||
declare name: CourseType['name']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: CourseType['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: CourseType['updatedAt']
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { Credit, Course, CreditType, Semester, SISStudyRight, Student, Teacher } from '@oodikone/shared/models'
|
||||
import type { Credit } from '@oodikone/shared/models'
|
||||
import { CreditTypeCode } from '@oodikone/shared/types'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
@@ -27,85 +27,85 @@ import { TeacherModel } from './teacher'
|
||||
modelName: 'credit',
|
||||
tableName: 'credit',
|
||||
})
|
||||
export class CreditModel extends Model<Credit> implements Credit {
|
||||
export class CreditModel extends Model implements Credit {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: Credit['id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
grade!: string
|
||||
declare grade: Credit['grade']
|
||||
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
student_studentnumber!: string
|
||||
declare student_studentnumber: Credit['student_studentnumber']
|
||||
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'student_studentnumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
declare student: Credit['student']
|
||||
|
||||
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite' })
|
||||
semester!: Semester
|
||||
declare semester: Credit['semester']
|
||||
|
||||
@Column(DataType.DOUBLE)
|
||||
credits!: number
|
||||
declare credits: Credit['credits']
|
||||
|
||||
@ForeignKey(() => CreditTypeModel)
|
||||
@Column(DataType.INTEGER)
|
||||
credittypecode!: CreditTypeCode
|
||||
declare credittypecode: Credit['credittypecode']
|
||||
|
||||
@BelongsTo(() => CreditTypeModel, { foreignKey: 'credittypecode', targetKey: 'credittypecode' })
|
||||
creditType!: CreditType
|
||||
declare creditType: Credit['creditType']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
createdate!: Date
|
||||
declare createdate: Credit['createdate']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
attainment_date!: Date
|
||||
declare attainment_date: Credit['attainment_date']
|
||||
|
||||
@BelongsToMany(() => TeacherModel, () => CreditTeacherModel, 'credit_id')
|
||||
teachers!: Teacher[]
|
||||
declare teachers: Credit['teachers']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
course_code!: string
|
||||
declare course_code: Credit['course_code']
|
||||
|
||||
@BelongsTo(() => CourseModel, { foreignKey: 'course_id' })
|
||||
course!: Course
|
||||
declare course: Credit['course']
|
||||
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
course_id!: string
|
||||
declare course_id: Credit['course_id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
semester_composite!: string
|
||||
declare semester_composite: Credit['semester_composite']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
semestercode!: number
|
||||
declare semestercode: Credit['semestercode']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
isStudyModule!: boolean
|
||||
declare isStudyModule: Credit['isStudyModule']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
org!: string
|
||||
declare org: Credit['org']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
language!: string
|
||||
declare language: Credit['language']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
is_open!: boolean
|
||||
declare is_open: Credit['is_open']
|
||||
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column(DataType.STRING)
|
||||
studyright_id!: string
|
||||
declare studyright_id: Credit['studyright_id']
|
||||
|
||||
@BelongsTo(() => SISStudyRightModel)
|
||||
studyright!: SISStudyRight
|
||||
declare studyright: Credit['studyright']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Credit['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Credit['updatedAt']
|
||||
|
||||
static passed: ({ credittypecode }: { credittypecode: CreditTypeCode }) => boolean
|
||||
|
||||
|
||||
@@ -10,20 +10,20 @@ import { TeacherModel } from './teacher'
|
||||
modelName: 'credit_teacher',
|
||||
tableName: 'credit_teachers',
|
||||
})
|
||||
export class CreditTeacherModel extends Model<CreditTeacher> implements CreditTeacher {
|
||||
export class CreditTeacherModel extends Model implements CreditTeacher {
|
||||
@ForeignKey(() => CreditModel)
|
||||
@Column(DataType.STRING)
|
||||
credit_id!: string
|
||||
declare credit_id: CreditTeacher['credit_id']
|
||||
|
||||
@ForeignKey(() => TeacherModel)
|
||||
@Column(DataType.STRING)
|
||||
teacher_id!: string
|
||||
declare teacher_id: CreditTeacher['teacher_id']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: CreditTeacher['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: CreditTeacher['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { CreditType } from '@oodikone/shared/models'
|
||||
import { CreditTypeCode, Name } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
@@ -11,16 +10,16 @@ import { CreditTypeCode, Name } from '@oodikone/shared/types'
|
||||
export class CreditTypeModel extends Model<CreditType> implements CreditType {
|
||||
@PrimaryKey
|
||||
@Column(DataType.INTEGER)
|
||||
credittypecode!: CreditTypeCode
|
||||
declare credittypecode: CreditType['credittypecode']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Required<Name>
|
||||
declare name: CreditType['name']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: CreditType['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: CreditType['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
import { Column, CreatedAt, DataType, Model, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { CurriculumPeriod } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'curriculum_period',
|
||||
tableName: 'curriculum_periods',
|
||||
})
|
||||
export class CurriculumPeriodModel extends Model<CurriculumPeriod> implements CurriculumPeriod {
|
||||
export class CurriculumPeriodModel extends Model implements CurriculumPeriod {
|
||||
@Column({ type: DataType.STRING, primaryKey: true })
|
||||
id!: string
|
||||
declare id: CurriculumPeriod['id']
|
||||
|
||||
@Column({ type: DataType.JSONB, allowNull: false })
|
||||
name!: Name
|
||||
declare name: CurriculumPeriod['name']
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: false })
|
||||
universityOrgId!: string
|
||||
declare universityOrgId: CurriculumPeriod['universityOrgId']
|
||||
|
||||
@Column({ type: DataType.DATE, allowNull: false })
|
||||
startDate!: Date
|
||||
declare startDate: CurriculumPeriod['startDate']
|
||||
|
||||
@Column({ type: DataType.DATE, allowNull: false })
|
||||
endDate!: Date
|
||||
declare endDate: CurriculumPeriod['endDate']
|
||||
|
||||
@CreatedAt
|
||||
@Column({ type: DataType.DATE, allowNull: false })
|
||||
createdAt!: Date
|
||||
declare createdAt: CurriculumPeriod['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column({ type: DataType.DATE, allowNull: false })
|
||||
updatedAt!: Date
|
||||
declare updatedAt: CurriculumPeriod['updatedAt']
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { Enrollment, Semester, SISStudyRight, Student } from '@oodikone/shared/models'
|
||||
import { EnrollmentState } from '@oodikone/shared/types'
|
||||
import type { Enrollment } from '@oodikone/shared/models'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
import { SemesterModel } from './semester'
|
||||
@@ -23,59 +22,59 @@ import { StudentModel } from './student'
|
||||
modelName: 'enrollment',
|
||||
tableName: 'enrollment',
|
||||
})
|
||||
export class EnrollmentModel extends Model<Enrollment> implements Enrollment {
|
||||
export class EnrollmentModel extends Model implements Enrollment {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: Enrollment['id']
|
||||
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
declare studentnumber: Enrollment['studentnumber']
|
||||
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
declare student: Enrollment['student']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
course_code!: string
|
||||
declare course_code: Enrollment['course_code']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
state!: EnrollmentState
|
||||
declare state: Enrollment['state']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
enrollment_date_time!: Date
|
||||
declare enrollment_date_time: Enrollment['enrollment_date_time']
|
||||
|
||||
@ForeignKey(() => CourseModel)
|
||||
@Column(DataType.STRING)
|
||||
course_id!: string
|
||||
declare course_id: Enrollment['course_id']
|
||||
|
||||
@ForeignKey(() => SemesterModel)
|
||||
@Column(DataType.STRING)
|
||||
semester_composite!: string
|
||||
declare semester_composite: Enrollment['semester_composite']
|
||||
|
||||
@BelongsTo(() => SemesterModel, { foreignKey: 'semester_composite', targetKey: 'composite' })
|
||||
semester!: Semester
|
||||
declare semester: Enrollment['semester']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
semestercode!: number
|
||||
declare semestercode: Enrollment['semestercode']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
is_open!: boolean
|
||||
declare is_open: Enrollment['is_open']
|
||||
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column({ type: DataType.STRING, allowNull: true })
|
||||
studyright_id!: string
|
||||
declare studyright_id: Enrollment['studyright_id']
|
||||
|
||||
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'studyright_id', targetKey: 'id', constraints: false })
|
||||
studyright!: SISStudyRight
|
||||
declare studyright: Enrollment['studyright']
|
||||
|
||||
@BelongsTo(() => CourseModel, { foreignKey: 'course_id', targetKey: 'id' })
|
||||
course!: CourseModel
|
||||
declare course: Enrollment['course']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Enrollment['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Enrollment['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { CustomPopulationSearch, CustomPopulationSearchCreation } from '@oodikone/shared/models/kone'
|
||||
import type { CustomPopulationSearch } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: false,
|
||||
modelName: 'custom_population_search',
|
||||
tableName: 'custom_population_searches',
|
||||
})
|
||||
export class CustomPopulationSearchModel
|
||||
extends Model<CustomPopulationSearch, CustomPopulationSearchCreation>
|
||||
implements CustomPopulationSearch
|
||||
{
|
||||
export class CustomPopulationSearchModel extends Model implements CustomPopulationSearch {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
id!: CreationOptional<string>
|
||||
declare id: CustomPopulationSearch['id']
|
||||
|
||||
@Column(DataType.BIGINT)
|
||||
userId!: string
|
||||
declare userId: CustomPopulationSearch['userId']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
name!: string
|
||||
declare name: CustomPopulationSearch['name']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
students!: string[]
|
||||
declare students: CustomPopulationSearch['students']
|
||||
}
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { Column, DataType, Model, PrimaryKey, Table, AutoIncrement, Default } from 'sequelize-typescript'
|
||||
|
||||
import type { ExcludedCourse, ExcludedCourseCreation } from '@oodikone/shared/models/kone'
|
||||
import type { ExcludedCourse } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'excluded_course',
|
||||
tableName: 'excluded_courses',
|
||||
})
|
||||
export class ExcludedCourseModel extends Model<ExcludedCourse, ExcludedCourseCreation> implements ExcludedCourse {
|
||||
export class ExcludedCourseModel extends Model implements ExcludedCourse {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.INTEGER)
|
||||
id!: CreationOptional<number>
|
||||
declare id: ExcludedCourse['id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
programme_code!: string
|
||||
declare programme_code: ExcludedCourse['programme_code']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
course_code!: string
|
||||
declare course_code: ExcludedCourse['course_code']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
curriculum_version!: string
|
||||
declare curriculum_version: ExcludedCourse['curriculum_version']
|
||||
|
||||
@Default(DataType.NOW)
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: CreationOptional<Date>
|
||||
declare createdAt: ExcludedCourse['createdAt']
|
||||
|
||||
@Default(DataType.NOW)
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: CreationOptional<Date>
|
||||
declare updatedAt: ExcludedCourse['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { OpenUniPopulationSearch, OpenUniPopulationSearchCreation } from '@oodikone/shared/models/kone'
|
||||
import type { OpenUniPopulationSearch } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
@@ -9,21 +8,18 @@ import type { OpenUniPopulationSearch, OpenUniPopulationSearchCreation } from '@
|
||||
modelName: 'open_uni_population_search',
|
||||
tableName: 'open_uni_population_searches',
|
||||
})
|
||||
export class OpenUniPopulationSearchModel
|
||||
extends Model<OpenUniPopulationSearch, OpenUniPopulationSearchCreation>
|
||||
implements OpenUniPopulationSearch
|
||||
{
|
||||
export class OpenUniPopulationSearchModel extends Model implements OpenUniPopulationSearch {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
id!: CreationOptional<string>
|
||||
declare id: OpenUniPopulationSearch['id']
|
||||
|
||||
@Column(DataType.BIGINT)
|
||||
userId!: string
|
||||
declare userId: OpenUniPopulationSearch['userId']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
name!: string
|
||||
declare name: OpenUniPopulationSearch['name']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
courseCodes!: string[]
|
||||
declare courseCodes: OpenUniPopulationSearch['courseCodes']
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { ProgressCriteria, ProgressCriteriaCreation } from '@oodikone/shared/models/kone'
|
||||
import type { ProgressCriteria } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
@@ -9,50 +8,47 @@ import type { ProgressCriteria, ProgressCriteriaCreation } from '@oodikone/share
|
||||
modelName: 'progress_criteria',
|
||||
tableName: 'progress_criteria',
|
||||
})
|
||||
export class ProgressCriteriaModel
|
||||
extends Model<ProgressCriteria, ProgressCriteriaCreation>
|
||||
implements ProgressCriteria
|
||||
{
|
||||
export class ProgressCriteriaModel extends Model implements ProgressCriteria {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
declare code: ProgressCriteria['code']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
curriculumVersion!: CreationOptional<string>
|
||||
declare curriculumVersion: ProgressCriteria['curriculumVersion']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
coursesYearOne!: string[]
|
||||
declare coursesYearOne: ProgressCriteria['coursesYearOne']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
coursesYearTwo!: string[]
|
||||
declare coursesYearTwo: ProgressCriteria['coursesYearTwo']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
coursesYearThree!: string[]
|
||||
declare coursesYearThree: ProgressCriteria['coursesYearThree']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
coursesYearFour!: string[]
|
||||
declare coursesYearFour: ProgressCriteria['coursesYearFour']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
coursesYearFive!: string[]
|
||||
declare coursesYearFive: ProgressCriteria['coursesYearFive']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
coursesYearSix!: string[]
|
||||
declare coursesYearSix: ProgressCriteria['coursesYearSix']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditsYearOne!: number
|
||||
declare creditsYearOne: ProgressCriteria['creditsYearOne']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditsYearTwo!: number
|
||||
declare creditsYearTwo: ProgressCriteria['creditsYearTwo']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditsYearThree!: number
|
||||
declare creditsYearThree: ProgressCriteria['creditsYearThree']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditsYearFour!: number
|
||||
declare creditsYearFour: ProgressCriteria['creditsYearFour']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditsYearFive!: number
|
||||
declare creditsYearFive: ProgressCriteria['creditsYearFive']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditsYearSix!: number
|
||||
declare creditsYearSix: ProgressCriteria['creditsYearSix']
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
|
||||
|
||||
import type { StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation } from '@oodikone/shared/models/kone'
|
||||
import type { StudyGuidanceGroupTag } from '@oodikone/shared/models/kone'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
@@ -9,22 +8,19 @@ import type { StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation } from '@oodi
|
||||
modelName: 'study_guidance_group_tag',
|
||||
tableName: 'study_guidance_group_tags',
|
||||
})
|
||||
export class StudyGuidanceGroupTagModel
|
||||
extends Model<StudyGuidanceGroupTag, StudyGuidanceGroupTagCreation>
|
||||
implements StudyGuidanceGroupTag
|
||||
{
|
||||
export class StudyGuidanceGroupTagModel extends Model implements StudyGuidanceGroupTag {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
id?: CreationOptional<string>
|
||||
declare id: StudyGuidanceGroupTag['id']
|
||||
|
||||
@Unique
|
||||
@Column(DataType.STRING)
|
||||
studyGuidanceGroupId!: string
|
||||
declare studyGuidanceGroupId: StudyGuidanceGroupTag['studyGuidanceGroupId']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
studyProgramme!: string | null
|
||||
declare studyProgramme: StudyGuidanceGroupTag['studyProgramme']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
year!: string | null
|
||||
declare year: StudyGuidanceGroupTag['year']
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import type { StudyProgrammePin } from '@oodikone/shared/models/kone'
|
||||
modelName: 'study_programme_pin',
|
||||
tableName: 'study_programme_pins',
|
||||
})
|
||||
export class StudyProgrammePinModel extends Model<StudyProgrammePin> implements StudyProgrammePin {
|
||||
export class StudyProgrammePinModel extends Model implements StudyProgrammePin {
|
||||
@PrimaryKey
|
||||
@Column(DataType.INTEGER)
|
||||
userId!: number
|
||||
declare userId: StudyProgrammePin['userId']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
studyProgrammes!: string[]
|
||||
declare studyProgrammes: StudyProgrammePin['studyProgrammes']
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { Tag, TagCreation, TagStudent } from '@oodikone/shared/models/kone'
|
||||
import type { Tag } from '@oodikone/shared/models/kone'
|
||||
|
||||
import { TagStudentModel } from './tagStudent'
|
||||
|
||||
@@ -9,25 +8,25 @@ import { TagStudentModel } from './tagStudent'
|
||||
modelName: 'tag',
|
||||
tableName: 'tag',
|
||||
})
|
||||
export class TagModel extends Model<Tag, TagCreation> implements Tag {
|
||||
export class TagModel extends Model implements Tag {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@ForeignKey(() => TagStudentModel)
|
||||
@Column(DataType.BIGINT)
|
||||
tag_id!: CreationOptional<string>
|
||||
declare tag_id: Tag['tag_id']
|
||||
|
||||
@BelongsTo(() => TagStudentModel)
|
||||
tagStudent!: CreationOptional<TagStudent>
|
||||
declare tagStudent: Tag['tagStudent']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
tagname!: string
|
||||
declare tagname: Tag['tagname']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
studytrack!: string
|
||||
declare studytrack: Tag['studytrack']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
year!: string | null
|
||||
declare year: Tag['year']
|
||||
|
||||
@Column(DataType.BIGINT)
|
||||
personal_user_id!: string | null
|
||||
declare personal_user_id: Tag['personal_user_id']
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, BelongsTo, Column, DataType, ForeignKey, Model, PrimaryKey, Table } from 'sequelize-typescript'
|
||||
|
||||
import type { Tag, TagStudent, TagStudentCreation } from '@oodikone/shared/models/kone'
|
||||
import type { TagStudent } from '@oodikone/shared/models/kone'
|
||||
|
||||
import { StudentModel } from '../student'
|
||||
import { TagModel } from './tag'
|
||||
@@ -10,18 +9,18 @@ import { TagModel } from './tag'
|
||||
modelName: 'tag_student',
|
||||
tableName: 'tag_student',
|
||||
})
|
||||
export class TagStudentModel extends Model<TagStudent, TagStudentCreation> implements TagStudent {
|
||||
export class TagStudentModel extends Model implements TagStudent {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@ForeignKey(() => TagModel)
|
||||
@Column(DataType.BIGINT)
|
||||
tag_id!: string
|
||||
declare tag_id: TagStudent['tag_id']
|
||||
|
||||
@PrimaryKey
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
declare studentnumber: TagStudent['studentnumber']
|
||||
|
||||
@BelongsTo(() => TagModel)
|
||||
tag!: CreationOptional<Tag>
|
||||
declare tag: TagStudent['tag']
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { Organization, ProgrammeModule, SISStudyRight } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
import type { Organization } from '@oodikone/shared/models'
|
||||
|
||||
import { CourseModel } from './course'
|
||||
import { CourseProviderModel } from './courseProvider'
|
||||
@@ -23,37 +22,37 @@ import { SISStudyRightModel } from './SISStudyRight'
|
||||
modelName: 'organization',
|
||||
tableName: 'organization',
|
||||
})
|
||||
export class OrganizationModel extends Model<Organization> implements Organization {
|
||||
export class OrganizationModel extends Model implements Organization {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: Organization['id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
declare code: Organization['code']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Name
|
||||
declare name: Organization['name']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
parent_id!: string
|
||||
declare parent_id: Organization['parent_id']
|
||||
|
||||
@HasMany(() => OrganizationModel, { foreignKey: 'parent_id', as: 'children' })
|
||||
children!: Organization[]
|
||||
declare children: Organization['children']
|
||||
|
||||
@HasMany(() => ProgrammeModuleModel, { foreignKey: 'organization_id' })
|
||||
programmeModules!: ProgrammeModule[]
|
||||
declare programmeModules: Organization['programmeModules']
|
||||
|
||||
@HasMany(() => SISStudyRightModel, { foreignKey: 'facultyCode', sourceKey: 'code' })
|
||||
SISStudyRights!: SISStudyRight[]
|
||||
declare SISStudyRights: Organization['SISStudyRights']
|
||||
|
||||
@BelongsToMany(() => CourseModel, () => CourseProviderModel, 'organizationcode')
|
||||
courses!: CourseModel[]
|
||||
declare courses: Organization['courses']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Organization['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Organization['updatedAt']
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { Organization, ProgrammeModule } from '@oodikone/shared/models'
|
||||
import { DegreeProgrammeType, Name } from '@oodikone/shared/types'
|
||||
|
||||
import { OrganizationModel } from './organization'
|
||||
import { ProgrammeModuleChildModel } from './programmeModuleChild'
|
||||
@@ -22,62 +21,62 @@ import { ProgrammeModuleChildModel } from './programmeModuleChild'
|
||||
modelName: 'programme_module',
|
||||
tableName: 'programme_modules',
|
||||
})
|
||||
export class ProgrammeModuleModel extends Model<ProgrammeModule> implements ProgrammeModule {
|
||||
export class ProgrammeModuleModel extends Model implements ProgrammeModule {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: ProgrammeModule['id']
|
||||
|
||||
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'child_id')
|
||||
parents!: ProgrammeModule[]
|
||||
declare parents: ProgrammeModule[]
|
||||
|
||||
@BelongsToMany(() => ProgrammeModuleModel, () => ProgrammeModuleChildModel, 'parent_id')
|
||||
children!: ProgrammeModule[]
|
||||
declare children: ProgrammeModule[]
|
||||
|
||||
@BelongsTo(() => OrganizationModel, { foreignKey: 'organization_id' })
|
||||
declare organization: Organization
|
||||
|
||||
@Column(DataType.STRING)
|
||||
group_id!: string
|
||||
declare group_id: ProgrammeModule['group_id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
code!: string
|
||||
declare code: ProgrammeModule['code']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Name
|
||||
declare name: ProgrammeModule['name']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
type!: 'course' | 'module'
|
||||
declare type: ProgrammeModule['type']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
order!: number
|
||||
declare order: ProgrammeModule['order']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
studyLevel!: string
|
||||
declare studyLevel: ProgrammeModule['studyLevel']
|
||||
|
||||
@ForeignKey(() => OrganizationModel)
|
||||
@Column(DataType.STRING)
|
||||
organization_id!: string
|
||||
|
||||
@BelongsTo(() => OrganizationModel, { foreignKey: 'organization_id' })
|
||||
organization!: Organization
|
||||
declare organization_id: ProgrammeModule['organization_id']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
valid_from!: Date
|
||||
declare valid_from: ProgrammeModule['valid_from']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
valid_to!: Date
|
||||
declare valid_to: ProgrammeModule['valid_to']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
curriculum_period_ids!: string[]
|
||||
declare curriculum_period_ids: ProgrammeModule['curriculum_period_ids']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
degreeProgrammeType!: DegreeProgrammeType | null
|
||||
declare degreeProgrammeType: ProgrammeModule['degreeProgrammeType']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
minimumCredits!: number | null
|
||||
declare minimumCredits: ProgrammeModule['minimumCredits']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: ProgrammeModule['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: ProgrammeModule['updatedAt']
|
||||
}
|
||||
|
||||
@@ -9,23 +9,23 @@ import { ProgrammeModuleModel } from './programmeModule'
|
||||
modelName: 'programme_module_child',
|
||||
tableName: 'programme_module_children',
|
||||
})
|
||||
export class ProgrammeModuleChildModel extends Model<ProgrammeModuleChild> implements ProgrammeModuleChild {
|
||||
export class ProgrammeModuleChildModel extends Model implements ProgrammeModuleChild {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
composite!: string
|
||||
declare composite: ProgrammeModuleChild['composite']
|
||||
|
||||
@ForeignKey(() => ProgrammeModuleModel)
|
||||
@Column(DataType.STRING)
|
||||
parentId!: string
|
||||
declare parentId: ProgrammeModuleChild['parentId']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
childId!: string
|
||||
declare childId: ProgrammeModuleChild['childId']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: ProgrammeModuleChild['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: ProgrammeModuleChild['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,50 +1,49 @@
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { Semester } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'semester',
|
||||
tableName: 'semesters',
|
||||
})
|
||||
export class SemesterModel extends Model<Semester> implements Semester {
|
||||
export class SemesterModel extends Model implements Semester {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
composite!: string
|
||||
declare composite: Semester['composite']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
semestercode!: number
|
||||
declare semestercode: Semester['semestercode']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
name!: Name
|
||||
declare name: Semester['name']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
startYear!: number
|
||||
declare startYear: Semester['startYear']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
startdate!: Date
|
||||
declare startdate: Semester['startdate']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
enddate!: Date
|
||||
declare enddate: Semester['enddate']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
yearcode!: number
|
||||
declare yearcode: Semester['yearcode']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
yearname!: string
|
||||
declare yearname: Semester['yearname']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
org!: string
|
||||
declare org: Semester['org']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
termIndex!: number
|
||||
declare termIndex: Semester['termIndex']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Semester['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Semester['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Column, CreatedAt, DataType, HasMany, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { Student, Credit, Enrollment, SISStudyRight, Studyplan } from '@oodikone/shared/models'
|
||||
import type { Name } from '@oodikone/shared/types'
|
||||
import { GenderCode } from '@oodikone/shared/types'
|
||||
import type { Student } from '@oodikone/shared/models'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { EnrollmentModel } from './enrollment'
|
||||
@@ -14,73 +12,73 @@ import { StudyplanModel } from './studyplan'
|
||||
modelName: 'student',
|
||||
tableName: 'student',
|
||||
})
|
||||
export class StudentModel extends Model<Student> implements Student {
|
||||
export class StudentModel extends Model implements Student {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
declare studentnumber: Student['studentnumber']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
lastname!: string
|
||||
declare lastname: Student['lastname']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
firstnames!: string
|
||||
declare firstnames: Student['firstnames']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
abbreviatedname!: string
|
||||
declare abbreviatedname: Student['abbreviatedname']
|
||||
|
||||
@HasMany(() => EnrollmentModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
enrollments!: Enrollment[]
|
||||
declare enrollments: Student['enrollments']
|
||||
|
||||
@HasMany(() => SISStudyRightModel, { foreignKey: 'studentNumber', sourceKey: 'studentnumber' })
|
||||
studyRights!: SISStudyRight[]
|
||||
declare studyRights: Student['studyRights']
|
||||
|
||||
@HasMany(() => StudyplanModel, { foreignKey: 'studentnumber', sourceKey: 'studentnumber' })
|
||||
studyplans!: Studyplan[]
|
||||
declare studyplans: Student['studyplans']
|
||||
|
||||
@HasMany(() => CreditModel, { foreignKey: 'student_studentnumber', sourceKey: 'studentnumber' })
|
||||
credits!: Credit[]
|
||||
declare credits: Student['credits']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
birthdate!: Date
|
||||
declare birthdate: Student['birthdate']
|
||||
|
||||
@Column(DataType.INTEGER)
|
||||
creditcount!: number
|
||||
declare creditcount: Student['creditcount']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
dateofuniversityenrollment!: Date
|
||||
declare dateofuniversityenrollment: Student['dateofuniversityenrollment']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
email!: string
|
||||
declare email: Student['email']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
secondary_email!: string
|
||||
declare secondary_email: Student['secondary_email']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
national_student_number!: string
|
||||
declare national_student_number: Student['national_student_number']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
phone_number!: string
|
||||
declare phone_number: Student['phone_number']
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
citizenships!: Name[]
|
||||
declare citizenships: Student['citizenships']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
gender_code!: GenderCode
|
||||
declare gender_code: Student['gender_code']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
sis_person_id!: string
|
||||
declare sis_person_id: Student['sis_person_id']
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
hasPersonalIdentityCode!: boolean
|
||||
declare hasPersonalIdentityCode: Student['hasPersonalIdentityCode']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
preferredLanguage!: string
|
||||
declare preferredLanguage: Student['preferredLanguage']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Student['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Student['updatedAt']
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
UpdatedAt,
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import type { SISStudyRight, Student, Studyplan } from '@oodikone/shared/models'
|
||||
import type { Studyplan } from '@oodikone/shared/models'
|
||||
|
||||
import { SISStudyRightModel } from './SISStudyRight'
|
||||
import { StudentModel } from './student'
|
||||
@@ -20,48 +20,48 @@ import { StudentModel } from './student'
|
||||
modelName: 'studyplan',
|
||||
tableName: 'studyplan',
|
||||
})
|
||||
export class StudyplanModel extends Model<Studyplan> implements Studyplan {
|
||||
export class StudyplanModel extends Model implements Studyplan {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: Studyplan['id']
|
||||
|
||||
@ForeignKey(() => StudentModel)
|
||||
@Column(DataType.STRING)
|
||||
studentnumber!: string
|
||||
declare studentnumber: Studyplan['studentnumber']
|
||||
|
||||
@BelongsTo(() => StudentModel, { foreignKey: 'studentnumber', targetKey: 'studentnumber' })
|
||||
student!: Student
|
||||
declare student: Studyplan['student']
|
||||
|
||||
@ForeignKey(() => SISStudyRightModel)
|
||||
@Column(DataType.STRING)
|
||||
sis_study_right_id!: string
|
||||
declare sis_study_right_id: Studyplan['sis_study_right_id']
|
||||
|
||||
@BelongsTo(() => SISStudyRightModel, { foreignKey: 'sis_study_right_id', targetKey: 'id', as: 'studyRight' })
|
||||
studyRight!: SISStudyRight
|
||||
declare studyRight: Studyplan['studyRight']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
programme_code!: string
|
||||
declare programme_code: Studyplan['programme_code']
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
included_courses!: string[]
|
||||
declare included_courses: Studyplan['included_courses']
|
||||
|
||||
@Column({ type: DataType.ARRAY(DataType.TEXT), field: 'included_modules' })
|
||||
includedModules!: string[]
|
||||
declare includedModules: Studyplan['includedModules']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
sisu_id!: string
|
||||
declare sisu_id: Studyplan['sisu_id']
|
||||
|
||||
@Column(DataType.DOUBLE)
|
||||
completed_credits!: number
|
||||
declare completed_credits: Studyplan['completed_credits']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
curriculum_period_id!: string
|
||||
declare curriculum_period_id: Studyplan['curriculum_period_id']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Studyplan['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Studyplan['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { StudyrightExtent } from '@oodikone/shared/models'
|
||||
import { ExtentCode } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
modelName: 'studyright_extent',
|
||||
tableName: 'studyright_extents',
|
||||
})
|
||||
export class StudyrightExtentModel extends Model<StudyrightExtent> implements StudyrightExtent {
|
||||
export class StudyrightExtentModel extends Model implements StudyrightExtent {
|
||||
@PrimaryKey
|
||||
@Column(DataType.INTEGER)
|
||||
extentcode!: ExtentCode
|
||||
declare extentcode: StudyrightExtent['extentcode']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
name!: string
|
||||
declare name: StudyrightExtent['name']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: StudyrightExtent['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: StudyrightExtent['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BelongsToMany, Column, CreatedAt, DataType, Model, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
|
||||
import type { Teacher, Credit } from '@oodikone/shared/models'
|
||||
import type { Teacher } from '@oodikone/shared/models'
|
||||
|
||||
import { CreditModel } from './credit'
|
||||
import { CreditTeacherModel } from './creditTeacher'
|
||||
@@ -10,22 +10,22 @@ import { CreditTeacherModel } from './creditTeacher'
|
||||
modelName: 'teacher',
|
||||
tableName: 'teacher',
|
||||
})
|
||||
export class TeacherModel extends Model<Teacher> implements Teacher {
|
||||
export class TeacherModel extends Model implements Teacher {
|
||||
@PrimaryKey
|
||||
@Column(DataType.STRING)
|
||||
id!: string
|
||||
declare id: Teacher['id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
name!: string
|
||||
declare name: Teacher['name']
|
||||
|
||||
@BelongsToMany(() => CreditModel, () => CreditTeacherModel)
|
||||
credits!: Credit[]
|
||||
declare credits: Teacher['credits']
|
||||
|
||||
@CreatedAt
|
||||
@Column(DataType.DATE)
|
||||
createdAt!: Date
|
||||
declare createdAt: Teacher['createdAt']
|
||||
|
||||
@UpdatedAt
|
||||
@Column(DataType.DATE)
|
||||
updatedAt!: Date
|
||||
declare updatedAt: Teacher['updatedAt']
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { CreationOptional } from 'sequelize'
|
||||
import { AutoIncrement, Column, DataType, Default, Model, PrimaryKey, Table, Unique } from 'sequelize-typescript'
|
||||
|
||||
import type { Language } from '@oodikone/shared/language'
|
||||
import type { User, UserCreation } from '@oodikone/shared/models/user'
|
||||
|
||||
import type { Role } from '@oodikone/shared/types'
|
||||
|
||||
@Table({
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
@@ -16,33 +12,33 @@ export class UserModel extends Model<User, UserCreation> implements User {
|
||||
@PrimaryKey
|
||||
@AutoIncrement
|
||||
@Column(DataType.BIGINT)
|
||||
id!: CreationOptional<string>
|
||||
declare id: User['id']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
fullName!: string
|
||||
declare fullName: User['fullName']
|
||||
|
||||
@Unique
|
||||
@Column(DataType.STRING)
|
||||
username!: string
|
||||
declare username: User['username']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
email!: string
|
||||
declare email: User['email']
|
||||
|
||||
@Default('fi')
|
||||
@Column(DataType.STRING)
|
||||
language!: CreationOptional<Language>
|
||||
declare language: User['language']
|
||||
|
||||
@Column(DataType.STRING)
|
||||
sisuPersonId!: string
|
||||
declare sisuPersonId: User['sisuPersonId']
|
||||
|
||||
@Column(DataType.DATE)
|
||||
lastLogin!: Date
|
||||
declare lastLogin: User['lastLogin']
|
||||
|
||||
@Default([])
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
roles!: CreationOptional<Role[]>
|
||||
declare roles: User['roles']
|
||||
|
||||
@Default([])
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
programmeRights!: CreationOptional<string[]>
|
||||
declare programmeRights: User['programmeRights']
|
||||
}
|
||||
|
||||
@@ -1,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)
|
||||
|
||||
@@ -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,101 +1,103 @@
|
||||
import crypto from 'crypto'
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import {
|
||||
CoursesMultiResBody,
|
||||
CoursesMultiReqBody,
|
||||
CoursesMultiQuery,
|
||||
CourseYearlyStatsReqBody,
|
||||
CourseYearlyStatsQuery,
|
||||
} from '@oodikone/shared/routes/courses'
|
||||
import type { CourseWithSubsId } from '@oodikone/shared/types/course'
|
||||
import { uniq } from '@oodikone/shared/util'
|
||||
import { getCourseYearlyStats } from '../services/courses'
|
||||
import { getCoursesByNameAndOrCode, getCoursesByCodes } from '../services/courses/courseFinders'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, validateParamLength } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
interface GetCoursesRequest extends Request {
|
||||
query: {
|
||||
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, includeSpecial } = 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 = await getCoursesByNameAndOrCode(name, code, includeSpecial === 'true')
|
||||
|
||||
for (const course of courses) {
|
||||
const groupId = combineSubstitutions === 'true' ? course.subsId! : course.code
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) {
|
||||
continue
|
||||
}
|
||||
if (!mergedCourses[groupId]) {
|
||||
mergedCourses[groupId] = {
|
||||
...course,
|
||||
substitutions: combineSubstitutions === 'true' ? course.substitutions : [],
|
||||
}
|
||||
} else {
|
||||
const mergedCourse = mergedCourses[groupId]
|
||||
if (mergedCourse.max_attainment_date < course.max_attainment_date) {
|
||||
mergedCourse.max_attainment_date = course.max_attainment_date
|
||||
}
|
||||
if (mergedCourse.min_attainment_date > course.min_attainment_date) {
|
||||
mergedCourse.min_attainment_date = course.min_attainment_date
|
||||
if (combineSubstitutions === 'false') {
|
||||
const courseCodes = courses.map(course => course.code)
|
||||
const substitutions = uniq(
|
||||
courses.flatMap(course => course.substitutions).filter(code => !courseCodes.includes(code))
|
||||
)
|
||||
const substitutionDetails = await getCoursesByCodes(substitutions)
|
||||
for (const substitution of substitutionDetails) {
|
||||
courses.push(substitution.toJSON())
|
||||
}
|
||||
}
|
||||
|
||||
const mergedCourses: Record<string, CourseWithSubsId> = {}
|
||||
|
||||
for (const course of courses) {
|
||||
if (!(course.max_attainment_date && course.min_attainment_date)) continue
|
||||
|
||||
const groupId = combineSubstitutions === 'true' ? course.subsId?.toString() : course.code
|
||||
if (!groupId) 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) })
|
||||
}
|
||||
)
|
||||
|
||||
res.json({ courses: Object.values(mergedCourses) })
|
||||
})
|
||||
type CourseYearlyStatsResBody = Awaited<ReturnType<typeof getCourseYearlyStats>>
|
||||
|
||||
interface GetCourseYearlyStatsRequest extends Request {
|
||||
query: {
|
||||
codes: string[]
|
||||
separate: string
|
||||
combineSubstitutions: string
|
||||
router.get<never, CanError<CourseYearlyStatsResBody>, CourseYearlyStatsReqBody, CourseYearlyStatsQuery>(
|
||||
'/v3/courseyearlystats',
|
||||
async (req, res) => {
|
||||
const { codes, combineSubstitutions, separate } = req.query
|
||||
|
||||
if (!codes) {
|
||||
return res.status(422).send({ error: 'Missing required query parameters' })
|
||||
}
|
||||
|
||||
const { roles, programmeRights } = req.user
|
||||
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
|
||||
const userHasAccessToCourseStats = userHasFullAccessToStudentData || roles.includes('courseStatistics')
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
// If user has rights to see at least one programme, then they are allowed to see all of them
|
||||
if (!userHasAccessToCourseStats && !fullStudyProgrammeRights.length) {
|
||||
return res.status(403).json({ error: 'No valid rights provided' })
|
||||
}
|
||||
|
||||
// Student numbers should be obfuscated to all other users except admins,
|
||||
// fullSisuAccess users, and users with rights to any specific degree programmes
|
||||
const anonymize = !userHasFullAccessToStudentData && fullStudyProgrammeRights.length === 0
|
||||
const anonymizationSalt = anonymize ? crypto.randomBytes(12).toString('hex') : null
|
||||
|
||||
const useCombineSubstitutions = combineSubstitutions !== 'false'
|
||||
const useSeparate = separate === 'true'
|
||||
|
||||
const results = await getCourseYearlyStats(codes, useSeparate, anonymizationSalt, useCombineSubstitutions)
|
||||
res.json(results)
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/v3/courseyearlystats', async (req: GetCourseYearlyStatsRequest, res: Response) => {
|
||||
const { roles, programmeRights } = req.user
|
||||
|
||||
const userHasFullAccessToStudentData = hasFullAccessToStudentData(roles)
|
||||
const userHasCorrectRole = userHasFullAccessToStudentData === true || roles.includes('courseStatistics')
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
|
||||
// If user has rights to see at least one programme, then they are allowed to see all of them
|
||||
if (!userHasCorrectRole && fullStudyProgrammeRights.length === 0) {
|
||||
return res.status(403).json({ error: 'No programmes so no access to course stats' })
|
||||
}
|
||||
|
||||
const { codes } = req.query
|
||||
if (!codes) {
|
||||
return res.status(422).send('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 '@oodikone/shared/models'
|
||||
import { CurriculumPeriodModel } from '../models'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const result = await CurriculumPeriodModel.findAll()
|
||||
type CurriculumPeriodsResBody = CurriculumPeriod[]
|
||||
|
||||
router.get<never, CurriculumPeriodsResBody>('/', async (_req, res) => {
|
||||
const result: CurriculumPeriod[] = await CurriculumPeriodModel.findAll({
|
||||
raw: true,
|
||||
})
|
||||
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ type SearchQuery = {
|
||||
router.get<never, SearchResBody, SearchReqBody, SearchQuery>('/', async (req, res) => {
|
||||
const { courselist, startdate, enddate } = req.query
|
||||
|
||||
const courseCodes = (await safeJSONParse(courselist)) || []
|
||||
const courseCodes = (await safeJSONParse(courselist)) ?? []
|
||||
if (!Array.isArray(courseCodes)) {
|
||||
return res.status(400).json({ error: 'Courses must be of type array' })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@ import { combineFacultyThesisWriters } from '../services/faculty/facultyThesisWr
|
||||
import { updateFacultyOverview, updateFacultyProgressOverview } from '../services/faculty/facultyUpdates'
|
||||
import logger from '../util/logger'
|
||||
|
||||
// Faculty uses a lot of tools designed for Study programme.
|
||||
// Faculty uses a lot of tools designed for Degree programme.
|
||||
// Some of them have been copied here and slightly edited for faculty purpose.
|
||||
|
||||
const router = Router()
|
||||
@@ -56,7 +56,7 @@ router.get('/:id/basicstats', auth.roles(['facultyStatistics']), async (req: Get
|
||||
return res.json(data)
|
||||
}
|
||||
|
||||
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
|
||||
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
|
||||
if (!programmes.length) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
@@ -96,7 +96,7 @@ router.get('/:id/thesisstats', auth.roles(['facultyStatistics']), async (req: Ge
|
||||
return res.json(data)
|
||||
}
|
||||
|
||||
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
|
||||
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
|
||||
if (!programmes.length) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
@@ -130,7 +130,7 @@ router.get(
|
||||
return res.json(data)
|
||||
}
|
||||
|
||||
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_STUDY_PROGRAMMES')
|
||||
const programmes = await getDegreeProgrammesOfFaculty(code, programmeFilter === 'NEW_DEGREE_PROGRAMMES')
|
||||
if (!programmes) {
|
||||
return res.status(422).end()
|
||||
}
|
||||
@@ -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,19 +1,27 @@
|
||||
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) {
|
||||
throw new ApplicationError('User not found', 404)
|
||||
throw new ApplicationError('User not found', 401)
|
||||
}
|
||||
|
||||
res.send({
|
||||
user: omit(user, ['studentsUserCanAccess']),
|
||||
user: omitKeys(user, ['studentsUserCanAccess']),
|
||||
logoutUrl,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,100 +1,111 @@
|
||||
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 { CanError } from '@oodikone/shared/routes'
|
||||
import { GenderCode } from '@oodikone/shared/types'
|
||||
import type { CanError } from '@oodikone/shared/routes'
|
||||
import {
|
||||
type PopulationstatisticsQuery,
|
||||
type PopulationstatisticsReqBody,
|
||||
type PopulationstatisticsResBody,
|
||||
type PopulationstatisticsbycourseResBody,
|
||||
type PopulationstatisticsbycourseReqBody,
|
||||
type PopulationstatisticsbycourseParams,
|
||||
type GetCustomPopulationResBody,
|
||||
type CustomPopulationQuery,
|
||||
type PopulationstatisticsStudyprogrammesResBody,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormQuery,
|
||||
PopulationstatisticsMaxYearsToCreatePopulationFormResBody,
|
||||
} from '@oodikone/shared/routes/populations'
|
||||
import { Unification, Unarray } from '@oodikone/shared/types'
|
||||
import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { rootOrgId } from '../config'
|
||||
import { SISStudyRightModel } from '../models'
|
||||
import { maxYearsToCreatePopulationFrom, getCourseProvidersForCourses } from '../services/courses'
|
||||
import { encrypt, type EncrypterData } from '../services/encrypt'
|
||||
import { getCourseProvidersForCourses, maxYearsToCreatePopulationFrom } from '../services/courses'
|
||||
import { encrypt } from '../services/encrypt'
|
||||
import { getDegreeProgrammesOfOrganization, ProgrammesOfOrganization } from '../services/faculty/faculty'
|
||||
import { Bottlenecks, bottlenecksOf } from '../services/populations/bottlenecksOf'
|
||||
import { optimizedStatisticsOf } from '../services/populations/optimizedStatisticsOf'
|
||||
import { getStudentTagMap } from '../services/populations/getStudentData'
|
||||
import { parseDateRangeFromParams } from '../services/populations/shared'
|
||||
import { statisticsOf } from '../services/populations/statisticsOf'
|
||||
import { getStudentNumbersWithStudyRights } from '../services/populations/studentNumbersWithStudyRights'
|
||||
import { findByCourseAndSemesters } from '../services/students'
|
||||
import { ParsedCourse, Unarray, Unification, UnifyStatus } from '../types'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData, safeJSONParse } from '../util'
|
||||
import { getFullStudyProgrammeRights, hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
type EncryptedStudent = EncrypterData
|
||||
const isEncryptedStudent = (student?: string | EncryptedStudent) => {
|
||||
return (student as EncryptedStudent)?.encryptedData !== undefined
|
||||
}
|
||||
const obfuscateStuff = ({
|
||||
result,
|
||||
|
||||
export type PopulationstatisticsCoursesResBody = CanError<Bottlenecks>
|
||||
export type PopulationstatisticsCoursesReqBody = {
|
||||
// NOTE: Encrypted students have their iv in selectedStudents
|
||||
selectedStudents: string[] | EncryptedStudent[]
|
||||
selectedStudentsByYear?: { [year: string]: string[] }
|
||||
courses?: string[]
|
||||
}
|
||||
programme,
|
||||
combinedProgramme,
|
||||
|
||||
// NOTE: POST instead of GET because of too long params and "sensitive" data
|
||||
router.post<never, PopulationstatisticsCoursesResBody, PopulationstatisticsCoursesReqBody>(
|
||||
'/v4/populationstatistics/courses',
|
||||
async (req, res) => {
|
||||
const { roles, studentsUserCanAccess } = req.user
|
||||
const { selectedStudents, courses: selectedCourses = [], selectedStudentsByYear = {} } = req.body
|
||||
userRoles,
|
||||
userProgrammeRights,
|
||||
studentsUserCanAccess,
|
||||
}: {
|
||||
result: Awaited<ReturnType<typeof statisticsOf>>
|
||||
[key: string]: any
|
||||
}): Awaited<ReturnType<typeof statisticsOf>> => {
|
||||
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
|
||||
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
|
||||
const hasFullRightsToProgramme = userFullProgrammeRights.includes(programme)
|
||||
const hasFullRightsToCombinedProgramme = userFullProgrammeRights.includes(combinedProgramme)
|
||||
|
||||
const hasFullAccess = hasFullAccessToStudentData(roles)
|
||||
|
||||
const isEncrypted = selectedStudents.some(isEncryptedStudent)
|
||||
const hasAccessToStudents =
|
||||
isEncrypted || (selectedStudents as string[]).every(student => studentsUserCanAccess.includes(student))
|
||||
|
||||
if (!hasAccessToStudents && !hasFullAccess) {
|
||||
return res.status(403).json({ error: 'Trying to request unauthorized students data' })
|
||||
}
|
||||
|
||||
if (isEncrypted && !selectedStudents.every(isEncryptedStudent)) {
|
||||
Sentry.captureException(new Error('Trying to request unencrypted student data as encrypted'))
|
||||
return res.status(403).json({ error: 'Trying to request unauthorized student data' })
|
||||
}
|
||||
|
||||
const requiredFields = [selectedStudents, selectedCourses]
|
||||
if (requiredFields.some(field => !field)) {
|
||||
Sentry.captureException(new Error('The request body should countain: selected students and courses'))
|
||||
return res.status(400).json({ error: 'The request body should countain: selected students and courses' })
|
||||
}
|
||||
|
||||
const result = await bottlenecksOf(
|
||||
selectedStudents as string[],
|
||||
selectedStudentsByYear,
|
||||
selectedCourses,
|
||||
isEncrypted
|
||||
)
|
||||
|
||||
return res.json(result)
|
||||
if (hasFullAccessToStudents || hasFullRightsToProgramme || hasFullRightsToCombinedProgramme) {
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
export type PopulationstatisticsResBody = CanError<{ students: any }>
|
||||
export type PopulationstatisticsReqBody = never
|
||||
export type PopulationstatisticsQuery = {
|
||||
semesters: string[]
|
||||
studentStatuses?: string[]
|
||||
// NOTE: This param is a JSON -object
|
||||
studyRights: string
|
||||
year: string
|
||||
months?: string
|
||||
years?: string[]
|
||||
const { criteria, students, coursestatistics } = result
|
||||
const encryptionMap = new Map<string, string>()
|
||||
|
||||
return {
|
||||
criteria,
|
||||
students: students.map(student => {
|
||||
if (studentsUserCanAccess.has(student.studentNumber)) return student
|
||||
|
||||
// correct year for age distribution calculation but the date is always January 1st
|
||||
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0))
|
||||
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
|
||||
|
||||
encryptionMap.set(student.studentNumber, studentNumber)
|
||||
|
||||
return {
|
||||
...student,
|
||||
|
||||
obfuscated: true,
|
||||
iv,
|
||||
studentNumber,
|
||||
firstnames: '',
|
||||
lastname: '',
|
||||
phoneNumber: '',
|
||||
name: '',
|
||||
email: '',
|
||||
secondaryEmail: '',
|
||||
sis_person_id: '',
|
||||
tags: [],
|
||||
birthdate: obfuscatedBirthDate,
|
||||
}
|
||||
}),
|
||||
coursestatistics: {
|
||||
courses: coursestatistics.courses,
|
||||
enrollments: coursestatistics.enrollments.map(enrollment => ({
|
||||
...enrollment,
|
||||
studentnumber: encryptionMap.get(enrollment.studentnumber) ?? enrollment.studentnumber,
|
||||
})),
|
||||
credits: coursestatistics.credits.map(credit => ({
|
||||
...credit,
|
||||
student_studentnumber: encryptionMap.get(credit.student_studentnumber) ?? credit.student_studentnumber,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
router.get<never, PopulationstatisticsResBody, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
|
||||
router.get<never, CanError<PopulationstatisticsResBody>, PopulationstatisticsReqBody, PopulationstatisticsQuery>(
|
||||
'/v3/populationstatistics',
|
||||
async (req, res) => {
|
||||
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights } = req.user
|
||||
const { year, semesters, studyRights: requestedStudyRightsJSON, studentStatuses } = req.query
|
||||
const { id: userId, roles: userRoles, programmeRights: userProgrammeRights, studentsUserCanAccess } = req.user
|
||||
const { years, semesters, programme, combinedProgramme, studentStatuses } = req.query
|
||||
|
||||
const months = req.query.months ?? '12'
|
||||
|
||||
// NOTE: `year` isn't needed anymore if `years` is defined
|
||||
const requiredFields = [year, semesters, requestedStudyRightsJSON]
|
||||
const requiredFields = [years, semesters, programme]
|
||||
if (requiredFields.some(field => !field)) {
|
||||
return res.status(400).json({ error: 'The query should have a year, semester and studyRights defined' })
|
||||
return res.status(400).json({ error: 'The query should have years, semesters and a programme defined' })
|
||||
}
|
||||
|
||||
if (!semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
|
||||
@@ -107,268 +118,166 @@ router.get<never, PopulationstatisticsResBody, PopulationstatisticsReqBody, Popu
|
||||
return res.status(400).json({ error: 'Student status should be either EXCHANGE or NONDEGREE or TRANSFERRED' })
|
||||
}
|
||||
|
||||
const requestedStudyRights: { programme: string; combinedProgramme: string } | null =
|
||||
await safeJSONParse(requestedStudyRightsJSON)
|
||||
if (requestedStudyRights === null) {
|
||||
return res.status(400).json({ error: 'Invalid studyrights value!' })
|
||||
}
|
||||
|
||||
const userFullProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
|
||||
const userProgrammeRightsCodes = userProgrammeRights.map(({ code }) => code)
|
||||
|
||||
const hasFullAccessToStudents = hasFullAccessToStudentData(userRoles)
|
||||
const hasAccessToProgramme = userProgrammeRightsCodes.includes(requestedStudyRights.programme)
|
||||
const hasAccessToCombinedProgramme = userProgrammeRightsCodes.includes(requestedStudyRights.combinedProgramme)
|
||||
|
||||
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' })
|
||||
}
|
||||
|
||||
let result
|
||||
if (req.query.years) {
|
||||
const upperYearBound = new Date().getFullYear() + 1
|
||||
const multiYearStudents = Promise.all(
|
||||
req.query.years.map(year => {
|
||||
const yearAsNumber = +year
|
||||
const monthsForCurrentYear = String((upperYearBound - yearAsNumber) * 12)
|
||||
return optimizedStatisticsOf({
|
||||
...req.query,
|
||||
userId,
|
||||
studyRights: requestedStudyRights.programme,
|
||||
year,
|
||||
months: monthsForCurrentYear,
|
||||
})
|
||||
})
|
||||
)
|
||||
const { startDate, endDate } = parseDateRangeFromParams({
|
||||
...req.query,
|
||||
years: req.query.years,
|
||||
})
|
||||
|
||||
const populationStudentsMerger = (multiYearStudents: any) => {
|
||||
const samples = { students: [], courses: [] as any[] }
|
||||
const uniqueCourseCodes = new Set<string>()
|
||||
const studentNumbers = await getStudentNumbersWithStudyRights({
|
||||
programmeCodes: [programme],
|
||||
startDate,
|
||||
endDate,
|
||||
studentStatuses,
|
||||
})
|
||||
|
||||
for (const year of multiYearStudents) {
|
||||
samples.students = samples.students.concat(year.students)
|
||||
for (const course of year.courses) {
|
||||
if (!uniqueCourseCodes.has(course.code)) {
|
||||
uniqueCourseCodes.add(course.code)
|
||||
samples.courses.push(course)
|
||||
}
|
||||
}
|
||||
}
|
||||
const studyRights = [programme]
|
||||
const tagMap = await getStudentTagMap(studyRights, studentNumbers, userId)
|
||||
|
||||
return samples
|
||||
}
|
||||
const result = await statisticsOf(studentNumbers, studyRights, tagMap, startDate)
|
||||
const processed = obfuscateStuff({
|
||||
result,
|
||||
|
||||
result = populationStudentsMerger(await multiYearStudents)
|
||||
} else {
|
||||
result = await optimizedStatisticsOf({
|
||||
...req.query,
|
||||
userId,
|
||||
studyRights: requestedStudyRights.programme,
|
||||
year,
|
||||
months,
|
||||
})
|
||||
}
|
||||
programme,
|
||||
combinedProgramme,
|
||||
|
||||
// Obfuscate if user has only limited study programme rights and there are any students
|
||||
if (
|
||||
'students' in result &&
|
||||
!hasFullAccessToStudentData(userRoles) &&
|
||||
!userFullProgrammeRights.includes(requestedStudyRights.programme) &&
|
||||
!userFullProgrammeRights.includes(requestedStudyRights.combinedProgramme)
|
||||
) {
|
||||
result.students = result.students.map(student => {
|
||||
const { iv, encryptedData: studentNumber } = encrypt(student.studentNumber)
|
||||
// correct year for age distribution calculation but the date is always January 1st
|
||||
const obfuscatedBirthDate = new Date(Date.UTC(new Date(student.birthdate).getUTCFullYear(), 0))
|
||||
return {
|
||||
...student,
|
||||
firstnames: '',
|
||||
lastname: '',
|
||||
phoneNumber: '',
|
||||
iv,
|
||||
studentNumber,
|
||||
name: '',
|
||||
email: '',
|
||||
secondaryEmail: '',
|
||||
sis_person_id: '',
|
||||
tags: [],
|
||||
birthdate: obfuscatedBirthDate,
|
||||
obfuscated: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
userRoles,
|
||||
userProgrammeRights,
|
||||
studentsUserCanAccess: new Set(studentsUserCanAccess),
|
||||
})
|
||||
|
||||
return res.json(result)
|
||||
return res.json(processed)
|
||||
}
|
||||
)
|
||||
|
||||
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: userRoles,
|
||||
programmeRights: userProgrammeRights,
|
||||
studentsUserCanAccess: allStudentsUserCanAccess,
|
||||
} = 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: userId, 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.
|
||||
userId,
|
||||
year: '1900',
|
||||
studyRights: undefined,
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
},
|
||||
studentNumbers
|
||||
)
|
||||
let courseProviders: string[] = []
|
||||
if (!hasFullAccessToStudentData(roles)) {
|
||||
courseProviders = await getCourseProvidersForCourses(JSON.parse(coursecodes))
|
||||
}
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(programmeRights)
|
||||
const studentNumbers = await findByCourseAndSemesters(coursecodes, Number(from), Number(to), isSeparate, unifyCourses)
|
||||
|
||||
const courseProviders: string[] = await getCourseProvidersForCourses(coursecodes)
|
||||
const fullStudyProgrammeRights = getFullStudyProgrammeRights(userProgrammeRights)
|
||||
const rightsMappedToProviders = mapToProviders(fullStudyProgrammeRights)
|
||||
const found = courseProviders.some(provider => rightsMappedToProviders.includes(provider))
|
||||
|
||||
const studentsUserCanAccess =
|
||||
hasFullAccessToStudentData(roles) || found
|
||||
? new Set(studentNumbers)
|
||||
: new Set(intersection(studentNumbers, allStudentsUserCanAccess))
|
||||
const studentsUserCanAccess = courseProviders.some(provider => rightsMappedToProviders.includes(provider))
|
||||
? new Set(studentNumbers)
|
||||
: new Set(allStudentsUserCanAccess)
|
||||
|
||||
const randomHash = crypto.randomBytes(12).toString('hex')
|
||||
const obfuscateStudent = ({
|
||||
studyRights,
|
||||
studentNumber,
|
||||
courses,
|
||||
gender_code,
|
||||
}: {
|
||||
studyRights: SISStudyRightModel[]
|
||||
studentNumber: string
|
||||
courses: ParsedCourse[]
|
||||
gender_code: GenderCode
|
||||
}) => ({
|
||||
courses,
|
||||
studyRights,
|
||||
gender_code,
|
||||
studentNumber: crypto.createHash('md5').update(`${studentNumber}${randomHash}`).digest('hex'),
|
||||
firstnames: '',
|
||||
lastname: '',
|
||||
phone_number: '',
|
||||
name: '',
|
||||
email: '',
|
||||
started: null,
|
||||
credits: '',
|
||||
tags: [],
|
||||
obfuscated: true,
|
||||
const studyRights = []
|
||||
const tagMap = await getStudentTagMap(studyRights, studentNumbers, userId)
|
||||
const result = await statisticsOf(studentNumbers, studyRights, tagMap)
|
||||
const processed = obfuscateStuff({
|
||||
result,
|
||||
|
||||
userRoles,
|
||||
userProgrammeRights,
|
||||
studentsUserCanAccess,
|
||||
})
|
||||
|
||||
if ('students' in result) {
|
||||
result.students = result.students.map(student =>
|
||||
studentsUserCanAccess.has(student.studentNumber) ? student : obfuscateStudent(student)
|
||||
)
|
||||
}
|
||||
|
||||
res.json(result)
|
||||
return res.json(processed)
|
||||
})
|
||||
|
||||
interface PostByStudentNumbersRequest extends Request {
|
||||
body: {
|
||||
studentnumberlist: string[]
|
||||
tags?: {
|
||||
studyProgramme: string | null
|
||||
year: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
// Used in custom population and single study guidance groups
|
||||
router.post<never, GetCustomPopulationResBody, CustomPopulationQuery>(
|
||||
'/v3/populationstatisticsbystudentnumbers',
|
||||
async (req, res) => {
|
||||
const { studentNumbers, tags } = req.body
|
||||
const { id: userId, roles, studentsUserCanAccess } = req.user
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentNumbers
|
||||
: intersection(studentNumbers, studentsUserCanAccess)
|
||||
|
||||
router.post('/v3/populationstatisticsbystudentnumbers', async (req: PostByStudentNumbersRequest, res: Response) => {
|
||||
const { studentnumberlist, tags } = req.body
|
||||
const { id: userId, roles, studentsUserCanAccess } = req.user
|
||||
const filteredStudentNumbers = hasFullAccessToStudentData(roles)
|
||||
? studentnumberlist
|
||||
: intersection(studentnumberlist, studentsUserCanAccess)
|
||||
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
|
||||
|
||||
const studyProgrammeCode = tags?.studyProgramme?.split('+')[0]
|
||||
const studyRights = [studyProgrammeCode].filter(value => value !== undefined)
|
||||
const tagMap = await getStudentTagMap(studyRights, filteredStudentNumbers, userId)
|
||||
|
||||
const result = await optimizedStatisticsOf(
|
||||
{
|
||||
userId,
|
||||
year: tags?.year ?? '1900',
|
||||
studyRights: studyProgrammeCode,
|
||||
semesters: ['FALL', 'SPRING'],
|
||||
},
|
||||
filteredStudentNumbers
|
||||
)
|
||||
const { startDate } = tags?.year
|
||||
? parseDateRangeFromParams({ semesters: ['FALL', 'SPRING'], years: [tags?.year] })
|
||||
: { startDate: undefined }
|
||||
|
||||
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
|
||||
const discardedStudentNumbers = difference(studentnumberlist, filteredStudentNumbers)
|
||||
const result = await statisticsOf(filteredStudentNumbers, studyRights, tagMap, startDate)
|
||||
|
||||
res.status(200).json({ ...resultWithStudyProgramme, discardedStudentNumbers })
|
||||
})
|
||||
const resultWithStudyProgramme = { ...result, studyProgramme: tags?.studyProgramme }
|
||||
const discardedStudentNumbers = difference(studentNumbers, filteredStudentNumbers)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
@@ -12,12 +12,13 @@ router.get('/v3/curriculum-options/:code', async (req: Request, res: Response) =
|
||||
|
||||
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,65 +1,58 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import type { CanError } from '@oodikone/shared/routes'
|
||||
import type {
|
||||
GetStudentRequestResBody,
|
||||
GetStudentRequestReqBody,
|
||||
GetStudentRequestQuery,
|
||||
GetStudentDetailParams,
|
||||
GetStudentDetailResBody,
|
||||
GetStudentDetailReqBody,
|
||||
} from '@oodikone/shared/routes/students'
|
||||
import { splitByEmptySpace } from '@oodikone/shared/util'
|
||||
import { bySearchTermAndStudentNumbers, withStudentNumber } from '../services/students'
|
||||
import { hasFullAccessToStudentData, splitByEmptySpace } from '../util'
|
||||
import { ApplicationError } from '../util/customErrors'
|
||||
import { hasFullAccessToStudentData } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
interface GetStudentsRequest extends Request {
|
||||
query: {
|
||||
searchTerm: string
|
||||
router.get<never, CanError<GetStudentRequestResBody>, GetStudentRequestReqBody, GetStudentRequestQuery>(
|
||||
'/',
|
||||
async (req, res) => {
|
||||
const { searchTerm } = req.query
|
||||
const trimmedSearchTerm = searchTerm?.trim()
|
||||
const validSearchTerm =
|
||||
trimmedSearchTerm &&
|
||||
!splitByEmptySpace(trimmedSearchTerm)
|
||||
.slice(0, 2)
|
||||
.find(term => term.length > 2)
|
||||
|
||||
if (!trimmedSearchTerm || validSearchTerm) {
|
||||
return res.status(400).json({ error: 'at least one search term must be longer than 2 characters' })
|
||||
}
|
||||
|
||||
const { roles, studentsUserCanAccess } = req.user
|
||||
const results: ReturnType<typeof bySearchTermAndStudentNumbers> = hasFullAccessToStudentData(roles)
|
||||
? bySearchTermAndStudentNumbers(trimmedSearchTerm)
|
||||
: bySearchTermAndStudentNumbers(trimmedSearchTerm, studentsUserCanAccess)
|
||||
|
||||
return res.json(await results)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.get('/', async (req: GetStudentsRequest, res: Response) => {
|
||||
const {
|
||||
user: { roles, studentsUserCanAccess },
|
||||
query: { searchTerm },
|
||||
} = req
|
||||
router.get<GetStudentDetailParams, CanError<GetStudentDetailResBody>, GetStudentDetailReqBody>(
|
||||
'/:studentNumber',
|
||||
async (req, res) => {
|
||||
const { studentNumber } = req.params
|
||||
const { id, roles, studentsUserCanAccess } = req.user
|
||||
|
||||
const trimmedSearchTerm = searchTerm ? searchTerm.trim() : undefined
|
||||
if (!hasFullAccessToStudentData(roles) && !studentsUserCanAccess.includes(studentNumber))
|
||||
return res.status(403).json({ error: 'User does not have permission to view current student.' })
|
||||
|
||||
if (
|
||||
trimmedSearchTerm &&
|
||||
!splitByEmptySpace(trimmedSearchTerm)
|
||||
.slice(0, 2)
|
||||
.find(term => term.length > 2)
|
||||
) {
|
||||
throw new ApplicationError('at least one search term must be longer than 2 characters', 400)
|
||||
const student = await withStudentNumber(studentNumber, id)
|
||||
if (!student) return res.status(404).json({ error: 'Student not found' })
|
||||
|
||||
res.json(student)
|
||||
}
|
||||
|
||||
let results: Awaited<ReturnType<typeof bySearchTermAndStudentNumbers>> = []
|
||||
if (trimmedSearchTerm) {
|
||||
results = hasFullAccessToStudentData(roles)
|
||||
? await bySearchTermAndStudentNumbers(trimmedSearchTerm)
|
||||
: await bySearchTermAndStudentNumbers(trimmedSearchTerm, studentsUserCanAccess)
|
||||
}
|
||||
res.json(results)
|
||||
})
|
||||
|
||||
interface GetStudentRequest extends Request {
|
||||
params: {
|
||||
studentNumber: string
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/:studentNumber', async (req: GetStudentRequest, res: Response) => {
|
||||
const { studentNumber } = req.params
|
||||
const {
|
||||
user: { id, roles, studentsUserCanAccess },
|
||||
} = req
|
||||
|
||||
if (!hasFullAccessToStudentData(roles) && !studentsUserCanAccess.includes(studentNumber)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: `User does not have permission to view data for student number ${studentNumber}.` })
|
||||
}
|
||||
const student = await withStudentNumber(studentNumber, id)
|
||||
if (!student) {
|
||||
return res.status(404).json({ error: `Student not found with student number ${studentNumber}.` })
|
||||
}
|
||||
res.json(student)
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -180,7 +180,7 @@ router.get('/:id/studytracks', async (req: Request, res: Response) => {
|
||||
const data = await getStudyTracksForProgramme(code)
|
||||
return res.json(data)
|
||||
} catch (error) {
|
||||
logger.error({ message: `Failed to get study tracks for study programme ${code}`, meta: `${error}` })
|
||||
logger.error({ message: `Failed to get study tracks for degree programme ${code}`, meta: `${error}` })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { mapToProviders } from '@oodikone/shared/util'
|
||||
import { mapToProviders, splitByEmptySpace } 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 { getFullStudyProgrammeRights, splitByEmptySpace } from '../util'
|
||||
import { getFullStudyProgrammeRights, validateParamLength } from '../util'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -28,9 +28,10 @@ router.get('/', fullAccessAuth(), async (req: GetTeachersRequest, res: Response)
|
||||
}
|
||||
|
||||
const trimmedSearchTerm = searchTerm.trim()
|
||||
const stringSearchTermIsInvalid = !splitByEmptySpace(trimmedSearchTerm).find(searchTerm => searchTerm.length >= 4)
|
||||
const numericSearchTermIsInvalid = !Number.isNaN(Number(trimmedSearchTerm)) && trimmedSearchTerm.length < 6
|
||||
if (stringSearchTermIsInvalid || numericSearchTermIsInvalid) {
|
||||
const searchTermIsInvalid =
|
||||
!validateParamLength(trimmedSearchTerm, 4) &&
|
||||
!splitByEmptySpace(trimmedSearchTerm).find(searchTerm => searchTerm.length >= 4)
|
||||
if (searchTermIsInvalid) {
|
||||
return res.status(400).json({ error: 'Invalid search term' })
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ router.get('/allgraduationstats', async (_req: Request, res: Response) => {
|
||||
const allFaculties = await getSortedFaculties()
|
||||
const facultyCodes = allFaculties.map(faculty => faculty.code)
|
||||
const facultyData: Record<string, Awaited<ReturnType<typeof countGraduationTimes>>> = {}
|
||||
const programmeFilter = serviceProvider === 'toska' ? 'NEW_STUDY_PROGRAMMES' : 'ALL_PROGRAMMES'
|
||||
const programmeFilter = serviceProvider === 'toska' ? 'NEW_DEGREE_PROGRAMMES' : 'ALL_PROGRAMMES'
|
||||
for (const facultyCode of facultyCodes) {
|
||||
let data: any = await getGraduationStats(facultyCode, programmeFilter, true)
|
||||
if (!data) {
|
||||
|
||||
@@ -88,10 +88,10 @@ router.post('/refresh-teacher-leaderboard', (req: Request, res: Response) => {
|
||||
res.status(200).json('Teacher leaderboard for the current and previous academic year refreshed')
|
||||
})
|
||||
|
||||
router.post('/refresh_study_programmes_v2', async (req: Request, res: Response) => {
|
||||
logger.info(`${req.user.username} requested refresh of study programmes`)
|
||||
router.post('/refresh_degree_programmes_v2', async (req: Request, res: Response) => {
|
||||
logger.info(`${req.user.username} requested refresh of degree programmes`)
|
||||
await refreshProgrammes()
|
||||
res.status(200).json('Added job for refreshing study programme overviews')
|
||||
res.status(200).json('Added job for refreshing degree programme overviews')
|
||||
})
|
||||
|
||||
router.post('/refresh_faculties_v2', async (req: Request, res: Response) => {
|
||||
|
||||
@@ -1,113 +1,132 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { LANGUAGE_CODES, Language } from '@oodikone/shared/language'
|
||||
import { CanError } from '@oodikone/shared/routes'
|
||||
import { Role } from '@oodikone/shared/types'
|
||||
import { tryCatch } from '@oodikone/shared/util'
|
||||
import { roles } from '../config/roles'
|
||||
import * as auth from '../middleware/auth'
|
||||
import { sendNotificationAboutAccessToUser, previewNotificationAboutAccessToUser } from '../services/mailService'
|
||||
import * as userService from '../services/userService'
|
||||
import { FormattedUser } from '../types'
|
||||
import logger from '../util/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', auth.roles(['admin']), async (_req: Request, res: Response) => {
|
||||
type GetUsersResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>[]
|
||||
|
||||
router.get<never, CanError<GetUsersResBody>>('/', auth.roles(['admin']), async (_, res) => {
|
||||
const results = await userService.findAll()
|
||||
res.json(results)
|
||||
})
|
||||
|
||||
router.get('/roles', auth.roles(['admin']), (_req: Request, res: Response) => {
|
||||
type GetUserRolesResBody = readonly string[]
|
||||
|
||||
router.get<never, CanError<GetUserRolesResBody>>('/roles', auth.roles(['admin']), (_, res) => {
|
||||
res.json(roles)
|
||||
})
|
||||
|
||||
interface UidRequest extends Request {
|
||||
params: {
|
||||
uid: string
|
||||
type GetUserByIdParams = { uid: string }
|
||||
type GetUserByIdResBody = Omit<FormattedUser, 'studentsUserCanAccess' | 'isAdmin' | 'mockedBy' | 'userId'>
|
||||
|
||||
router.get<never, CanError<GetUserByIdResBody>, never, GetUserByIdParams>(
|
||||
'/:uid',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { uid } = req.params
|
||||
const { data: user, error } = await tryCatch(userService.findOne(uid))
|
||||
if (error) return res.status(400).json({ error: error.message })
|
||||
|
||||
return res.json(user)
|
||||
}
|
||||
)
|
||||
|
||||
type ModifyUserRolesReqBody = {
|
||||
username: string
|
||||
roles: Record<Role, boolean>
|
||||
}
|
||||
type ModifyUserRolesResBody = never
|
||||
|
||||
router.post<never, CanError<ModifyUserRolesResBody, Error>, ModifyUserRolesReqBody>(
|
||||
'/modify-roles',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { username, roles } = req.body
|
||||
const { error } = await tryCatch(userService.modifyAccess(username, roles))
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json(error)
|
||||
}
|
||||
|
||||
return res.status(204).end()
|
||||
}
|
||||
)
|
||||
|
||||
type UserEmailPreviewResBody = {
|
||||
subject: string
|
||||
html: string
|
||||
}
|
||||
|
||||
router.get('/:uid', auth.roles(['admin']), async (req: UidRequest, res: Response) => {
|
||||
const { uid } = req.params
|
||||
const { data: user, error } = await tryCatch(userService.findOne(uid))
|
||||
if (error) return res.status(400).json({ error: error.message })
|
||||
|
||||
return res.json(user)
|
||||
})
|
||||
|
||||
interface ModifyRolesRequest extends Request {
|
||||
body: {
|
||||
username: string
|
||||
roles: Record<Role, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/modify-roles', auth.roles(['admin']), async (req: ModifyRolesRequest, res: Response) => {
|
||||
const { username, roles } = req.body
|
||||
const { error } = await tryCatch(userService.modifyAccess(username, roles))
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json(error)
|
||||
}
|
||||
|
||||
return res.status(204).end()
|
||||
})
|
||||
|
||||
router.get('/email/preview', auth.roles(['admin']), (_req: Request, res: Response) => {
|
||||
router.get<never, CanError<UserEmailPreviewResBody>>('/email/preview', auth.roles(['admin']), (_, res) => {
|
||||
const { accessMessageSubject, accessMessageText } = previewNotificationAboutAccessToUser()
|
||||
res.json({ subject: accessMessageSubject, html: accessMessageText })
|
||||
})
|
||||
|
||||
interface EmailRequest extends Request {
|
||||
body: {
|
||||
email: string
|
||||
type PostUserEmailReqBody = { email: string }
|
||||
type PostUserEmailResBody = never
|
||||
|
||||
router.post<never, CanError<PostUserEmailResBody>, PostUserEmailReqBody>(
|
||||
'/email',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { email } = req.body
|
||||
if (!email) return res.status(400).json({ error: 'Email address is missing' })
|
||||
|
||||
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
|
||||
logger.info('Succesfully sent message about Oodikone access to user')
|
||||
return res.status(200).end()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post('/email', auth.roles(['admin']), async (req: EmailRequest, res: Response) => {
|
||||
const { email } = req.body
|
||||
if (!email) return res.status(400).json({ error: 'Email address is missing' })
|
||||
type ElementsParams = { uid: string }
|
||||
type ElementsReqBody = { codes: string[] }
|
||||
|
||||
const { error } = await tryCatch(sendNotificationAboutAccessToUser(email))
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
type PostUserUIDElementsResBody = void
|
||||
|
||||
logger.info('Succesfully sent message about Oodikone access to user')
|
||||
return res.status(200).end()
|
||||
})
|
||||
|
||||
interface ElementsRequest extends Request {
|
||||
params: {
|
||||
uid: string
|
||||
router.post<never, CanError<PostUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
|
||||
'/:uid/elements',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { uid } = req.params
|
||||
const { codes } = req.body
|
||||
await userService.modifyElementDetails(uid, codes, true)
|
||||
res.status(204).end()
|
||||
}
|
||||
body: {
|
||||
codes: string[]
|
||||
)
|
||||
|
||||
type DeleteUserUIDElementsResBody = void
|
||||
|
||||
router.delete<never, CanError<DeleteUserUIDElementsResBody>, ElementsReqBody, ElementsParams>(
|
||||
'/:uid/elements',
|
||||
auth.roles(['admin']),
|
||||
async (req, res) => {
|
||||
const { uid } = req.params
|
||||
const { codes } = req.body
|
||||
await userService.modifyElementDetails(uid, codes, false)
|
||||
res.status(204).end()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post('/:uid/elements', auth.roles(['admin']), async (req: ElementsRequest, res: Response) => {
|
||||
const { uid } = req.params
|
||||
const { codes } = req.body
|
||||
await userService.modifyElementDetails(uid, codes, true)
|
||||
res.status(204).end()
|
||||
})
|
||||
type ChangeLanguageReqBody = { language: Language }
|
||||
type PostChangeLanguageResBody = void
|
||||
|
||||
router.delete('/:uid/elements', auth.roles(['admin']), async (req: ElementsRequest, res: Response) => {
|
||||
const { uid } = req.params
|
||||
const { codes } = req.body
|
||||
await userService.modifyElementDetails(uid, codes, false)
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
interface ChangeLanguageRequest extends Request {
|
||||
body: {
|
||||
language: Language
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/language', async (req: ChangeLanguageRequest, res: Response) => {
|
||||
router.post<never, CanError<PostChangeLanguageResBody>, ChangeLanguageReqBody>('/language', async (req, res) => {
|
||||
const { language } = req.body
|
||||
|
||||
if (!LANGUAGE_CODES.includes(language)) {
|
||||
return res.status(400).json('Invalid language')
|
||||
return res.status(400).json({ error: 'Invalid language' })
|
||||
}
|
||||
|
||||
const { error } = await tryCatch(userService.updateUser(req.user.username, { language }))
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import moment from 'moment'
|
||||
|
||||
import { Graduated, SpecialGroups, YearType } from '@oodikone/shared/types'
|
||||
import { Graduated, SpecialGroups, StudyTrackStats, YearType } from '@oodikone/shared/types'
|
||||
import { facultyCodes, ignoredFacultyCodes } from '../config/organizationConstants'
|
||||
import { redisClient } from './redis'
|
||||
import { isRelevantProgramme } from './studyProgramme/studyProgrammeHelpers'
|
||||
@@ -49,7 +47,7 @@ export const setBasicStats = async (data, yearType: YearType, specialGroups: Spe
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
if (!shouldSaveToRedis(id)) {
|
||||
return dataToRedis
|
||||
@@ -84,7 +82,7 @@ export const setCreditStats = async (data, isAcademicYear: boolean, specialGroup
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
if (!isRelevantProgramme(id) && !isFaculty(id)) {
|
||||
return dataToRedis
|
||||
@@ -117,7 +115,7 @@ export const setGraduationStats = async (data, yearType: YearType, specialGroups
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
if (!shouldSaveToRedis(id)) {
|
||||
return dataToRedis
|
||||
@@ -141,16 +139,16 @@ export const getStudyTrackStats = async (
|
||||
if (!dataFromRedis) {
|
||||
return null
|
||||
}
|
||||
return JSON.parse(dataFromRedis)
|
||||
return JSON.parse(dataFromRedis) as StudyTrackStats & { status: string; lastUpdated: string }
|
||||
}
|
||||
|
||||
export const setStudyTrackStats = async (data, graduated: Graduated, specialGroups: SpecialGroups) => {
|
||||
export const setStudyTrackStats = async (data: StudyTrackStats, graduated: Graduated, specialGroups: SpecialGroups) => {
|
||||
const { id } = data
|
||||
const redisKey = createRedisKeyForStudyTrackStats(id, graduated, specialGroups)
|
||||
const dataToRedis = {
|
||||
...data,
|
||||
status: 'DONE',
|
||||
lastUpdated: moment().format(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}
|
||||
if (!shouldSaveToRedis(id)) {
|
||||
return dataToRedis
|
||||
|
||||
@@ -129,7 +129,7 @@ const getEnrollments = async (courses: Courses, fullCourseCodes: string[], stude
|
||||
return formattedEnrollments
|
||||
}
|
||||
|
||||
const getStudents = async (studentNumbers: string[]) => {
|
||||
const getStudents = async (studentNumbers: string[]): Promise<StudentWithCourses[]> => {
|
||||
const students = await StudentModel.findAll({
|
||||
attributes: ['studentnumber', 'firstnames', 'lastname', 'email', 'sis_person_id', 'secondary_email'],
|
||||
where: {
|
||||
@@ -147,9 +147,9 @@ const getStudents = async (studentNumbers: string[]) => {
|
||||
})
|
||||
|
||||
return students.map(student => {
|
||||
const { studyplans, ...rest } = student.toJSON() as StudentWithStudyplanNested
|
||||
const { studyplans, ...rest }: StudentWithStudyplanNested = student.toJSON()
|
||||
const coursesInStudyPlan = studyplans.flatMap(studyplan => studyplan.included_courses)
|
||||
return { ...rest, coursesInStudyPlan } as StudentWithCourses
|
||||
return { ...rest, coursesInStudyPlan }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user