mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-13 03:13:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0c2dd44e7 | ||
|
|
7044c3bf7f | ||
|
|
d70ceb88c1 | ||
|
|
d0acf302fe | ||
|
|
ccd2380e76 | ||
|
|
9cd9c16a09 | ||
|
|
f605d66b69 | ||
|
|
ce8aad2c3a | ||
|
|
ef247bba42 | ||
|
|
69544f7e45 | ||
|
|
808ce72579 | ||
|
|
75710b43b3 | ||
|
|
bb83957a29 | ||
|
|
7fbfc04157 | ||
|
|
e856b6ec99 | ||
|
|
017a792f0b | ||
|
|
1811306502 | ||
|
|
1175b16fc9 | ||
|
|
8aec97f78f | ||
|
|
72821ec2c6 | ||
|
|
f01a3f749e | ||
|
|
1fc95867f0 | ||
|
|
481719904b | ||
|
|
39947f2cd7 | ||
|
|
8b9a2c67cc | ||
|
|
d442d46f1e | ||
|
|
abd42bc6e5 | ||
|
|
0ba052a858 | ||
|
|
c971c1e565 | ||
|
|
c077916cde | ||
|
|
83f338c73d | ||
|
|
03aa01615c | ||
|
|
16d8de296c | ||
|
|
1e63219d46 | ||
|
|
64fc3ce828 | ||
|
|
cc5503c0e7 | ||
|
|
2610b2635c | ||
|
|
b714db7524 | ||
|
|
f327e15d1c | ||
|
|
b747775e8c | ||
|
|
04a16dc507 | ||
|
|
02d7f53970 | ||
|
|
1e7c976151 | ||
|
|
81140cd30f | ||
|
|
666963e18d | ||
|
|
67e33e966a | ||
|
|
d29ffea0dd | ||
|
|
111e479f4d | ||
|
|
72fa96e948 | ||
|
|
3e30bef439 | ||
|
|
25e23d6e2a | ||
|
|
bc6b249ad2 | ||
|
|
f127e03685 | ||
|
|
26d3a58d81 | ||
|
|
1fb272252f | ||
|
|
4f1a244cc3 | ||
|
|
b2f85d21fd | ||
|
|
8573173fb6 | ||
|
|
0c3cf36bb5 | ||
|
|
41b20cc8f4 | ||
|
|
ba6788647f | ||
|
|
b099e034e4 | ||
|
|
3763d6e3b4 | ||
|
|
4cf14cd149 | ||
|
|
9ad7cdbdd1 | ||
|
|
0697a8a9c4 | ||
|
|
0cd66ad84f | ||
|
|
3a2c5ce21e | ||
|
|
e16011877e | ||
|
|
16487aad22 | ||
|
|
38fe18960d | ||
|
|
5dcc3f21bd | ||
|
|
453055a8c8 | ||
|
|
a9e56781bd | ||
|
|
1f6a0f3bfb | ||
|
|
85dd92c0ee | ||
|
|
4f3503ba4f | ||
|
|
695937a451 | ||
|
|
9a8beef1b9 | ||
|
|
fc56d20e33 | ||
|
|
1a6dc6d814 | ||
|
|
985614e0be | ||
|
|
54bf8cdbbf | ||
|
|
b40e30ff01 | ||
|
|
e7e8559524 | ||
|
|
b9837a1951 | ||
|
|
54a290231a | ||
|
|
519c508a82 | ||
|
|
08915db3d8 | ||
|
|
81f2fa3735 | ||
|
|
20ccff4b44 | ||
|
|
7d752cdba8 | ||
|
|
8003f4d0a3 | ||
|
|
9f9e786387 | ||
|
|
99f16a84b7 | ||
|
|
2490f3c84f | ||
|
|
2f0092e944 | ||
|
|
26d9335b67 | ||
|
|
99dd6d072f | ||
|
|
421a532f58 | ||
|
|
b210e895a1 | ||
|
|
878b1b1052 | ||
|
|
d40c905b58 | ||
|
|
e816e71472 | ||
|
|
644c8df8c5 | ||
|
|
58a357099c | ||
|
|
a3a6e642aa | ||
|
|
2b68bcad6e | ||
|
|
b0d236c55e | ||
|
|
2e71627525 | ||
|
|
7529ee24c3 | ||
|
|
0c11a93dba | ||
|
|
0bfa213b1d | ||
|
|
2c1d3e8d57 | ||
|
|
51647b9880 | ||
|
|
e89701d7d7 | ||
|
|
7348ad93fe | ||
|
|
394025a6a3 | ||
|
|
1fe8d3f120 | ||
|
|
c4a194a278 | ||
|
|
185ac3a3e5 | ||
|
|
9944cb3f4d | ||
|
|
649ef838d7 | ||
|
|
497f04dbe3 | ||
|
|
dd0de6063d | ||
|
|
a9dba67f97 | ||
|
|
453c9792ac | ||
|
|
de3ef75867 | ||
|
|
1a34d5f98d | ||
|
|
4499937abb | ||
|
|
8ec7d503bd | ||
|
|
b7c4d0f46e | ||
|
|
a405c14039 | ||
|
|
11ed4dbdf6 | ||
|
|
9027e56c08 | ||
|
|
fc6eaafa1e | ||
|
|
8d60a06bf2 | ||
|
|
457040eb50 | ||
|
|
de8cbdb38c | ||
|
|
f7ce2b8096 | ||
|
|
b37c38d6ea | ||
|
|
042788687c | ||
|
|
0140c33390 | ||
|
|
e992bebe6e | ||
|
|
25cff2d0db | ||
|
|
d3a9a19d27 | ||
|
|
1a9af1814d | ||
|
|
f1844c00dc | ||
|
|
5f90746893 | ||
|
|
27c15ad273 | ||
|
|
b8b2d8e50d | ||
|
|
ca72edbf84 | ||
|
|
4d394c6b68 | ||
|
|
96693aa5e7 | ||
|
|
b4b5cf4c37 | ||
|
|
4c62e6f4d9 | ||
|
|
9abc150625 | ||
|
|
2feffd4c08 | ||
|
|
52d2be87f0 | ||
|
|
3c0929e7bc | ||
|
|
10108b78af | ||
|
|
e159c2e7c0 | ||
|
|
04071da1ee | ||
|
|
5d028ea004 | ||
|
|
2bd78b7e27 | ||
|
|
f6a470e05b | ||
|
|
211747101d | ||
|
|
58c56e6efa | ||
|
|
7feabb1f0a | ||
|
|
929c25b3c9 | ||
|
|
a15e4fe746 | ||
|
|
4f083e89c9 | ||
|
|
51dbd1aa3a | ||
|
|
2e202e5bde | ||
|
|
89c8d7c743 | ||
|
|
72a275fc2b | ||
|
|
246f5999ae | ||
|
|
93607623c4 | ||
|
|
e299f3f99b | ||
|
|
95e9140e5e | ||
|
|
9d12f5be1b | ||
|
|
8e7146c1d6 | ||
|
|
2195c93fe7 | ||
|
|
1a0e0b9003 | ||
|
|
09e346fe48 | ||
|
|
277df18169 | ||
|
|
a5b9eae6c9 | ||
|
|
1820bcd800 | ||
|
|
cb84179982 | ||
|
|
e8cd783eb1 | ||
|
|
01eef2dfb2 | ||
|
|
466e3e2a08 | ||
|
|
1733368dcb | ||
|
|
db829dff48 | ||
|
|
095894e46b | ||
|
|
bbd031cec2 | ||
|
|
a879b080b7 | ||
|
|
41ffb433ad | ||
|
|
35d24d7ea3 | ||
|
|
56706ae225 | ||
|
|
1ee001148b | ||
|
|
87fa8d42c0 | ||
|
|
b8a6e6ffbc | ||
|
|
f2d53c2569 | ||
|
|
0f4269a9d1 | ||
|
|
1983c87d15 | ||
|
|
01c727e814 | ||
|
|
49bc25f07c | ||
|
|
fbe6229cb8 | ||
|
|
abc4869e25 | ||
|
|
02395073c3 | ||
|
|
7063cceff6 | ||
|
|
0d6dc54b27 | ||
|
|
fc8631574c | ||
|
|
4f093fb8c9 | ||
|
|
41e5e56358 | ||
|
|
fc8670abe4 | ||
|
|
3a77b033d9 | ||
|
|
8aff92420e | ||
|
|
98ce6c1977 | ||
|
|
ce6c8139ac | ||
|
|
951a87d003 | ||
|
|
827172ef23 | ||
|
|
b41fad1d7d | ||
|
|
d5539fad0f | ||
|
|
1ed3a1af08 | ||
|
|
69af0887fe | ||
|
|
641df176f1 | ||
|
|
5baf5ce6fc | ||
|
|
d031140126 | ||
|
|
efb729df84 | ||
|
|
21074ca2cf | ||
|
|
15039451b5 | ||
|
|
25229f6776 | ||
|
|
50b0597ef9 | ||
|
|
1bd2741ae3 | ||
|
|
1848671762 | ||
|
|
be4579365f | ||
|
|
3a214d79cb | ||
|
|
b5cec49f39 | ||
|
|
4bbd4208b4 | ||
|
|
fc6d34f468 | ||
|
|
6004894629 | ||
|
|
ba2035f42b | ||
|
|
41294ad74c | ||
|
|
972682ef7d | ||
|
|
b15358c915 | ||
|
|
e7e98ccf36 | ||
|
|
98910eb2b7 | ||
|
|
3cc286b776 | ||
|
|
d1aa0642a2 | ||
|
|
7aae8b7194 | ||
|
|
835077e50a | ||
|
|
f2f4c60988 | ||
|
|
65cb045224 | ||
|
|
5f162550c4 | ||
|
|
5d84378f53 | ||
|
|
c76ed75b1a | ||
|
|
00bb92a69e | ||
|
|
f1a8624e78 | ||
|
|
c9e59795a7 | ||
|
|
ec43b49679 | ||
|
|
dfa2c17a8e | ||
|
|
73dd3fa9f6 | ||
|
|
5594b9314b | ||
|
|
690505f900 | ||
|
|
5cbf56094d | ||
|
|
1dd75b5b73 | ||
|
|
b2d8b7e7d0 | ||
|
|
1c83db5dfd | ||
|
|
71a4ebab25 | ||
|
|
599e0f067b | ||
|
|
3f51d6a1d9 | ||
|
|
5f257d2f03 | ||
|
|
d2f148e460 | ||
|
|
37f927f5ce | ||
|
|
ea99be5de6 | ||
|
|
f0de82032f | ||
|
|
81bcf90da4 | ||
|
|
71dc751622 | ||
|
|
69f68d8d7a | ||
|
|
a3bed7de45 | ||
|
|
aa2dba5eae | ||
|
|
4a61bcbc5c | ||
|
|
9c3b666ed1 | ||
|
|
7e829f2485 | ||
|
|
6197539472 | ||
|
|
8a96fb35e7 | ||
|
|
4adf9b7f4f | ||
|
|
b20fff05f5 | ||
|
|
b771980283 | ||
|
|
9e52ee98d3 | ||
|
|
b16eca90b1 | ||
|
|
9ef677fec3 | ||
|
|
cce98c3e9c | ||
|
|
23b540a28c | ||
|
|
9e63231744 | ||
|
|
0790f357f5 | ||
|
|
b561ba7749 | ||
|
|
efd7558e2b | ||
|
|
8563d286a7 | ||
|
|
f062e3638f | ||
|
|
633e2df6fc | ||
|
|
7cb83ab0d7 | ||
|
|
8f7bf68aa9 | ||
|
|
0dea5a3d2d | ||
|
|
a0061f5248 | ||
|
|
4adb4969de | ||
|
|
618f1feb91 | ||
|
|
9b36a5c5e4 | ||
|
|
7babd14105 | ||
|
|
3e027b0de8 | ||
|
|
3bd45a4bf2 | ||
|
|
f881c68937 | ||
|
|
a36becdaf3 | ||
|
|
233d914c20 | ||
|
|
25ae1cc326 | ||
|
|
cd668e75a4 | ||
|
|
fb63a1a417 | ||
|
|
c660a1c791 | ||
|
|
7a154d9715 | ||
|
|
5ecf47c0d0 | ||
|
|
81ff0a951e | ||
|
|
d735fb633f | ||
|
|
ed25501833 | ||
|
|
1e3b5f60d0 | ||
|
|
ffbed82670 | ||
|
|
b519e4220b | ||
|
|
f9238b122e | ||
|
|
726a491fe8 | ||
|
|
e4b775b324 | ||
|
|
62a0f453b0 | ||
|
|
fc964f5746 | ||
|
|
d18b2741df | ||
|
|
1ec6ed7da8 | ||
|
|
a146470ce6 | ||
|
|
0128e4d01c | ||
|
|
5f11ff66ef | ||
|
|
bca2aef066 | ||
|
|
36698cf18f | ||
|
|
7eb4071785 | ||
|
|
4ea9cab186 | ||
|
|
9bd2f09697 | ||
|
|
481cabe323 | ||
|
|
84c34e6b1a | ||
|
|
e2d958917d | ||
|
|
c688046516 | ||
|
|
055eb8ace1 | ||
|
|
786d70dfba | ||
|
|
d620375ae8 | ||
|
|
b357b59f6f | ||
|
|
724b8a84e2 | ||
|
|
8f20349053 | ||
|
|
efe52389e2 | ||
|
|
7330b11421 | ||
|
|
4ed3cbe6a9 | ||
|
|
5f675fe874 | ||
|
|
3acb0660c8 | ||
|
|
493ed2966b | ||
|
|
6b54f03ace | ||
|
|
b83e7695e7 | ||
|
|
5f6229d9a7 | ||
|
|
aaf687b8ee | ||
|
|
42d0408489 | ||
|
|
ad9c7f5b0d | ||
|
|
213020b060 | ||
|
|
e76eb9f833 | ||
|
|
7ac855b290 | ||
|
|
0636ad604f | ||
|
|
0bf178a130 | ||
|
|
9f0d54b589 | ||
|
|
da90302449 | ||
|
|
6233caf056 | ||
|
|
bd5340253e | ||
|
|
246beffdda | ||
|
|
1a9d5702e4 | ||
|
|
309f5b64d0 | ||
|
|
f2b8d36c71 | ||
|
|
33719c7d63 | ||
|
|
c1d5bfdc3c | ||
|
|
ff2a060a3f | ||
|
|
2f26f28d28 | ||
|
|
4350d5ccf8 | ||
|
|
8a8c879d19 | ||
|
|
6abc68f235 | ||
|
|
29d6e58184 | ||
|
|
ef423466c0 | ||
|
|
ad27e01af6 | ||
|
|
4c98b693d1 | ||
|
|
86e13119f3 | ||
|
|
d6d13ed302 | ||
|
|
309537866a | ||
|
|
4a9b6fddce | ||
|
|
86acf69e30 | ||
|
|
7ab5cc6b6a | ||
|
|
445045d0d3 | ||
|
|
61bb11ce55 | ||
|
|
63d0821b17 | ||
|
|
48665fca7d | ||
|
|
a3e2f0c050 | ||
|
|
d6ab36ab4a | ||
|
|
4aa69d792d | ||
|
|
2e4a8407b2 | ||
|
|
57b7d78621 | ||
|
|
0b7e0747be | ||
|
|
177f0b1c6b | ||
|
|
1519a005f2 | ||
|
|
f4836f07cf | ||
|
|
038e472e26 | ||
|
|
950ae29bdb | ||
|
|
4cb0a60ce9 | ||
|
|
cab87fae65 | ||
|
|
9c56b5daaa | ||
|
|
d1ec029d12 | ||
|
|
ad39463076 | ||
|
|
59862bfd79 | ||
|
|
91811da150 | ||
|
|
92b6d2713f | ||
|
|
cfb0a0368d | ||
|
|
704a1e34b0 | ||
|
|
e99dc4d999 | ||
|
|
02387a2d4a | ||
|
|
ee4e00d0ec | ||
|
|
75caf4c639 | ||
|
|
0ca30cada3 | ||
|
|
985a6e7e1a | ||
|
|
3b62d4da6b | ||
|
|
19b29e6390 | ||
|
|
99168201a2 | ||
|
|
5a85afec3c | ||
|
|
d64cd6833b | ||
|
|
30fbf993d7 | ||
|
|
98acb17403 | ||
|
|
2a3a99a6b1 | ||
|
|
e12890d488 | ||
|
|
4a83bd0419 | ||
|
|
2c1bb28a8b | ||
|
|
fc966743ac | ||
|
|
fde5a81656 | ||
|
|
0b28f7b4ac | ||
|
|
6faffa7ca8 | ||
|
|
3854193f32 | ||
|
|
bda3e79705 | ||
|
|
85a8afe8d9 | ||
|
|
a4d7024de9 | ||
|
|
3a14392b6f | ||
|
|
87df82b281 | ||
|
|
e5298f9e07 | ||
|
|
d89515f847 | ||
|
|
56de943b75 | ||
|
|
8b229bc79b | ||
|
|
dd40841276 | ||
|
|
dbcf23f397 | ||
|
|
223cd164b5 | ||
|
|
83c381e952 | ||
|
|
14ac03193a | ||
|
|
93ff205f3a | ||
|
|
dd517c96fb | ||
|
|
3b87bb3256 | ||
|
|
0023d2ff35 | ||
|
|
e254a0920f | ||
|
|
5c52030066 | ||
|
|
6dc96ef54c | ||
|
|
8a661cf174 | ||
|
|
051bf8983c | ||
|
|
3b8c6f9a56 | ||
|
|
13e37b45b7 | ||
|
|
e23f03fd40 | ||
|
|
f05f3fae22 | ||
|
|
567cd57820 | ||
|
|
5b11cda4a0 | ||
|
|
af6167410b | ||
|
|
aed5c5f14a | ||
|
|
a52aa39ad3 | ||
|
|
30325ee2d6 | ||
|
|
ab61c5542e | ||
|
|
5ff33f076a | ||
|
|
7faed37c2c | ||
|
|
4125a71c58 | ||
|
|
6b5942df75 | ||
|
|
df2aa6fa42 | ||
|
|
165dc64f8a | ||
|
|
15542a130f | ||
|
|
0a9d6c503a | ||
|
|
42bd9dac42 | ||
|
|
5f3a4afb79 | ||
|
|
868ae2da7c | ||
|
|
98da606794 | ||
|
|
90a1995577 | ||
|
|
db00e6c326 | ||
|
|
2feec3df5b | ||
|
|
664f6760a4 | ||
|
|
1fa0a4c4d6 | ||
|
|
53d1ca02bd | ||
|
|
a74b2fa1a8 | ||
|
|
61cf7beb87 | ||
|
|
9d621d7252 | ||
|
|
d0583c5dc8 | ||
|
|
cea46648ac | ||
|
|
621a9c746b | ||
|
|
4f00d8bf34 | ||
|
|
b219f6c560 | ||
|
|
d98bd5634b | ||
|
|
8804d0c26d | ||
|
|
9be8af00fc | ||
|
|
92283687fe | ||
|
|
01288da371 | ||
|
|
86b4d46881 | ||
|
|
c2e84632e6 | ||
|
|
fa1fa1decc | ||
|
|
52f8477bb7 | ||
|
|
1fe2e700aa | ||
|
|
f529765d01 | ||
|
|
d7b17e7508 | ||
|
|
4236478e2d | ||
|
|
a34733ede4 | ||
|
|
2eecb252c0 | ||
|
|
f27f33230a | ||
|
|
91790ab987 | ||
|
|
738d14f8ae | ||
|
|
426b8366d3 | ||
|
|
143e608208 | ||
|
|
9859d09542 | ||
|
|
024fe4645f | ||
|
|
f9324a521f | ||
|
|
968264a0f9 | ||
|
|
b9d60e101a | ||
|
|
6ef9c33cbb | ||
|
|
5d2b8c425c | ||
|
|
d1c8bb2fed | ||
|
|
16f1669566 | ||
|
|
5dfe81a6f2 | ||
|
|
58b6647735 | ||
|
|
2f761631d9 | ||
|
|
ca81e9ec8c | ||
|
|
d7f4919b63 | ||
|
|
f9fb48bd0f | ||
|
|
9d89b1eabb | ||
|
|
6012dc2399 | ||
|
|
a92c3ca359 | ||
|
|
af9fc49d38 | ||
|
|
30143e032c | ||
|
|
0aca801da8 | ||
|
|
6caeddcdbb | ||
|
|
5bc55b2aa7 | ||
|
|
e34009b878 | ||
|
|
2d2499e5d7 | ||
|
|
9b55371d76 | ||
|
|
2586c37a9f | ||
|
|
7d4971b005 | ||
|
|
1bb366dc37 | ||
|
|
aa37fccf4d | ||
|
|
0577ccd87e | ||
|
|
ce4f7b074e | ||
|
|
830037491f | ||
|
|
51513077ec | ||
|
|
80ed72375e | ||
|
|
97c955a959 | ||
|
|
fe3f5a78ea | ||
|
|
bec0d9b460 | ||
|
|
3bfd8121f1 | ||
|
|
5bd20a4237 | ||
|
|
f497de87de | ||
|
|
c3b9eab905 | ||
|
|
7dff91e350 | ||
|
|
893337b596 | ||
|
|
aa523b0b29 | ||
|
|
cc4339db91 | ||
|
|
f6a3fba6d6 | ||
|
|
aba95f59eb | ||
|
|
6d8e2e4dae | ||
|
|
86074c6d17 | ||
|
|
61fe7a5a3e | ||
|
|
1db1cb7534 | ||
|
|
9bccb62bb2 | ||
|
|
2214a8dee4 | ||
|
|
12e7a3409e | ||
|
|
de96850d5b | ||
|
|
5de51af7e6 | ||
|
|
97a1068d50 | ||
|
|
fcb74da52d | ||
|
|
9d7529642a | ||
|
|
8ff5c5819d | ||
|
|
8382634840 | ||
|
|
7a1b79c120 | ||
|
|
07c480ca75 | ||
|
|
d6c0132007 | ||
|
|
550496346a | ||
|
|
cb1900878c | ||
|
|
56da9defc5 | ||
|
|
d595173618 | ||
|
|
0c6eca4f1f | ||
|
|
87fff0ec20 | ||
|
|
83b2f8b8d8 | ||
|
|
047b0d7ea3 | ||
|
|
d0277aa3f5 | ||
|
|
8345e04d23 | ||
|
|
04a0984590 | ||
|
|
343894d26a | ||
|
|
775f8769fc | ||
|
|
7feb51281b | ||
|
|
d57b51ca68 | ||
|
|
c09fd9097a | ||
|
|
54a9d87770 | ||
|
|
e74163e2d0 | ||
|
|
d8cd323e8f | ||
|
|
b892c62021 | ||
|
|
3b7f44f819 | ||
|
|
653449989b | ||
|
|
cc771d3ae2 | ||
|
|
04b868a84b | ||
|
|
6674e8358c | ||
|
|
2b822fce0a | ||
|
|
162c604078 | ||
|
|
c12ce13db1 | ||
|
|
f15d8832f1 | ||
|
|
cd363f2c1a | ||
|
|
fd70b9e63b | ||
|
|
49ad82a0ea | ||
|
|
0be484abe4 | ||
|
|
0b71d37d4f | ||
|
|
f36bcc147a | ||
|
|
7f56ab449a | ||
|
|
222d9dcfd1 | ||
|
|
43f867ee0a | ||
|
|
af550e6dbe | ||
|
|
6b1b526347 | ||
|
|
67e054b684 | ||
|
|
48933b33ee | ||
|
|
017d90bb05 | ||
|
|
e47fde953f | ||
|
|
84ed7b5c7e | ||
|
|
2252e72c3b | ||
|
|
891a75d27a | ||
|
|
ccb9350fd5 | ||
|
|
91a7c21fb4 | ||
|
|
4dba535e46 | ||
|
|
b579b56db0 | ||
|
|
99f1691c5b | ||
|
|
c496ddf3bb | ||
|
|
244f4df1d1 | ||
|
|
7586ce264e | ||
|
|
5a50be4b05 | ||
|
|
8bff9d71b8 | ||
|
|
61258cd139 | ||
|
|
2583f473cb | ||
|
|
5d380fa087 | ||
|
|
0425797920 | ||
|
|
016b8dd1d6 | ||
|
|
cbcf3f78f1 | ||
|
|
afc07fe42c | ||
|
|
9bf5507716 | ||
|
|
dea0c32397 | ||
|
|
e9c7e12d48 | ||
|
|
732d250ef2 | ||
|
|
7c46211f42 | ||
|
|
bea6a65bca | ||
|
|
9f456947e5 | ||
|
|
8e062b87c7 | ||
|
|
e90fb7f7bc | ||
|
|
03a9b1e3ae | ||
|
|
16fe6e0a90 | ||
|
|
9f42de3fde |
@@ -94,6 +94,10 @@ jobs:
|
||||
- deploy:
|
||||
command: |
|
||||
if [ "${DEPLOY}" == "true" ]; then
|
||||
docker exec --env SENTRY_URL --env SENTRY_AUTH_TOKEN --env SENTRY_ORG=sentry --env SENTRY_PROJECT=oodifront frontend \
|
||||
sh +e -c 'npm run sentry-cli -- releases info "$(cat /SENTRY_RELEASE_VERSION)" && \
|
||||
npm run sentry-cli -- releases delete "$(cat /SENTRY_RELEASE_VERSION)" || true'
|
||||
|
||||
docker exec --env SENTRY_URL --env SENTRY_AUTH_TOKEN --env SENTRY_ORG=sentry --env SENTRY_PROJECT=oodifront frontend \
|
||||
sh -c 'npm run sentry-cli -- releases new --finalize "$(cat /SENTRY_RELEASE_VERSION)" && \
|
||||
npm run sentry-cli -- releases files "$(cat /SENTRY_RELEASE_VERSION)" upload-sourcemaps --rewrite dist'
|
||||
|
||||
@@ -25,3 +25,5 @@ datastore
|
||||
services/updater_api/debug.log
|
||||
services/backend/updater_writer/dumps
|
||||
debug.log
|
||||
programme_module_dump.sql
|
||||
morning.sh
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[](https://circleci.com/gh/UniversityOfHelsinkiCS/oodikone/tree/master)
|
||||
|
||||
Simple CLI tool for setting up the development environment for Oodikone. The entire development environment runs inside of a Docker network that's defined in the docker-compose.yml file. The specifics of what this means for setting up the environment and accessing logs is discussed in a later section.
|
||||
An application for analyzing university data. The entire development environment runs inside of a Docker network that's defined in the docker-compose.yml file. The specifics of what this means for setting up the environment and accessing logs is discussed in a later section.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -32,12 +32,15 @@ For local development with anonymized data use `2) Anon setup` (you can run e2e
|
||||
|
||||
To get also the full real dataset use `3) Full setup`
|
||||
|
||||
> `1) e2e setup` is designed for use in travis. But you can still try it locally if you want to.
|
||||
> `1) e2e setup` is designed for use in CircleCI. But you can still try it locally if you want to.
|
||||
|
||||
Run E2E tests in with `npm run cypress:run` or `npm run cypress:open` with visual browser.
|
||||
|
||||
The acual oodikone starts to http://localhost:8081/
|
||||
|
||||
On a fresh install, some features do not work because redis is empty.
|
||||
To populate redis, navigate to http://localhost:8081/updater, and press the 'refresh statistics' -button.
|
||||
|
||||
## What the CLI does
|
||||
|
||||
- Builds and runs the Dockerized development environment
|
||||
@@ -62,7 +65,7 @@ The mapping of ports and environment variables are also defined in the docker-co
|
||||
#### With anonymized data:
|
||||
|
||||
```
|
||||
npm run docker:up
|
||||
npm start
|
||||
```
|
||||
|
||||
#### With real data:
|
||||
|
||||
+3
-1
@@ -5,5 +5,7 @@
|
||||
"taskTimeout": 60000,
|
||||
"pageLoadTimeout": 60000,
|
||||
"requestTimeout": 60000,
|
||||
"responseTimeout": 60000
|
||||
"responseTimeout": 60000,
|
||||
"viewportHeight": 1080,
|
||||
"viewportWidth": 1920
|
||||
}
|
||||
|
||||
@@ -2,18 +2,7 @@
|
||||
|
||||
describe('Course population tests', () => {
|
||||
beforeEach(() => {
|
||||
cy.server({
|
||||
onAnyRequest: function (route, proxy) {
|
||||
if (Cypress.config().baseUrl.includes("http://nginx/")) {
|
||||
proxy.xhr.setRequestHeader('uid', 'tktl')
|
||||
proxy.xhr.setRequestHeader('shib-session-id', 'mock-shibboleth')
|
||||
proxy.xhr.setRequestHeader('hygroupcn', 'grp-oodikone-users')
|
||||
proxy.xhr.setRequestHeader('edupersonaffiliation', 'asdasd')
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log(Cypress.config().baseUrl)
|
||||
cy.visit(Cypress.config().baseUrl)
|
||||
cy.init()
|
||||
cy.contains("Course statistics").click()
|
||||
cy.contains("Search for courses")
|
||||
})
|
||||
@@ -32,35 +21,9 @@ describe('Course population tests', () => {
|
||||
cy.contains('Population of course Käyttöjärjestelmät 2017-18')
|
||||
cy.contains('TKT20003, 582219, 582640, 582497')
|
||||
cy.contains('Students (all=39)')
|
||||
cy.contains('add').click()
|
||||
cy.contains('Select students that have grade')
|
||||
|
||||
cy.contains('show').click()
|
||||
cy.contains('Students (39)').click()
|
||||
cy.contains('013614218')
|
||||
cy.contains('014022579')
|
||||
})
|
||||
|
||||
it('Filters are working', ()=> {
|
||||
cy.url().should('include', '/coursestatistics')
|
||||
cy.contains("Search for courses")
|
||||
cy.get("input[placeholder='Search by entering a course code']").type('TKT10004')
|
||||
cy.get(':nth-child(2) > .ten > :nth-child(1)').click()
|
||||
cy.contains("Fetch statistics").should('be.enabled').click()
|
||||
|
||||
cy.get(':nth-child(2) > :nth-child(1) > div > .item > .level').click()
|
||||
cy.contains('Population of course Tietokantojen perusteet 2017-18')
|
||||
cy.contains('add').click()
|
||||
|
||||
// sex filter
|
||||
cy.get('.inline > :nth-child(2) > :nth-child(1) > label').click()
|
||||
cy.get(':nth-child(8) > .form > .inline > :nth-child(3) > .ui').click()
|
||||
|
||||
cy.contains('Students (all=100)')
|
||||
cy.get(':nth-child(4) > .segment > .selectable > tbody > :nth-child(2) > :nth-child(2)').click()
|
||||
cy.contains('Students (all=17)')
|
||||
|
||||
cy.get(':nth-child(5) > .segment > .selectable > tbody > :nth-child(3) > :nth-child(1)').click()
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma, KH50_005').should('not.exist')
|
||||
cy.contains('Students (all=1)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,18 +2,7 @@
|
||||
|
||||
describe('Course Statistics tests', () => {
|
||||
beforeEach(() => {
|
||||
cy.server({
|
||||
onAnyRequest: function (route, proxy) {
|
||||
if (Cypress.config().baseUrl.includes("http://nginx/")) {
|
||||
proxy.xhr.setRequestHeader('uid', 'tktl')
|
||||
proxy.xhr.setRequestHeader('shib-session-id', 'mock-shibboleth')
|
||||
proxy.xhr.setRequestHeader('hygroupcn', 'grp-oodikone-users')
|
||||
proxy.xhr.setRequestHeader('edupersonaffiliation', 'asdasd')
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log(Cypress.config().baseUrl)
|
||||
cy.visit(Cypress.config().baseUrl)
|
||||
cy.init()
|
||||
cy.contains("Course statistics").click()
|
||||
cy.contains("Search for courses")
|
||||
})
|
||||
|
||||
@@ -22,19 +22,11 @@ const save = () => {
|
||||
};
|
||||
|
||||
const selectSavedPopulation = name => {
|
||||
cy.contains("Saved populations")
|
||||
.siblings()
|
||||
.get("input[class=search]")
|
||||
.click()
|
||||
.type(name)
|
||||
.type("{enter}");
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(name).type("{enter}");
|
||||
};
|
||||
|
||||
const deleteAllSearches = () => {
|
||||
cy.contains("Saved populations")
|
||||
.siblings()
|
||||
.get("input[class=search]")
|
||||
.click();
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).click();
|
||||
cy.contains("Saved populations")
|
||||
.parent()
|
||||
.parent()
|
||||
@@ -45,12 +37,7 @@ const deleteAllSearches = () => {
|
||||
const searchItems = d.find("div[role=option] > span[class=text]");
|
||||
for (let i = 0; i < searchItems.length; i++) {
|
||||
if (searchItems[i].textContent.includes("TEST-")) {
|
||||
cy.contains("Saved populations")
|
||||
.siblings()
|
||||
.get("input[class=search]")
|
||||
.click()
|
||||
.type(searchItems[i].textContent)
|
||||
.type("{enter}");
|
||||
cy.get('[data-cy="history-search"]').children().eq(0).type(searchItems[i].textContent).type("{enter}");
|
||||
cy.get("button")
|
||||
.contains("Delete")
|
||||
.click();
|
||||
@@ -79,7 +66,6 @@ const searchFor = studentnumbers => {
|
||||
};
|
||||
|
||||
const hasLanded = () => {
|
||||
cy.contains("Add filters");
|
||||
cy.contains("Credit accumulation");
|
||||
cy.contains("Programme distribution");
|
||||
cy.contains("Courses of Population");
|
||||
@@ -91,11 +77,7 @@ const containsAmountOfStudents = (amount = 0) => {
|
||||
};
|
||||
|
||||
const containsSpecificStudents = (studentnumbers = []) => {
|
||||
cy.contains("Students (")
|
||||
.siblings()
|
||||
.get("button")
|
||||
.contains("show")
|
||||
.click();
|
||||
cy.contains(`Students (${studentnumbers.length})`).click();
|
||||
|
||||
studentnumbers.forEach(s => cy.contains(s));
|
||||
};
|
||||
@@ -114,18 +96,7 @@ const loginAs = username => {
|
||||
|
||||
describe("Custom population tests", () => {
|
||||
beforeEach(() => {
|
||||
cy.server({
|
||||
onAnyRequest: function(route, proxy) {
|
||||
if (Cypress.config().baseUrl.includes("http://nginx/")) {
|
||||
proxy.xhr.setRequestHeader("uid", "tktl");
|
||||
proxy.xhr.setRequestHeader("shib-session-id", "mock-shibboleth");
|
||||
proxy.xhr.setRequestHeader("hygroupcn", "grp-oodikone-users");
|
||||
proxy.xhr.setRequestHeader("edupersonaffiliation", "asdasd");
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log(Cypress.config().baseUrl);
|
||||
cy.visit(`${Cypress.config().baseUrl}custompopulation`);
|
||||
cy.init('custompopulation')
|
||||
cy.url().should("include", "/custompopulation");
|
||||
cy.contains("Custom population");
|
||||
});
|
||||
@@ -167,11 +138,9 @@ describe("Custom population tests", () => {
|
||||
it("Doesn't find empty custom population", () => {
|
||||
const testStudentNumbers = ["1", "2", "3"];
|
||||
searchFor(testStudentNumbers);
|
||||
cy.contains("Add filters").should("not.exist");
|
||||
cy.contains("Credit accumulation").should("not.exist");
|
||||
cy.contains("Programme distribution").should("not.exist");
|
||||
cy.contains("Courses of Population").should("not.exist");
|
||||
cy.contains("Students").should("not.exist");
|
||||
cy.get("button").contains("Custom population");
|
||||
});
|
||||
|
||||
@@ -181,11 +150,9 @@ describe("Custom population tests", () => {
|
||||
loginAs("Normaalikäyttäjä");
|
||||
|
||||
searchFor(kasvatusStudents);
|
||||
cy.contains("Add filters").should("not.exist");
|
||||
cy.contains("Credit accumulation").should("not.exist");
|
||||
cy.contains("Programme distribution").should("not.exist");
|
||||
cy.contains("Courses of Population").should("not.exist");
|
||||
cy.contains("Students").should("not.exist");
|
||||
|
||||
searchFor(kapistelyStudents.concat(kasvatusStudents));
|
||||
hasLanded();
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
const checkFilteringResult = (studentCount, noFiltering = false) => {
|
||||
cy.get(".accordion > :nth-child(1)").should("contain", `for ${studentCount} students`)
|
||||
cy.cs("active-filter-count").should(noFiltering ? "not.exist" : "exist")
|
||||
}
|
||||
|
||||
describe("Population Statistics", () => {
|
||||
before(() => {
|
||||
// Tests run considerably faster if we don't init() before each case.
|
||||
// Drawback is that cases then rely on the tray being toggled open.
|
||||
// TODO: Make smarter (if one test fails all subsequent tests fail, too)?
|
||||
cy.init()
|
||||
cy.selectStudyProgramme("Tietojenkäsittelytieteen kandiohjelma")
|
||||
cy.cs("filter-toggle-open").click()
|
||||
})
|
||||
|
||||
it("Filter tray opens and closes", () => {
|
||||
cy.cs("filter-toggle-close").click().should("not.be.visible")
|
||||
cy.cs("filter-toggle-open").click().should("not.be.visible")
|
||||
})
|
||||
|
||||
it("Graduation filter works", () => {
|
||||
cy.cs("graduatedFromProgrammeFilter-header").click()
|
||||
cy.selectFromDropdown("graduatedFromProgrammeFilter-dropdown", 0)
|
||||
checkFilteringResult(210)
|
||||
cy.selectFromDropdown("graduatedFromProgrammeFilter-dropdown", 1)
|
||||
checkFilteringResult(9)
|
||||
cy.cs("graduatedFromProgrammeFilter-clear").click()
|
||||
checkFilteringResult(219, true)
|
||||
})
|
||||
|
||||
it("Transfer filter works", () => {
|
||||
cy.cs("transferredToProgrammeFilter-header").click()
|
||||
cy.cs("transferredToProgrammeFilter-have").click()
|
||||
checkFilteringResult(25)
|
||||
cy.cs("transferredToProgrammeFilter-havenot").click()
|
||||
checkFilteringResult(194)
|
||||
cy.cs("transferredToProgrammeFilter-clear").click()
|
||||
checkFilteringResult(219, true)
|
||||
})
|
||||
|
||||
it("Enrollment filter works", () => {
|
||||
cy.cs("enrollmentStatusFilter-header").click()
|
||||
cy.selectFromDropdown("enrollmentStatusFilter-status", 0)
|
||||
checkFilteringResult(219, true)
|
||||
cy.selectFromDropdown("enrollmentStatusFilter-semesters", [0, 0])
|
||||
checkFilteringResult(202)
|
||||
cy.selectFromDropdown("enrollmentStatusFilter-status", 1)
|
||||
checkFilteringResult(13)
|
||||
cy.cs("enrollmentStatusFilter-clear").click()
|
||||
checkFilteringResult(219, true)
|
||||
})
|
||||
|
||||
it("Credit filter works", () => {
|
||||
cy.cs("credit-filter-header").click()
|
||||
cy.cs("credit-filter-min").click().type("50{enter}")
|
||||
checkFilteringResult(98)
|
||||
cy.cs("credit-filter-max").click().type("100{enter}")
|
||||
checkFilteringResult(88)
|
||||
cy.cs("credit-filter-min-clear").click()
|
||||
checkFilteringResult(209)
|
||||
cy.cs("credit-filter-max-clear").click()
|
||||
checkFilteringResult(219, true)
|
||||
})
|
||||
|
||||
it("Gender filter works", () => {
|
||||
cy.cs("genderFilter-header").click()
|
||||
cy.selectFromDropdown("genderFilter-dropdown", 0)
|
||||
checkFilteringResult(48)
|
||||
cy.selectFromDropdown("genderFilter-dropdown", 1)
|
||||
checkFilteringResult(171)
|
||||
cy.selectFromDropdown("genderFilter-dropdown", 2)
|
||||
checkFilteringResult(0)
|
||||
cy.cs("genderFilter-clear").click()
|
||||
checkFilteringResult(219, true)
|
||||
})
|
||||
|
||||
// FIXME: This test fails in CI and with cypress:run but not with cypress:open.
|
||||
/*
|
||||
it("Starting year filter works", () => {
|
||||
cy.viewport(1920, 1080)
|
||||
cy.cs("startYearAtUni-header").click()
|
||||
cy.screenshot('debug0')
|
||||
cy.selectFromDropdown("startYearAtUni-dropdown", [18])
|
||||
cy.screenshot('debug1')
|
||||
checkFilteringResult(155) // 153 in ci
|
||||
cy.screenshot('debug2')
|
||||
cy.cs("startYearAtUni-clear").click()
|
||||
checkFilteringResult(219, true)
|
||||
})
|
||||
*/
|
||||
it("Courses filter works", () => {
|
||||
cy.viewport(1920, 1080)
|
||||
cy.cs("courseFilter-header").click()
|
||||
cy.cs("courseFilter-course-dropdown").click().contains("MAT11002").click()
|
||||
checkFilteringResult(82)
|
||||
cy.selectFromDropdown("courseFilter-MAT11002-dropdown", 1)
|
||||
checkFilteringResult(75)
|
||||
cy.cs("courseFilter-course-dropdown").click().contains("TKT20001").click()
|
||||
checkFilteringResult(62)
|
||||
cy.selectFromDropdown("courseFilter-TKT20001-dropdown", 6)
|
||||
checkFilteringResult(24)
|
||||
cy.cs("courseFilter-MAT11002-clear").click()
|
||||
checkFilteringResult(125)
|
||||
cy.cs("courseFilter-TKT20001-clear").click()
|
||||
checkFilteringResult(219, true)
|
||||
})
|
||||
})
|
||||
@@ -5,27 +5,14 @@ const setPopStatsUntil = (until, includeSettings = []) => {
|
||||
includeSettings.forEach(setting => {
|
||||
cy.contains("Advanced settings").parent().siblings().contains(setting).click()
|
||||
})
|
||||
cy.contains("Statistics until").siblings().get('.rdt').get('input').eq(2).click().clear().type(until)
|
||||
cy.get(".adv-stats-until > .form-control").click().clear().type(until)
|
||||
cy.contains("Fetch population with new settings").click()
|
||||
cy.contains("Advanced settings")
|
||||
}
|
||||
|
||||
describe('Population Statistics tests', () => {
|
||||
beforeEach(() => {
|
||||
cy.server({
|
||||
onAnyRequest: function (route, proxy) {
|
||||
if (Cypress.config().baseUrl.includes("http://nginx/")) {
|
||||
proxy.xhr.setRequestHeader('uid', 'tktl')
|
||||
proxy.xhr.setRequestHeader('shib-session-id', 'mock-shibboleth')
|
||||
proxy.xhr.setRequestHeader('hygroupcn', 'grp-oodikone-users')
|
||||
proxy.xhr.setRequestHeader('edupersonaffiliation', 'asdasd')
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log(Cypress.config().baseUrl)
|
||||
cy.visit(Cypress.config().baseUrl)
|
||||
cy.contains("Study programme").click().siblings().contains("Search by class").click()
|
||||
cy.contains("Select study programme")
|
||||
cy.init()
|
||||
})
|
||||
|
||||
const checkAmountOfStudents = (assertion) => {
|
||||
@@ -39,16 +26,9 @@ describe('Population Statistics tests', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const removeFilter = (text) => {
|
||||
cy.contains("Filters").siblings().within(() => {
|
||||
cy.contains(text).parent().within(() => {
|
||||
cy.get(".remove").click()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
it('Population statistics search form is usable', () => {
|
||||
cy.cs("navbar-studyProgramme").click()
|
||||
cy.cs("navbar-class").click()
|
||||
cy.contains("See population").should('be.disabled')
|
||||
cy.url().should('include', '/populations')
|
||||
cy.contains("Search for population")
|
||||
@@ -59,8 +39,8 @@ describe('Population Statistics tests', () => {
|
||||
cy.get("@enrollmentSelect").its(`${[0]}.value`).then((beforeVal) => {
|
||||
cy.get("@enrollmentSelect").click()
|
||||
// go back to 2010-2019
|
||||
cy.get(".yearSelectInput .rdtPrev").click()
|
||||
cy.get(".yearSelectInput table").contains("2014-2015").click()
|
||||
cy.get(".yearSelectInput .rdtPrev").click({ force: true })
|
||||
cy.get(".yearSelectInput table").contains("2014-2015").click({ force: true })
|
||||
cy.get("@enrollmentSelect").should('not.have.value', beforeVal)
|
||||
})
|
||||
|
||||
@@ -69,8 +49,7 @@ describe('Population Statistics tests', () => {
|
||||
})
|
||||
|
||||
it('Population statistics is usable on general level', () => {
|
||||
cy.contains("Select study programme").click().siblings().contains("Tietojenkäsittelytieteen maisteriohjelma").click()
|
||||
cy.contains("See population").click()
|
||||
cy.selectStudyProgramme("Tietojenkäsittelytieteen maisteriohjelma")
|
||||
setPopStatsUntil('September 2019')
|
||||
|
||||
cy.get(".card").within(() => {
|
||||
@@ -90,22 +69,17 @@ describe('Population Statistics tests', () => {
|
||||
})
|
||||
cy.contains("TKT20005")
|
||||
cy.go("back")
|
||||
cy.contains("Courses of population").click({ force: true })
|
||||
cy.contains("Ohjelmoinnin perusteet")
|
||||
cy.contains("Courses of population").click().parent().within(() => {
|
||||
cy.contains("Ohjelmoinnin perusteet").siblings().eq(3).should("have.text", "15")
|
||||
})
|
||||
|
||||
cy.contains("add").click()
|
||||
cy.contains("Add filters").siblings().within(() => {
|
||||
cy.get(".form").should('have.length', 9)
|
||||
cy.contains("Ohjelmoinnin perusteet").siblings().eq(4).should("have.text", "15")
|
||||
})
|
||||
|
||||
checkAmountOfStudents(29)
|
||||
|
||||
let filteredStudents = 1328493
|
||||
cy.contains("Credit statistics").click()
|
||||
cy.contains("Credits gained during first").parentsUntil(".tab").get("table").within(() => {
|
||||
cy.get("tr").eq(2).find("td").eq(1).invoke("text").then(text => filteredStudents = Number(text))
|
||||
cy.contains("Credits Gained During First").parentsUntil(".tab").get("table").within(() => {
|
||||
cy.get("tr").eq(2).find("td").eq(2).invoke("text").then(text => filteredStudents = Number(text))
|
||||
cy.route('POST', '/api/v2/populationstatistics/courses**').as('courseData')
|
||||
cy.get("tr").eq(2).find('.filter').click()
|
||||
cy.wait('@courseData')
|
||||
@@ -114,7 +88,7 @@ describe('Population Statistics tests', () => {
|
||||
})
|
||||
cy.contains("Courses of population").click({ force: true })
|
||||
cy.contains("Courses of population").parent().within(() => {
|
||||
cy.contains("Ohjelmoinnin perusteet").siblings().eq(3).should("have.text", "1")
|
||||
cy.contains("Ohjelmoinnin perusteet").siblings().eq(5).should("have.text", "0")
|
||||
})
|
||||
|
||||
cy.contains("Students (1)").click()
|
||||
@@ -127,8 +101,7 @@ describe('Population Statistics tests', () => {
|
||||
})
|
||||
|
||||
it('Student list checking works as intended', () => {
|
||||
cy.contains("Select study programme").click().siblings().contains("Tietojenkäsittelytieteen maisteriohjelma").click()
|
||||
cy.contains("See population").click()
|
||||
cy.selectStudyProgramme("Tietojenkäsittelytieteen maisteriohjelma")
|
||||
cy.contains("Students (29)").click()
|
||||
cy.contains("010111264")
|
||||
cy.contains("666666666").should('not.exist')
|
||||
@@ -146,198 +119,70 @@ describe('Population Statistics tests', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('All filters working', () => {
|
||||
cy.contains("Select study programme", { timeout: 50000 }).click().siblings().contains("Tietojenkäsittelytieteen maisteriohjelma").click()
|
||||
cy.contains("See population").click()
|
||||
|
||||
cy.contains("add").click()
|
||||
cy.contains("Add filters").siblings().within(() => {
|
||||
cy.contains("Show only students with credits at least").parentsUntil("form").contains("set filter").should('be.disabled')
|
||||
.parentsUntil("form").find("input").type("15")
|
||||
cy.contains("Show only students with credits at least").parentsUntil("form").contains("set filter").click()
|
||||
|
||||
})
|
||||
cy.contains("Filters").siblings().within(() => {
|
||||
cy.contains("Credits at least 15")
|
||||
})
|
||||
checkAmountOfStudents(23)
|
||||
|
||||
cy.contains("Show only students with credits less than").parentsUntil("form").contains("set filter").should('be.disabled')
|
||||
.parentsUntil("form").find("input").type("100")
|
||||
cy.contains("Show only students with credits less than").parentsUntil("form").contains("set filter").click()
|
||||
|
||||
checkAmountOfStudents(22)
|
||||
|
||||
cy.contains("chosen semester").parentsUntil("form").contains("set filter").click()
|
||||
|
||||
checkAmountOfStudents(11)
|
||||
|
||||
cy.contains("select status").click().siblings().contains("present").click()
|
||||
cy.contains("select semesters").click().siblings().contains("Fall 2018").click()
|
||||
cy.contains("Spring 2018").click()
|
||||
cy.contains("Spring 2018").parentsUntil("form").contains("set filter").click()
|
||||
cy.contains("Students that were present").should('have.text', "Students that were present during Fall 2018, Spring 2018")
|
||||
cy.get(':nth-child(6) > .form > .inline > :nth-child(2) > .ui > .default').click().siblings().contains('have not').click()
|
||||
|
||||
checkAmountOfStudents(11)
|
||||
|
||||
cy.contains('transferred to').parentsUntil("form").contains("set filter").click()
|
||||
|
||||
checkAmountOfStudents(11)
|
||||
|
||||
cy.contains("Students that").parentsUntil("form").contains("set filter").click()
|
||||
cy.contains('Showing students that have not graduated from Tietojenkäsittelytieteen maisteriohjelma')
|
||||
checkAmountOfStudents(11)
|
||||
|
||||
cy.contains("Advanced filters").click()
|
||||
|
||||
cy.contains("Save filters as preset").click()
|
||||
cy.contains("This filter is saved").siblings().within(() => { cy.get("input").type(`Basic filters-${new Date().getTime()}`, { delay: 0 }) })
|
||||
cy.contains("Save current filters as preset").parentsUntil(".dimmer").within(() => { cy.get("button").contains("Save").click() })
|
||||
|
||||
cy.contains("Filters").siblings().within(() => {
|
||||
cy.contains(`Basic filters`).parentsUntil("form").get(".remove").click()
|
||||
})
|
||||
|
||||
cy.contains("course type").click().siblings().contains("Aineopinnot").click()
|
||||
cy.contains("discipline").click().siblings().contains("Tietojenkäsittelytiede").click()
|
||||
cy.contains("and at least").parentsUntil("form").contains("set filter").click()
|
||||
|
||||
checkAmountOfStudents(0)
|
||||
|
||||
cy.get('.filter-segment').its('length')
|
||||
.then(originalLength => {
|
||||
cy.contains('.filter-segment', 'Laskennan mallit').should('exist')
|
||||
removeFilter('Laskennan mallit')
|
||||
cy.get('.filter-segment').should('have.length', originalLength - 1)
|
||||
})
|
||||
|
||||
cy.contains("select source").click().siblings().contains("Anywhere").click()
|
||||
cy.contains("select target").click().siblings().contains("Tietojenkäsittelytieteen maisteriohjelma").click()
|
||||
cy.contains("Students that transferred from").parentsUntil("form").contains("set filter").click()
|
||||
cy.contains("Showing students that transferred").should('have.text', 'Showing students that transferred to Tietojenkäsittelytieteen maisteriohjelma')
|
||||
|
||||
cy.contains('are/not').click().siblings().contains('not').click()
|
||||
cy.contains('select graduation').click().siblings().contains('graduated').click()
|
||||
cy.contains('select extent').click().siblings().contains('Ylempi korkeakoulututkinto').click()
|
||||
cy.contains('Alempi korkeakoulututkinto').parentsUntil("form").contains("set filter").click()
|
||||
|
||||
cy.contains('Excluded students that graduated')
|
||||
|
||||
cy.contains("Students that has").parentsUntil("form").within(() => {
|
||||
cy.contains("degree").click().siblings().contains("any degree").click()
|
||||
cy.contains("programme").click().siblings().contains("Tietojenkäsittelytieteen maisteriohjelma")
|
||||
cy.contains("priority").click().siblings().contains("primary studies").click()
|
||||
cy.contains("set filter").click()
|
||||
})
|
||||
|
||||
checkAmountOfStudents(0)
|
||||
|
||||
cy.contains("Basic filters").parentsUntil("form").contains("set filter").click()
|
||||
|
||||
cy.contains("Save filters as preset").click()
|
||||
cy.contains("This filter is saved").siblings().within(() => { cy.get("input").type(`Advanced filters-${new Date().getTime()}`, { delay: 0 }) })
|
||||
cy.contains("Save current filters as preset").parentsUntil(".dimmer").within(() => { cy.get("button").contains("Save").click() })
|
||||
|
||||
cy.visit("/populations")
|
||||
cy.contains("Select study programme", { timeout: 50000 }).click().siblings().contains("Tietojenkäsittelytieteen maisteriohjelma").click()
|
||||
cy.contains("See population").click()
|
||||
|
||||
cy.contains("add").click()
|
||||
cy.contains("Advanced filters").click()
|
||||
|
||||
cy.get('label:contains(Basic filters)').each(($f) => {
|
||||
cy.wrap($f).parentsUntil("form").contains("set filter").click()
|
||||
})
|
||||
cy.get("label:contains(Advanced filters-)").each(($f) => {
|
||||
cy.wrap($f).parentsUntil("form").contains("set filter").click()
|
||||
})
|
||||
|
||||
cy.get("label:contains(Basic filters)").each(($f) => {
|
||||
cy.wrap($f).parentsUntil(".segment").within(() => {
|
||||
cy.get(".trash").click()
|
||||
})
|
||||
cy.root().get("button").contains("Delete for good").click({ force: true })
|
||||
})
|
||||
|
||||
cy.get("label:contains(Advanced filters-)").each(($f) => {
|
||||
cy.wrap($f).parentsUntil(".segment").within(() => {
|
||||
cy.get(".trash").click()
|
||||
})
|
||||
cy.root().get("button").contains("Delete for good").click({ force: true })
|
||||
})
|
||||
})
|
||||
|
||||
it("'Has enrolled' filter is shown only if non-enrolled students are included in the advanced settings", () => {
|
||||
cy.contains("Select study programme").click().siblings().contains("Tietojenkäsittelytieteen maisteriohjelma").click()
|
||||
cy.contains("See population").click()
|
||||
setPopStatsUntil("September 2019", ["present nor absent"])
|
||||
|
||||
cy.wait(500)
|
||||
cy.contains("Add filters").parent().contains("button", "add").should('not.have.class', 'disabled')
|
||||
cy.contains("Add filters").parent().contains("button", "add").click()
|
||||
cy.contains("Add filters").siblings().within(() => {
|
||||
cy.get(".form").should('have.length', 10)
|
||||
})
|
||||
|
||||
checkAmountOfStudents(32)
|
||||
cy.contains("have/haven't").click().siblings().contains("have").click()
|
||||
cy.contains("enrolled present (n)or absent").parentsUntil("form").contains("set filter").click()
|
||||
cy.contains("Excluded students who").should("have.text", "Excluded students who haven't enrolled present nor absent")
|
||||
checkAmountOfStudents(29)
|
||||
})
|
||||
|
||||
it('Population statistics wont crash course population', () => {
|
||||
cy.contains("Select study programme").click().siblings().contains("Tietojenkäsittelytieteen maisteriohjelma").click()
|
||||
cy.contains("See population").click()
|
||||
cy.selectStudyProgramme("Tietojenkäsittelytieteen maisteriohjelma")
|
||||
cy.contains("Courses of population").click({ force: true })
|
||||
cy.contains("Lineaarialgebra").siblings().within(() => { cy.get('.level').click() })
|
||||
cy.get(':nth-child(3) > .iconCell > p > .item > .level').click({ force: true })
|
||||
cy.get(':nth-child(3) > :nth-child(1) > div > .item > .level').click({ force: true })
|
||||
})
|
||||
|
||||
it("Empty 'mandatory courses' tab has a link to the page where mandatory courses can be added", () => {
|
||||
cy.contains("Select study programme").click().siblings().contains("Kielten kandiohjelma").click()
|
||||
cy.contains("See population").click()
|
||||
cy.selectStudyProgramme("Kielten kandiohjelma")
|
||||
cy.contains("Students (5)").click()
|
||||
cy.contains("Mandatory courses").click()
|
||||
cy.get("[data-cy=student-table-tabs]").contains("Mandatory Courses").click()
|
||||
cy.contains("No mandatory courses defined. You can define them here.").find("a").click()
|
||||
cy.contains("Kielten kandiohjelma")
|
||||
cy.contains("Add courses")
|
||||
})
|
||||
|
||||
it("Empty 'tags' tab has a link to the page where tags can be created", () => {
|
||||
cy.contains("Select study programme").click().siblings().contains("Kielten kandiohjelma").click()
|
||||
cy.contains("See population").click()
|
||||
cy.selectStudyProgramme("Kielten kandiohjelma")
|
||||
cy.contains("Students (5)").click()
|
||||
cy.contains("Mandatory courses").siblings().contains("Tags").click()
|
||||
cy.get("[data-cy=student-table-tabs]").contains("Mandatory Courses").siblings().contains("Tags").click()
|
||||
cy.contains("No tags defined. You can define them here.").find("a").click()
|
||||
cy.contains("Kielten kandiohjelma")
|
||||
cy.contains("Create new tag")
|
||||
})
|
||||
|
||||
it("Advanced settings work", () => {
|
||||
cy.contains("Select study programme").click().siblings().contains("Tietojenkäsittelytieteen kandiohjelma").click()
|
||||
cy.contains("See population").click()
|
||||
cy.get('.field > .ui > label').click()
|
||||
cy.selectStudyProgramme("Tietojenkäsittelytieteen kandiohjelma")
|
||||
cy.get('[data-cy=advanced-toggle]').click()
|
||||
cy.contains('Statistics until')
|
||||
// only spring
|
||||
cy.contains('Semesters').siblings().contains('Fall').click()
|
||||
cy.cs('toggle-fall').click()
|
||||
cy.contains('Fetch population').click()
|
||||
|
||||
cy.contains('Credit accumulation (for 13 students)')
|
||||
|
||||
// only fall
|
||||
cy.contains('Semesters').siblings().contains('Fall').click()
|
||||
cy.contains('Semesters').siblings().contains('Spring').click()
|
||||
cy.cs('toggle-fall').click()
|
||||
cy.cs('toggle-spring').click()
|
||||
|
||||
cy.contains('Fetch population').click()
|
||||
|
||||
// FIXME: This fails randomly both locally and on CI.
|
||||
cy.contains('Credit accumulation (for 206 students)')
|
||||
|
||||
// spring + fall and include cancelled
|
||||
cy.contains('Semesters').siblings().contains('Spring').click()
|
||||
cy.contains('Include').siblings().contains('present nor absent').click()
|
||||
cy.cs('toggle-spring').click()
|
||||
cy.cs('toggle-cancelled').click()
|
||||
cy.contains('Fetch population').click()
|
||||
|
||||
cy.contains('Credit accumulation (for 228 students)')
|
||||
})
|
||||
|
||||
it("Credit Statistics, Statistics pane works", () => {
|
||||
cy.selectStudyProgramme("Tietojenkäsittelytieteen kandiohjelma")
|
||||
cy.contains("Credit statistics").click()
|
||||
cy.get("[data-cy='credit-stats-tab'] > .menu > :nth-child(2)").click()
|
||||
|
||||
cy.get("[data-cy='credit-stats-table-name-header']").should('contain', 'Statistic for n = 219 Students')
|
||||
cy.get("[data-cy='credit-stats-mean']").should('contain', '44.36')
|
||||
cy.get("[data-cy='credit-stats-stdev']").should('contain', '30.77')
|
||||
cy.get("[data-cy='credit-stats-min']").should('contain', '0')
|
||||
cy.get("[data-cy='credit-stats-q1']").should('contain', '17')
|
||||
cy.get("[data-cy='credit-stats-q2']").should('contain', '46')
|
||||
cy.get("[data-cy='credit-stats-q3']").should('contain', '66')
|
||||
cy.get("[data-cy='credit-stats-max']").should('contain', '137')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,17 +2,7 @@
|
||||
|
||||
describe('Student Statistics tests', () => {
|
||||
beforeEach(() => {
|
||||
cy.server({
|
||||
onAnyRequest: function (route, proxy) {
|
||||
if (Cypress.config().baseUrl.includes("http://nginx/")) {
|
||||
proxy.xhr.setRequestHeader('uid', 'tktl')
|
||||
proxy.xhr.setRequestHeader('shib-session-id', 'mock-shibboleth')
|
||||
proxy.xhr.setRequestHeader('hygroupcn', 'grp-oodikone-users')
|
||||
proxy.xhr.setRequestHeader('edupersonaffiliation', 'asdasd')
|
||||
}
|
||||
}
|
||||
})
|
||||
cy.visit(Cypress.config().baseUrl)
|
||||
cy.init()
|
||||
cy.contains("Student statistics").click()
|
||||
cy.contains("Student names hidden")
|
||||
})
|
||||
|
||||
@@ -9,17 +9,7 @@ const deleteTag = (name) => {
|
||||
|
||||
describe('Studyprogramme overview', () => {
|
||||
beforeEach(() => {
|
||||
cy.server({
|
||||
onAnyRequest: function (route, proxy) {
|
||||
if (Cypress.config().baseUrl.includes("http://nginx/")) {
|
||||
proxy.xhr.setRequestHeader('uid', 'tktl')
|
||||
proxy.xhr.setRequestHeader('shib-session-id', 'mock-shibboleth')
|
||||
proxy.xhr.setRequestHeader('hygroupcn', 'grp-oodikone-users')
|
||||
proxy.xhr.setRequestHeader('edupersonaffiliation', 'asdasd')
|
||||
}
|
||||
}
|
||||
})
|
||||
cy.visit(Cypress.config().baseUrl)
|
||||
cy.init()
|
||||
cy.contains("Study programme").click().siblings().contains("Overview").click()
|
||||
cy.contains("Study Programme", { timeout: 100000 })
|
||||
})
|
||||
@@ -59,10 +49,10 @@ describe('Studyprogramme overview', () => {
|
||||
cy.get('table').should('have.length', 2)
|
||||
cy.contains('Population progress')
|
||||
cy.contains('Yearly productivity')
|
||||
cy.contains("2017-2018").siblings().contains('219').siblings().contains('171')
|
||||
cy.contains("2017-2018").siblings().contains('228').siblings().contains('178')
|
||||
|
||||
cy.get('table').eq(1).contains('2018').siblings().contains('3023.00').siblings().contains('2616.00')
|
||||
cy.get('table').eq(1).contains('2017').siblings().contains('3159.00').siblings().contains("2889.00")
|
||||
cy.get('table').eq(1).contains('2018').siblings().contains('3023.00').siblings().contains('2631.00')
|
||||
cy.get('table').eq(1).contains('2017').siblings().contains('3159.00').siblings().contains("2914.00")
|
||||
})
|
||||
|
||||
it('can add and delete mandatory courses and labels', () => {
|
||||
@@ -73,8 +63,8 @@ describe('Studyprogramme overview', () => {
|
||||
cy.get('input').eq(0).type('Code Generation')
|
||||
cy.wait('@searchResponse')
|
||||
cy.contains('Searched courses')
|
||||
cy.get('table').eq(0).contains('Code Generation').click()
|
||||
cy.get('table').eq(1).contains('tr', 'Code Generation').contains('select label')
|
||||
cy.get(':nth-child(1) > .ten').click()
|
||||
cy.contains('select label')
|
||||
|
||||
cy.contains('Group labels').click()
|
||||
const label = 'cypress test label'
|
||||
@@ -124,6 +114,7 @@ describe('Studyprogramme overview', () => {
|
||||
cy.get('i.level.up.alternate.icon').eq(0).click()
|
||||
cy.contains('Credit accumulation (for 29 students)')
|
||||
})
|
||||
|
||||
it('can create and delete tags for population', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
@@ -135,6 +126,7 @@ describe('Studyprogramme overview', () => {
|
||||
cy.contains('2018')
|
||||
deleteTag(name)
|
||||
})
|
||||
|
||||
it('can create personal tags', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
@@ -147,6 +139,7 @@ describe('Studyprogramme overview', () => {
|
||||
cy.contains(name)
|
||||
deleteTag(name)
|
||||
})
|
||||
|
||||
it('can add tags to students', () => {
|
||||
const name = `tag-${new Date().getTime()}`
|
||||
|
||||
@@ -160,7 +153,7 @@ describe('Studyprogramme overview', () => {
|
||||
cy.contains('Add tags to students').click()
|
||||
cy.get('.form > .field > .dropdown').click().get('.ui > .search').type(name).click()
|
||||
|
||||
cy.get('.form > .field > .dropdown').contains(name).click()
|
||||
cy.get('.form > .field > .dropdown > .visible').contains(name).click()
|
||||
|
||||
cy.get('textarea').type('010623419')
|
||||
cy.get('.positive').click()
|
||||
|
||||
@@ -10,18 +10,7 @@ const deleteTag = (name) => {
|
||||
|
||||
describe('Tag tests', () => {
|
||||
beforeEach(() => {
|
||||
cy.server({
|
||||
onAnyRequest: function (route, proxy) {
|
||||
if (Cypress.config().baseUrl.includes("http://nginx/")) {
|
||||
proxy.xhr.setRequestHeader('uid', 'admink')
|
||||
proxy.xhr.setRequestHeader('shib-session-id', 'mock-shibboleth')
|
||||
proxy.xhr.setRequestHeader('hygroupcn', 'grp-oodikone-users')
|
||||
proxy.xhr.setRequestHeader('edupersonaffiliation', 'asdasd')
|
||||
}
|
||||
}
|
||||
})
|
||||
cy.visit(Cypress.config().baseUrl)
|
||||
cy.reload()
|
||||
cy.init()
|
||||
cy.contains("Study programme").click()
|
||||
cy.contains("Overview").click()
|
||||
cy.contains("Datatieteen maisteriohjelma").click()
|
||||
@@ -39,7 +28,7 @@ describe('Tag tests', () => {
|
||||
cy.contains('Add tags to students').click()
|
||||
cy.get('.form > .field > .dropdown').click().get('.ui > .search').type(name).click()
|
||||
|
||||
cy.get('.form > .field > .dropdown').contains(name).click()
|
||||
cy.get('.form > .field > .dropdown > .visible').contains(name).click()
|
||||
|
||||
cy.get('textarea').type('014022579\n011122249')
|
||||
cy.get('.positive').click()
|
||||
|
||||
@@ -2,18 +2,7 @@
|
||||
|
||||
describe('Teachers page tests', () => {
|
||||
beforeEach(() => {
|
||||
cy.server({
|
||||
onAnyRequest: function (route, proxy) {
|
||||
if (Cypress.config().baseUrl.includes("http://nginx/")) {
|
||||
proxy.xhr.setRequestHeader('uid', 'tktl')
|
||||
proxy.xhr.setRequestHeader('shib-session-id', 'mock-shibboleth')
|
||||
proxy.xhr.setRequestHeader('hygroupcn', 'grp-oodikone-users')
|
||||
proxy.xhr.setRequestHeader('edupersonaffiliation', 'asdasd')
|
||||
}
|
||||
}
|
||||
})
|
||||
cy.visit(Cypress.config().baseUrl)
|
||||
cy.reload()
|
||||
cy.init()
|
||||
cy.contains("Teachers").click()
|
||||
cy.contains("Teacher statistics by course providers")
|
||||
})
|
||||
@@ -21,8 +10,9 @@ describe('Teachers page tests', () => {
|
||||
it("Check Statistics", () => {
|
||||
cy.get(':nth-child(1) > .ui > .search').click()
|
||||
cy.contains('Autumn 2017').click()
|
||||
cy.get('.form > :nth-child(2) > .ui').click()
|
||||
cy.cs('course-providers').click()
|
||||
cy.contains('Tietojenkäsittelytieteen kandiohjelma').click()
|
||||
cy.cs('course-providers').children('.icon').click()
|
||||
cy.get('.form > .fluid').click()
|
||||
cy.contains('Name')
|
||||
cy.contains('Wu')
|
||||
@@ -57,13 +47,13 @@ describe('Teachers page tests', () => {
|
||||
})
|
||||
|
||||
it("Check leaderboad works", () => {
|
||||
cy.get('.borderless > :nth-child(2)').click()
|
||||
cy.get(':nth-child(1) > .ui > .search').click()
|
||||
cy.contains('2017-18').click()
|
||||
cy.contains("Recalculate this year").click()
|
||||
cy.get('.borderless > :nth-child(2)').click({ force: true })
|
||||
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
|
||||
cy.contains('2017-18').click({ force: true })
|
||||
cy.contains("Recalculate this year").click({ force: true })
|
||||
cy.wait(5000)
|
||||
cy.reload()
|
||||
cy.get(':nth-child(1) > .ui > .search').click()
|
||||
cy.get(':nth-child(1) > .ui > .search').click({ force: true })
|
||||
cy.contains('2017-18').click()
|
||||
cy.contains("Passed")
|
||||
cy.contains("Antero")
|
||||
|
||||
@@ -2,19 +2,7 @@
|
||||
|
||||
describe('Users tests', () => {
|
||||
beforeEach(() => {
|
||||
cy.server({
|
||||
onAnyRequest: function (route, proxy) {
|
||||
if (Cypress.config().baseUrl.includes("http://nginx/")) {
|
||||
proxy.xhr.setRequestHeader('uid', 'admink')
|
||||
proxy.xhr.setRequestHeader('shib-session-id', 'mock-shibboleth')
|
||||
proxy.xhr.setRequestHeader('hygroupcn', 'grp-oodikone-users')
|
||||
proxy.xhr.setRequestHeader('edupersonaffiliation', 'asdasd')
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log(Cypress.config().baseUrl)
|
||||
cy.visit(Cypress.config().baseUrl)
|
||||
cy.reload()
|
||||
cy.init()
|
||||
cy.contains("Users").click()
|
||||
cy.contains("Oodikone users")
|
||||
})
|
||||
@@ -39,5 +27,4 @@ describe('Users tests', () => {
|
||||
cy.get("div[role='option']").should('have.length', 1).contains("Tietojenkäsittelytieteen kandiohjelma")
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
+42
-25
@@ -1,25 +1,42 @@
|
||||
// ***********************************************
|
||||
// This example commands.js shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add("login", (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
/**
|
||||
* Initialize headers and load the base URL or optional path.
|
||||
*/
|
||||
Cypress.Commands.add("init", (path = '') => {
|
||||
cy.server({
|
||||
onAnyRequest: function (route, proxy) {
|
||||
if (Cypress.config().baseUrl.includes("http://nginx/")) {
|
||||
proxy.xhr.setRequestHeader('uid', 'tktl')
|
||||
proxy.xhr.setRequestHeader('shib-session-id', 'mock-shibboleth')
|
||||
proxy.xhr.setRequestHeader('hygroupcn', 'grp-oodikone-users')
|
||||
proxy.xhr.setRequestHeader('edupersonaffiliation', 'asdasd')
|
||||
}
|
||||
}
|
||||
})
|
||||
cy.visit(Cypress.config().baseUrl.concat(path))
|
||||
})
|
||||
|
||||
/**
|
||||
* Shorthand for using "Cypress Selectors" (CS), i.e., `data-cy` attributes.
|
||||
*/
|
||||
Cypress.Commands.add("cs", (name) => {
|
||||
return cy.get(`[data-cy='${name}']`)
|
||||
})
|
||||
|
||||
/**
|
||||
* Select item specified by `index` number from a semantic-ui dropdown with
|
||||
* `data-cy` attribute value `name`.
|
||||
*/
|
||||
Cypress.Commands.add("selectFromDropdown", (name, index) => {
|
||||
const indexes = Array.isArray(index) ? index : [index]
|
||||
|
||||
indexes.forEach((i) => {
|
||||
cy.cs(name).click().children(".menu").children().eq(i).click({ force: true })
|
||||
})
|
||||
|
||||
// Close multiple selection so it does not block elements underneath it.
|
||||
if (Array.isArray(index)) {
|
||||
cy.cs(name).children(".icon").click({ force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands'
|
||||
import './populationStatistics'
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/// <reference types="Cypress" />
|
||||
|
||||
Cypress.Commands.add("selectStudyProgramme", (name) => {
|
||||
cy.cs("navbar-studyProgramme").click()
|
||||
cy.cs("navbar-class").click()
|
||||
cy.cs("select-study-programme").click().children().contains(name).click()
|
||||
cy.contains("See population").click()
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
version: "3.2"
|
||||
services:
|
||||
cypress:
|
||||
image: "cypress/included:3.4.1"
|
||||
image: "cypress/included:3.8.3"
|
||||
environment:
|
||||
- CYPRESS_baseUrl=${CYPRESS_baseUrl}
|
||||
- CYPRESS_videoCompression=false
|
||||
@@ -11,3 +11,4 @@ services:
|
||||
- ./cypress.json:/e2e/cypress.json
|
||||
- ./node_modules:/node_modules
|
||||
container_name: cypress
|
||||
command: cypress run --browser chrome --headless
|
||||
|
||||
Generated
+59
-142
@@ -1060,89 +1060,24 @@
|
||||
"any-observable": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"@types/blob-util": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/blob-util/-/blob-util-1.3.3.tgz",
|
||||
"integrity": "sha512-4ahcL/QDnpjWA2Qs16ZMQif7HjGP2cw3AGjHabybjw7Vm1EKu+cfQN1D78BaZbS1WJNa1opSMF5HNMztx7lR0w==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/bluebird": {
|
||||
"version": "3.5.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.29.tgz",
|
||||
"integrity": "sha512-kmVtnxTuUuhCET669irqQmPAez4KFnFVKvpleVRyfC3g+SHD1hIkFZcWLim9BVcwUBLO59o8VZE4yGCmTif8Yw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/chai": {
|
||||
"version": "4.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.7.tgz",
|
||||
"integrity": "sha512-luq8meHGYwvky0O7u0eQZdA7B4Wd9owUCqvbw2m3XCrCU8mplYOujMBbvyS547AxJkC+pGnd0Cm15eNxEUNU8g==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/chai-jquery": {
|
||||
"version": "1.1.40",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai-jquery/-/chai-jquery-1.1.40.tgz",
|
||||
"integrity": "sha512-mCNEZ3GKP7T7kftKeIs7QmfZZQM7hslGSpYzKbOlR2a2HCFf9ph4nlMRA9UnuOETeOQYJVhJQK7MwGqNZVyUtQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/chai": "*",
|
||||
"@types/jquery": "*"
|
||||
}
|
||||
},
|
||||
"@types/color-name": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
|
||||
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/jquery": {
|
||||
"version": "3.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.3.31.tgz",
|
||||
"integrity": "sha512-Lz4BAJihoFw5nRzKvg4nawXPzutkv7wmfQ5121avptaSIXlDNJCUuxZxX/G+9EVidZGuO0UBlk+YjKbwRKJigg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/sizzle": "*"
|
||||
}
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.14.149",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz",
|
||||
"integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/minimatch": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
||||
"integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/mocha": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.7.tgz",
|
||||
"integrity": "sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/parse-json": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
|
||||
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/sinon": {
|
||||
"version": "7.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-7.5.1.tgz",
|
||||
"integrity": "sha512-EZQUP3hSZQyTQRfiLqelC9NMWd1kqLcmQE0dMiklxBkgi84T+cHOhnKpgk4NnOWpGX863yE6+IaGnOXUNFqDnQ==",
|
||||
"@types/sinonjs__fake-timers": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz",
|
||||
"integrity": "sha512-yYezQwGWty8ziyYLdZjwxyMb0CZR49h8JALHGrxjQHWlqGgc8kLdHEgWrgL0uZ29DMvEVBDnHU2Wg36zKSIUtA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/sinon-chai": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/sinon-chai/-/sinon-chai-3.2.3.tgz",
|
||||
"integrity": "sha512-TOUFS6vqS0PVL1I8NGVSNcFaNJtFoyZPXZ5zur+qlhDfOmQECZZM4H4kKgca6O8L+QceX/ymODZASfUfn+y4yQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/chai": "*",
|
||||
"@types/sinon": "*"
|
||||
}
|
||||
},
|
||||
"@types/sizzle": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz",
|
||||
@@ -1183,9 +1118,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"ajv": {
|
||||
"version": "6.12.2",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz",
|
||||
"integrity": "sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==",
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.3.tgz",
|
||||
"integrity": "sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
@@ -1240,9 +1175,9 @@
|
||||
}
|
||||
},
|
||||
"arch": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/arch/-/arch-2.1.1.tgz",
|
||||
"integrity": "sha512-BLM56aPo9vLLFVa8+/+pJLnrZ7QGGTVHWsCwieAWT9o9K8UeGaQbzZbGoabWLOo2ksBCztoXdqBZBplqLDDCSg==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/arch/-/arch-2.1.2.tgz",
|
||||
"integrity": "sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ==",
|
||||
"dev": true
|
||||
},
|
||||
"arr-diff": {
|
||||
@@ -1359,9 +1294,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"aws4": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz",
|
||||
"integrity": "sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.0.tgz",
|
||||
"integrity": "sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA==",
|
||||
"dev": true
|
||||
},
|
||||
"babel-plugin-add-module-exports": {
|
||||
@@ -2053,9 +1988,9 @@
|
||||
}
|
||||
},
|
||||
"commander": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.0.tgz",
|
||||
"integrity": "sha512-NIQrwvv9V39FHgGFm36+U9SMQzbiHvU79k+iADraJTpmrFFfx7Ds0IvDoAdZsDrknlkRk14OYoWXb57uTh7/sw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
|
||||
"dev": true
|
||||
},
|
||||
"common-tags": {
|
||||
@@ -2276,48 +2211,39 @@
|
||||
}
|
||||
},
|
||||
"cypress": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/cypress/-/cypress-4.5.0.tgz",
|
||||
"integrity": "sha512-2A4g5FW5d2fHzq8HKUGAMVTnW6P8nlWYQALiCoGN4bqBLvgwhYM/oG9oKc2CS6LnvgHFiKivKzpm9sfk3uU3zQ==",
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/cypress/-/cypress-4.11.0.tgz",
|
||||
"integrity": "sha512-6Yd598+KPATM+dU1Ig0g2hbA+R/o1MAKt0xIejw4nZBVLSplCouBzqeKve6XsxGU6n4HMSt/+QYsWfFcoQeSEw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@cypress/listr-verbose-renderer": "0.4.1",
|
||||
"@cypress/request": "2.88.5",
|
||||
"@cypress/xvfb": "1.2.4",
|
||||
"@types/blob-util": "1.3.3",
|
||||
"@types/bluebird": "3.5.29",
|
||||
"@types/chai": "4.2.7",
|
||||
"@types/chai-jquery": "1.1.40",
|
||||
"@types/jquery": "3.3.31",
|
||||
"@types/lodash": "4.14.149",
|
||||
"@types/minimatch": "3.0.3",
|
||||
"@types/mocha": "5.2.7",
|
||||
"@types/sinon": "7.5.1",
|
||||
"@types/sinon-chai": "3.2.3",
|
||||
"@types/sinonjs__fake-timers": "6.0.1",
|
||||
"@types/sizzle": "2.3.2",
|
||||
"arch": "2.1.1",
|
||||
"arch": "2.1.2",
|
||||
"bluebird": "3.7.2",
|
||||
"cachedir": "2.3.0",
|
||||
"chalk": "2.4.2",
|
||||
"check-more-types": "2.24.0",
|
||||
"cli-table3": "0.5.1",
|
||||
"commander": "4.1.0",
|
||||
"commander": "4.1.1",
|
||||
"common-tags": "1.8.0",
|
||||
"debug": "4.1.1",
|
||||
"eventemitter2": "4.1.2",
|
||||
"eventemitter2": "6.4.2",
|
||||
"execa": "1.0.0",
|
||||
"executable": "4.1.1",
|
||||
"extract-zip": "1.7.0",
|
||||
"fs-extra": "8.1.0",
|
||||
"getos": "3.1.4",
|
||||
"getos": "3.2.1",
|
||||
"is-ci": "2.0.0",
|
||||
"is-installed-globally": "0.1.0",
|
||||
"is-installed-globally": "0.3.2",
|
||||
"lazy-ass": "1.6.0",
|
||||
"listr": "0.14.3",
|
||||
"lodash": "4.17.15",
|
||||
"lodash": "4.17.19",
|
||||
"log-symbols": "3.0.0",
|
||||
"minimist": "1.2.5",
|
||||
"moment": "2.24.0",
|
||||
"moment": "2.26.0",
|
||||
"ospath": "1.2.2",
|
||||
"pretty-bytes": "5.3.0",
|
||||
"ramda": "0.26.1",
|
||||
@@ -2549,9 +2475,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"elliptic": {
|
||||
"version": "6.5.1",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.1.tgz",
|
||||
"integrity": "sha512-xvJINNLbTeWQjrl6X+7eQCrIy/YPv5XCpKW6kB5mKvtnGILoLDcySuwomfdzt0BMdLNVnuRNTuzKNHj0bva1Cg==",
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz",
|
||||
"integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"bn.js": "^4.4.0",
|
||||
@@ -2600,9 +2526,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"eventemitter2": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-4.1.2.tgz",
|
||||
"integrity": "sha1-DhqEd6+CGm7zmVsxG/dMI6UkfxU=",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.2.tgz",
|
||||
"integrity": "sha512-r/Pwupa5RIzxIHbEKCkNXqpEQIIT4uQDxmP4G/Lug/NokVUWj0joz/WzWl3OxRpC5kDrH/WdiUJoR+IrwvXJEw==",
|
||||
"dev": true
|
||||
},
|
||||
"events": {
|
||||
@@ -2870,9 +2796,9 @@
|
||||
}
|
||||
},
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz",
|
||||
"integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==",
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"fast-json-stable-stringify": {
|
||||
@@ -3584,12 +3510,12 @@
|
||||
"dev": true
|
||||
},
|
||||
"getos": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/getos/-/getos-3.1.4.tgz",
|
||||
"integrity": "sha512-UORPzguEB/7UG5hqiZai8f0vQ7hzynMQyJLxStoQ8dPGAcmgsfXOPA4iE/fGtweHYkK+z4zc9V0g+CIFRf5HYw==",
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz",
|
||||
"integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"async": "^3.1.0"
|
||||
"async": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"getpass": {
|
||||
@@ -3637,12 +3563,12 @@
|
||||
}
|
||||
},
|
||||
"global-dirs": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz",
|
||||
"integrity": "sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-2.0.1.tgz",
|
||||
"integrity": "sha512-5HqUqdhkEovj2Of/ms3IeS/EekcO54ytHRLV4PEY2rhRwrHXLQjeVEES0Lhka0xwNDtGYn58wyC4s5+MHsOO6A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ini": "^1.3.4"
|
||||
"ini": "^1.3.5"
|
||||
}
|
||||
},
|
||||
"globals": {
|
||||
@@ -4062,13 +3988,13 @@
|
||||
}
|
||||
},
|
||||
"is-installed-globally": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.1.0.tgz",
|
||||
"integrity": "sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=",
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz",
|
||||
"integrity": "sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"global-dirs": "^0.1.0",
|
||||
"is-path-inside": "^1.0.0"
|
||||
"global-dirs": "^2.0.1",
|
||||
"is-path-inside": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"is-number": {
|
||||
@@ -4101,13 +4027,10 @@
|
||||
}
|
||||
},
|
||||
"is-path-inside": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz",
|
||||
"integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"path-is-inside": "^1.0.1"
|
||||
}
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz",
|
||||
"integrity": "sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==",
|
||||
"dev": true
|
||||
},
|
||||
"is-plain-object": {
|
||||
"version": "2.0.4",
|
||||
@@ -4424,9 +4347,9 @@
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
|
||||
"version": "4.17.19",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
|
||||
"integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.clonedeep": {
|
||||
@@ -4698,9 +4621,9 @@
|
||||
}
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.24.0",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
|
||||
"integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==",
|
||||
"version": "2.26.0",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.26.0.tgz",
|
||||
"integrity": "sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw==",
|
||||
"dev": true
|
||||
},
|
||||
"ms": {
|
||||
@@ -5026,12 +4949,6 @@
|
||||
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
|
||||
"dev": true
|
||||
},
|
||||
"path-is-inside": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz",
|
||||
"integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=",
|
||||
"dev": true
|
||||
},
|
||||
"path-key": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@
|
||||
"homepage": "https://github.com/UniversityOfHelsinkiCS/oodikone#readme",
|
||||
"devDependencies": {
|
||||
"concurrently": "^5.2.0",
|
||||
"cypress": "^4.5.0",
|
||||
"cypress": "^4.11.0",
|
||||
"cypress-smokify": "0.0.3",
|
||||
"husky": "^4.2.5"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DIR_PATH=$(dirname $(dirname "$0"))
|
||||
SERVICES=(db_sis db)
|
||||
BACKUPS=$DIR_PATH/backups
|
||||
@@ -44,13 +46,14 @@ export -f retry
|
||||
export -f drop_create_populate
|
||||
mkdir -p backups
|
||||
|
||||
echo "Fetching latest staging backup data"
|
||||
echo "Fetching latest staging SIS data & OODI demo data"
|
||||
scp -r -o ProxyCommand="ssh -W %h:%p melkinpaasi.cs.helsinki.fi" oodikone-staging:/home/tkt_oodi/backups/\{sis-updater-staging.sqz,demo-anon-staging.sqz\} $BACKUPS
|
||||
|
||||
echo "Setting up services: ${SERVICES[@]}"
|
||||
npm run docker:down --prefix $DIR_PATH
|
||||
npm run docker:up --prefix $DIR_PATH -- ${SERVICES[@]}
|
||||
|
||||
echo "Populating DB_SIS and DB_ANON"
|
||||
echo $DB_SIS_ARGS $DB_ANON_ARGS | xargs -n 3 -P 2 sh -c 'drop_create_populate $1 $2 $3' sh
|
||||
|
||||
echo "Cleaning up"
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
docker exec -it db_sis pg_dump -aO -U postgres -d db_sis -t programme_modules -t programme_module_children > programme_module_dump.sql
|
||||
@@ -26,6 +26,7 @@ options=(
|
||||
"Reset real db"
|
||||
"Download latest anon db"
|
||||
"Download latest real db"
|
||||
"Setup SIS data"
|
||||
"Quit"
|
||||
)
|
||||
|
||||
@@ -56,6 +57,9 @@ while true; do
|
||||
"Download latest real db")
|
||||
get_oodikone_server_backup
|
||||
;;
|
||||
"Setup SIS data")
|
||||
./scripts/populate-db.sh
|
||||
;;
|
||||
"Quit")
|
||||
break 2
|
||||
;;
|
||||
|
||||
+4
-2
@@ -36,8 +36,10 @@ echo_path () {
|
||||
}
|
||||
|
||||
get_oodikone_server_backup() {
|
||||
scp -r -o ProxyCommand="ssh -W %h:%p melkinpaasi.cs.helsinki.fi" oodikone.cs.helsinki.fi:/home/tkt_oodi/backups/* "$BACKUP_DIR/"
|
||||
scp -r -o ProxyCommand="ssh -W %h:%p melkinpaasi.cs.helsinki.fi" svm-77.cs.helsinki.fi:/home/tkt_oodi/backups/* "$BACKUP_DIR/"
|
||||
echo "Enter your Uni Helsinki username:"
|
||||
read username
|
||||
scp -r -o ProxyCommand="ssh -l $username -W %h:%p melkinpaasi.cs.helsinki.fi" $username@oodikone.cs.helsinki.fi:/home/tkt_oodi/backups/* "$BACKUP_DIR/"
|
||||
scp -r -o ProxyCommand="ssh -l $username -W %h:%p melkinpaasi.cs.helsinki.fi" $username@svm-77.cs.helsinki.fi:/home/tkt_oodi/backups/* "$BACKUP_DIR/"
|
||||
}
|
||||
|
||||
get_anon_oodikone() {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
const { CronJob } = require('cron')
|
||||
const moment = require('moment')
|
||||
const { refreshAssociationsInRedis } = require('./services/studyrights')
|
||||
const {
|
||||
refreshProtoC,
|
||||
refreshStatus,
|
||||
refreshStatusGraduated,
|
||||
refreshUber,
|
||||
refreshProtoCProgramme,
|
||||
getStartYears
|
||||
} = require('./services/coolDataScience')
|
||||
const { refreshAssociationsInRedis: refreshAssociationsInRedisV2 } = require('./servicesV2/studyrights')
|
||||
const { getAllProgrammes, nonGraduatedStudentsOfElementDetail } = require('./services/studyrights')
|
||||
const {
|
||||
@@ -239,6 +248,77 @@ const refreshNonGraduatedStudentsOfOldProgrammesV2 = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const refreshProtoCtoRedis = async () => {
|
||||
try {
|
||||
const defaultQuery = { include_old_attainments: 'false', exclude_non_enrolled: 'false' }
|
||||
const onlyOld = { include_old_attainments: 'true', exclude_non_enrolled: 'false' }
|
||||
const onlyEnr = { include_old_attainments: 'false', exclude_non_enrolled: 'true' }
|
||||
const bothToggles = { include_old_attainments: 'true', exclude_non_enrolled: 'true' }
|
||||
console.log('Refreshing CDS ProtoC')
|
||||
await refreshProtoC(defaultQuery)
|
||||
await refreshProtoC(onlyOld)
|
||||
await refreshProtoC(onlyEnr)
|
||||
await refreshProtoC(bothToggles)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshStatusToRedis = async () => {
|
||||
try {
|
||||
const unixMillis = moment().valueOf()
|
||||
const date = new Date(Number(unixMillis))
|
||||
|
||||
date.setHours(23, 59, 59, 999)
|
||||
const showByYearOff = 'false'
|
||||
const showByYear = 'true'
|
||||
console.log('Refreshing CDS Status')
|
||||
await refreshStatus(date.getTime(), showByYearOff)
|
||||
await refreshStatus(date.getTime(), showByYear)
|
||||
|
||||
console.log('Refreshing CDS Graduated')
|
||||
await refreshStatusGraduated(date.getTime(), showByYearOff)
|
||||
await refreshStatusGraduated(date.getTime(), showByYear)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshUberToRedis = async () => {
|
||||
try {
|
||||
const years = await getStartYears()
|
||||
const mappedYears = years.map(({ studystartdate }) => studystartdate)
|
||||
mappedYears.forEach(async year => {
|
||||
console.log('Refreshing CDS Uber data for date', year)
|
||||
const defaultQuery = { include_old_attainments: 'false', start_date: year }
|
||||
const oldAttainmentsQuery = { include_old_attainments: 'true', start_date: year }
|
||||
await refreshUber(defaultQuery)
|
||||
await refreshUber(oldAttainmentsQuery)
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshProtoCProgrammeToRedis = async () => {
|
||||
try {
|
||||
const codes = (await getAllProgrammesV2()).map(p => p.code).filter(code => code.includes('KH'))
|
||||
codes.forEach(async code => {
|
||||
const defaultQuery = { include_old_attainments: 'false', exclude_non_enrolled: 'false', code }
|
||||
const onlyOld = { include_old_attainments: 'true', exclude_non_enrolled: 'false', code }
|
||||
const onlyEnr = { include_old_attainments: 'false', exclude_non_enrolled: 'true', code }
|
||||
const bothToggles = { include_old_attainments: 'true', exclude_non_enrolled: 'true', code }
|
||||
console.log('Refreshing CDS ProtoCProgramme for code ', code)
|
||||
await refreshProtoCProgramme(defaultQuery)
|
||||
await refreshProtoCProgramme(onlyOld)
|
||||
await refreshProtoCProgramme(onlyEnr)
|
||||
await refreshProtoCProgramme(bothToggles)
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshStatistics = async () => {
|
||||
await refreshFacultyYearlyStats()
|
||||
await refreshStudyrightAssociations()
|
||||
@@ -254,10 +334,18 @@ const refreshStatisticsV2 = async () => {
|
||||
await refreshTeacherLeaderboardV2()
|
||||
}
|
||||
|
||||
const refreshCDS = async () => {
|
||||
await refreshProtoCtoRedis()
|
||||
await refreshStatusToRedis()
|
||||
await refreshUberToRedis()
|
||||
await refreshProtoCProgrammeToRedis()
|
||||
}
|
||||
|
||||
const startCron = () => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
schedule('0 6 * * *', async () => {
|
||||
await refreshStatistics()
|
||||
await refreshCDS()
|
||||
})
|
||||
|
||||
if (process.env.TAG === 'staging') {
|
||||
@@ -271,5 +359,6 @@ const startCron = () => {
|
||||
module.exports = {
|
||||
startCron,
|
||||
refreshStatistics,
|
||||
refreshStatisticsV2
|
||||
refreshStatisticsV2,
|
||||
refreshCDS
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const logger = require('../util/logger')
|
||||
// so don't e.g. make it into an async function or it'll print [object Promise] on every request
|
||||
const accessLogger = morgan((tokens, req, res) => {
|
||||
const fields = ['method', 'url', 'status', 'response-time', 'remote-addr', 'remote-user', 'user-agent', 'referrer']
|
||||
const meta = req.decodedToken
|
||||
const meta = { ...req.decodedToken }
|
||||
fields.forEach(field => (meta[field] = tokens[field](req, res)))
|
||||
meta['time'] = tokens['date'](req, res, 'iso')
|
||||
if (req.route) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const { Model, INTEGER, STRING, DATE } = require('sequelize')
|
||||
const { dbConnections } = require('../databaseV2/connection')
|
||||
|
||||
class ExcludedCourse extends Model {}
|
||||
|
||||
ExcludedCourse.init(
|
||||
{
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: INTEGER,
|
||||
autoIncrement: true
|
||||
},
|
||||
programme_code: {
|
||||
type: STRING
|
||||
},
|
||||
course_code: {
|
||||
type: STRING
|
||||
},
|
||||
created_at: {
|
||||
type: DATE
|
||||
},
|
||||
updated_at: {
|
||||
type: DATE
|
||||
}
|
||||
},
|
||||
{
|
||||
underscored: true,
|
||||
sequelize: dbConnections.sequelize,
|
||||
modelName: 'excluded_course',
|
||||
tableName: 'excluded_courses'
|
||||
}
|
||||
)
|
||||
|
||||
module.exports = ExcludedCourse
|
||||
@@ -14,6 +14,9 @@ const StudyrightExtent = require('./studyrightExtent')
|
||||
const Studyright = require('./studyright')
|
||||
const StudyrightElement = require('./studyrightElement')
|
||||
const Transfer = require('./transfer')
|
||||
const ProgrammeModule = require('./programmeModule')
|
||||
const ProgrammeModuleChild = require('./programmeModuleChild')
|
||||
const ExcludedCourse = require('./excludedCourse')
|
||||
|
||||
CourseType.hasMany(Course, { foreignKey: 'coursetypecode', sourceKey: 'coursetypecode' })
|
||||
Course.belongsTo(CourseType, { foreignKey: 'coursetypecode', targetKey: 'coursetypecode' })
|
||||
@@ -82,6 +85,17 @@ Studyright.hasMany(Transfer, { foreignKey: 'studyrightid', sourceKey: 'studyrigh
|
||||
Transfer.belongsTo(ElementDetail, { as: 'source', foreignKey: 'sourcecode' })
|
||||
Transfer.belongsTo(ElementDetail, { as: 'target', foreignKey: 'targetcode' })
|
||||
|
||||
ProgrammeModule.belongsToMany(ProgrammeModule, {
|
||||
as: 'parents',
|
||||
through: ProgrammeModuleChild,
|
||||
foreignKey: 'child_id'
|
||||
})
|
||||
ProgrammeModule.belongsToMany(ProgrammeModule, {
|
||||
as: 'children',
|
||||
through: ProgrammeModuleChild,
|
||||
foreignKey: 'parent_id'
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
Organization,
|
||||
Course,
|
||||
@@ -98,5 +112,8 @@ module.exports = {
|
||||
StudyrightExtent,
|
||||
Studyright,
|
||||
StudyrightElement,
|
||||
Transfer
|
||||
Transfer,
|
||||
ProgrammeModule,
|
||||
ProgrammeModuleChild,
|
||||
ExcludedCourse
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const { Model, STRING, DATE, JSONB } = require('sequelize')
|
||||
const { dbConnections } = require('../databaseV2/connection')
|
||||
|
||||
class ProgrammeModule extends Model {}
|
||||
|
||||
ProgrammeModule.init(
|
||||
{
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: STRING
|
||||
},
|
||||
code: {
|
||||
type: STRING
|
||||
},
|
||||
name: {
|
||||
type: JSONB
|
||||
},
|
||||
type: {
|
||||
type: STRING
|
||||
},
|
||||
createdAt: {
|
||||
type: DATE
|
||||
},
|
||||
updatedAt: {
|
||||
type: DATE
|
||||
}
|
||||
},
|
||||
{
|
||||
underscored: true,
|
||||
sequelize: dbConnections.sequelize,
|
||||
modelName: 'programme_module',
|
||||
tableName: 'programme_modules'
|
||||
}
|
||||
)
|
||||
|
||||
module.exports = ProgrammeModule
|
||||
@@ -0,0 +1,37 @@
|
||||
const { Model, STRING, DATE } = require('sequelize')
|
||||
const { dbConnections } = require('../databaseV2/connection')
|
||||
|
||||
class ProgrammeModuleChild extends Model {}
|
||||
|
||||
ProgrammeModuleChild.init(
|
||||
{
|
||||
parent_id: {
|
||||
type: STRING,
|
||||
references: {
|
||||
model: 'programme_modules',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
child_id: {
|
||||
type: STRING,
|
||||
references: {
|
||||
model: 'programme_modules',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
createdAt: {
|
||||
type: DATE
|
||||
},
|
||||
updatedAt: {
|
||||
type: DATE
|
||||
}
|
||||
},
|
||||
{
|
||||
underscored: true,
|
||||
sequelize: dbConnections.sequelize,
|
||||
modelName: 'programme_module_child',
|
||||
tableName: 'programme_module_children'
|
||||
}
|
||||
)
|
||||
|
||||
module.exports = ProgrammeModuleChild
|
||||
@@ -30,6 +30,7 @@ const tsaAnalytics = require('./routes/tsaAnalytics')
|
||||
const matomoInit = require('./routes/matomo-init')
|
||||
const customPopulationSearch = require('./routes/customPopulationSearch')
|
||||
const coolDataScience = require('./routes/coolDataScience')
|
||||
const programmeModules = require('./routes/programmeModules')
|
||||
|
||||
module.exports = (app, url) => {
|
||||
app.use(url, ping)
|
||||
@@ -47,6 +48,7 @@ module.exports = (app, url) => {
|
||||
app.use(url, providers)
|
||||
app.use(url, semesters)
|
||||
app.use(url, tags)
|
||||
app.use(url, programmeModules)
|
||||
app.use(`${url}/faculties`, faculties)
|
||||
app.use(`${url}/facultystats`, auth.roles(['faculties']), facultyStats)
|
||||
app.use(`${url}/updater`, auth.roles(['admin']), updater)
|
||||
|
||||
@@ -1,294 +1,59 @@
|
||||
const router = require('express').Router()
|
||||
const _ = require('lodash')
|
||||
const { sequelize } = require('../database/connection')
|
||||
|
||||
const {
|
||||
withErr,
|
||||
mankeliUberData,
|
||||
getTargetStudentCounts,
|
||||
get3yStudentsWithDrilldownPerYear,
|
||||
getStatusStatistics,
|
||||
sorters,
|
||||
getUberData
|
||||
getProtoC,
|
||||
getProtoCProgramme,
|
||||
getStatus,
|
||||
getUber,
|
||||
getStartYears,
|
||||
getGraduatedStatus
|
||||
} = require('../services/coolDataScience')
|
||||
const { getAssociations } = require('../services/studyrights')
|
||||
|
||||
const STUDYRIGHT_START_DATE = '2017-07-31 21:00:00+00'
|
||||
|
||||
router.get('/start-years', async (req, res) => {
|
||||
const years = await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
DISTINCT studyright.studystartdate
|
||||
FROM organization org
|
||||
INNER JOIN studyright
|
||||
ON studyright.faculty_code = org.code
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
WHERE
|
||||
studyright.extentcode = 1
|
||||
AND studyright.studystartdate >= :startDate
|
||||
AND transfers.studyrightid IS NULL
|
||||
ORDER BY 1
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: { startDate: STUDYRIGHT_START_DATE }
|
||||
}
|
||||
)
|
||||
const years = await getStartYears()
|
||||
res.json(years.map(({ studystartdate }) => studystartdate))
|
||||
})
|
||||
|
||||
// mankel data from:
|
||||
/*
|
||||
[
|
||||
{
|
||||
checkpoint: "2017-11-30T13:00:00.000Z",
|
||||
orgCode: "H10",
|
||||
orgName: "Teologinen tiedekunta",
|
||||
programmeCode: "KH10_001",
|
||||
programmeName: "Teologian ja uskonnontutkimuksen kandiohjelma",
|
||||
programmeTotalStudents: "120",
|
||||
students3y: "9"
|
||||
students4y: "10"
|
||||
},
|
||||
...
|
||||
]
|
||||
*/
|
||||
// into:
|
||||
/*
|
||||
{
|
||||
H10: {
|
||||
name: "Teologinen tiedekunta",
|
||||
code: "H10",
|
||||
// snapshots of faculty level total & target numbers
|
||||
snapshots: [
|
||||
{
|
||||
date: '2017-11-30T13:00:00.000Z',
|
||||
totalStudents: 340, // sum of programme total students at this checkpoint
|
||||
students3y: 43, // ^^^ of programme 3y target students
|
||||
students4y: 55
|
||||
},
|
||||
...
|
||||
],
|
||||
// snapshots of programme level total & target numbers, grouped by programme
|
||||
programmes: [
|
||||
{
|
||||
code: 'KH10_001',
|
||||
name: 'Teologian ja uskonnontutkimuksen kandiohjelma',
|
||||
snapshots: [
|
||||
{
|
||||
date: '2017-11-30T13:00:00.000Z',
|
||||
totalStudents: 120,
|
||||
students3y: 9,
|
||||
students4y: 10
|
||||
},
|
||||
...
|
||||
},
|
||||
...
|
||||
]
|
||||
},
|
||||
...
|
||||
}
|
||||
*/
|
||||
|
||||
router.get(
|
||||
'/uber-data',
|
||||
withErr(async (req, res) => {
|
||||
const data = await getUberData({
|
||||
startDate: new Date(req.query.start_date),
|
||||
includeOldAttainments: req.query.include_old_attainments === 'true'
|
||||
})
|
||||
const mankeld = mankeliUberData(data)
|
||||
res.json(mankeld)
|
||||
const data = await getUber(req.query)
|
||||
res.json(data)
|
||||
})
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/proto-c-data-programme',
|
||||
withErr(async (req, res) => {
|
||||
const associations = await getAssociations()
|
||||
const codes = associations.programmes[req.query.code]
|
||||
? [...associations.programmes[req.query.code].studytracks, req.query.code]
|
||||
: []
|
||||
const data = await getTargetStudentCounts({
|
||||
codes: codes,
|
||||
includeOldAttainments: req.query.include_old_attainments === 'true',
|
||||
excludeNonEnrolled: req.query.exclude_non_enrolled === 'true'
|
||||
})
|
||||
const programmeData = data.find(d => d.programmeType === 20)
|
||||
const studytrackData = data.filter(d => d.programmeType !== 20)
|
||||
|
||||
// mankel through studytracks
|
||||
const studytrackMankelid = _(studytrackData)
|
||||
// seems to return the numerical columns as strings, parse them first
|
||||
.map(programmeRow => ({
|
||||
...programmeRow,
|
||||
programmeTotalStudents: parseInt(programmeRow.programmeTotalStudents, 10),
|
||||
students3y: parseInt(programmeRow.students3y, 10),
|
||||
// 4y group includes 3y group, make 4y count exclusive:
|
||||
students4y: parseInt(programmeRow.students4y, 10) - parseInt(programmeRow.students3y, 10),
|
||||
currentlyCancelled: parseInt(programmeRow.currentlyCancelled, 10)
|
||||
}))
|
||||
.value()
|
||||
|
||||
// associate studytracks with bachelor programme
|
||||
const studytrackToBachelorProgrammes = Object.keys(associations.programmes).reduce((acc, curr) => {
|
||||
if (!curr.includes('KH')) return acc
|
||||
const studytracksForProgramme = studytrackMankelid.reduce((acc2, studytrackdata) => {
|
||||
if (associations.programmes[curr].studytracks.includes(studytrackdata.programmeCode)) {
|
||||
acc2.push({
|
||||
code: studytrackdata.programmeCode,
|
||||
name: studytrackdata.programmeName,
|
||||
totalStudents: studytrackdata.programmeTotalStudents,
|
||||
students3y: studytrackdata.students3y,
|
||||
students4y: studytrackdata.students4y,
|
||||
currentlyCancelled: studytrackdata.currentlyCancelled
|
||||
})
|
||||
}
|
||||
return acc2
|
||||
}, [])
|
||||
if (studytracksForProgramme) acc[curr] = studytracksForProgramme
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// combine studytracks data to programme data
|
||||
|
||||
const programmeDataMankeld = {
|
||||
code: programmeData.programmeCode,
|
||||
name: programmeData.programmeName,
|
||||
totalStudents: parseInt(programmeData.programmeTotalStudents, 10),
|
||||
students3y: parseInt(programmeData.students3y, 10),
|
||||
// 4y group includes 3y group, make 4y count exclusive:
|
||||
students4y: parseInt(programmeData.students4y, 10) - parseInt(programmeData.students3y, 10),
|
||||
currentlyCancelled: parseInt(programmeData.currentlyCancelled, 10),
|
||||
studytracks: studytrackToBachelorProgrammes[programmeData.programmeCode]
|
||||
}
|
||||
res.json(programmeDataMankeld)
|
||||
const data = await getProtoCProgramme(req.query)
|
||||
res.json(data)
|
||||
})
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/proto-c-data',
|
||||
withErr(async (req, res) => {
|
||||
const associations = await getAssociations()
|
||||
|
||||
const data = await getTargetStudentCounts({
|
||||
includeOldAttainments: req.query.include_old_attainments === 'true',
|
||||
excludeNonEnrolled: req.query.exclude_non_enrolled === 'true'
|
||||
})
|
||||
|
||||
const programmeData = data.filter(d => d.programmeType !== 30)
|
||||
const studytrackData = data.filter(d => d.programmeType !== 20)
|
||||
|
||||
// mankel through studytracks
|
||||
const studytrackMankelid = _(studytrackData)
|
||||
// seems to return the numerical columns as strings, parse them first
|
||||
.map(programmeRow => ({
|
||||
...programmeRow,
|
||||
programmeTotalStudents: parseInt(programmeRow.programmeTotalStudents, 10),
|
||||
students3y: parseInt(programmeRow.students3y, 10),
|
||||
// 4y group includes 3y group, make 4y count exclusive:
|
||||
students4y: parseInt(programmeRow.students4y, 10) - parseInt(programmeRow.students3y, 10),
|
||||
currentlyCancelled: parseInt(programmeRow.currentlyCancelled, 10)
|
||||
}))
|
||||
.value()
|
||||
|
||||
// associate studytracks with bachelor programme
|
||||
const studytrackToBachelorProgrammes = Object.keys(associations.programmes).reduce((acc, curr) => {
|
||||
if (!curr.includes('KH')) return acc
|
||||
const studytracksForProgramme = studytrackMankelid.reduce((acc2, studytrackdata) => {
|
||||
if (associations.programmes[curr].studytracks.includes(studytrackdata.programmeCode)) {
|
||||
acc2.push({
|
||||
code: studytrackdata.programmeCode,
|
||||
name: studytrackdata.programmeName,
|
||||
totalStudents: studytrackdata.programmeTotalStudents,
|
||||
students3y: studytrackdata.students3y,
|
||||
students4y: studytrackdata.students4y,
|
||||
currentlyCancelled: studytrackdata.currentlyCancelled
|
||||
})
|
||||
}
|
||||
return acc2
|
||||
}, [])
|
||||
if (studytracksForProgramme) acc[curr] = studytracksForProgramme
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// combine studytracks data to programme data
|
||||
const newmankelid = _(programmeData)
|
||||
// seems to return the numerical columns as strings, parse them first
|
||||
.map(programmeRow => ({
|
||||
...programmeRow,
|
||||
programmeTotalStudents: parseInt(programmeRow.programmeTotalStudents, 10),
|
||||
students3y: parseInt(programmeRow.students3y, 10),
|
||||
// 4y group includes 3y group, make 4y count exclusive:
|
||||
students4y: parseInt(programmeRow.students4y, 10) - parseInt(programmeRow.students3y, 10),
|
||||
currentlyCancelled: parseInt(programmeRow.currentlyCancelled, 10)
|
||||
}))
|
||||
.groupBy(r => r.orgCode)
|
||||
.mapValues(rows => ({
|
||||
// all of these rows have the same orgCode and orgName, just pick it from the first
|
||||
code: rows[0].orgCode,
|
||||
name: rows[0].orgName,
|
||||
totalStudents: _.sumBy(rows, row => row.programmeTotalStudents),
|
||||
students3y: _.sumBy(rows, row => row.students3y),
|
||||
students4y: _.sumBy(rows, row => row.students4y),
|
||||
currentlyCancelled: _.sumBy(rows, row => row.currentlyCancelled),
|
||||
programmes: rows.map(
|
||||
({
|
||||
programmeCode: code,
|
||||
programmeName: name,
|
||||
programmeTotalStudents: totalStudents,
|
||||
students3y,
|
||||
students4y,
|
||||
currentlyCancelled
|
||||
}) => ({
|
||||
code,
|
||||
name,
|
||||
totalStudents,
|
||||
students3y,
|
||||
students4y,
|
||||
currentlyCancelled,
|
||||
studytracks: studytrackToBachelorProgrammes[code]
|
||||
})
|
||||
)
|
||||
}))
|
||||
.value()
|
||||
|
||||
res.json(newmankelid)
|
||||
const data = await getProtoC(req.query)
|
||||
res.json(data)
|
||||
})
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/3y-students',
|
||||
'/status-graduated',
|
||||
withErr(async (req, res) => {
|
||||
const { startDate, sort } = req.query
|
||||
const shouldSort = Object.keys(sorters).includes(sort)
|
||||
const { date: unixMillis, showByYear } = req.query
|
||||
const date = new Date(Number(unixMillis))
|
||||
const endOfToday = new Date()
|
||||
endOfToday.setHours(23, 59, 59, 999)
|
||||
|
||||
const rawData = await get3yStudentsWithDrilldownPerYear(startDate)
|
||||
const byOrganization = rawData.reduce((acc, val) => {
|
||||
const programmeTotalStudents = parseInt(val.programmeTotalStudents, 10)
|
||||
const targetStudents = parseInt(val.targetStudents, 10)
|
||||
if (isNaN(date.getTime()) || date.getTime() > endOfToday.getTime()) {
|
||||
return res.status(400).json({ error: 'Invalid date' })
|
||||
}
|
||||
|
||||
const obj = acc[val.orgCode] || {}
|
||||
obj.code = val.orgCode
|
||||
obj.name = val.orgName
|
||||
obj.programmes = obj.programmes || []
|
||||
obj.programmes.push({
|
||||
name: val.programmeName,
|
||||
totalStudents: programmeTotalStudents,
|
||||
targetStudents: targetStudents
|
||||
})
|
||||
obj.totalStudents = (obj.totalStudents || 0) + programmeTotalStudents
|
||||
obj.targetStudents = (obj.targetStudents || 0) + targetStudents
|
||||
acc[val.orgCode] = obj
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const data = Object.values(byOrganization)
|
||||
res.json(
|
||||
shouldSort ? data.map(d => ({ ...d, programmes: d.programmes.sort(sorters[sort]) })).sort(sorters[sort]) : data
|
||||
)
|
||||
// End of day
|
||||
date.setHours(23, 59, 59, 999)
|
||||
const data = await getGraduatedStatus(date.getTime(), showByYear)
|
||||
res.json(data)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -306,7 +71,7 @@ router.get(
|
||||
|
||||
// End of day
|
||||
date.setHours(23, 59, 59, 999)
|
||||
const status = await getStatusStatistics(date.getTime(), showByYear)
|
||||
const status = await getStatus(date.getTime(), showByYear)
|
||||
res.json(status)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const router = require('express').Router()
|
||||
const {
|
||||
byProgrammeCode,
|
||||
addExcludedCourses,
|
||||
removeExcludedCourses,
|
||||
modulesByProgrammeCode
|
||||
} = require('../servicesV2/programmeModules')
|
||||
|
||||
router.get('/v3/programme_modules/:code', async (req, res) => {
|
||||
const { code } = req.params
|
||||
const module = await byProgrammeCode(code)
|
||||
res.json(module)
|
||||
})
|
||||
|
||||
router.get('/v3/programme_modules/:code/modules', async (req, res) => {
|
||||
const { code } = req.params
|
||||
const result = await modulesByProgrammeCode(code)
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.delete('/v3/programme_modules', async (req, res) => {
|
||||
const { programmecode, ids } = req.body
|
||||
try {
|
||||
await removeExcludedCourses(ids)
|
||||
const result = await byProgrammeCode(programmecode)
|
||||
res.json(result)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
res.json(500).json(e)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/v3/programme_modules/:programmecode/', async (req, res) => {
|
||||
const { programmecode, coursecodes } = req.body
|
||||
try {
|
||||
await addExcludedCourses(programmecode, coursecodes)
|
||||
const result = await byProgrammeCode(programmecode)
|
||||
res.json(result)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
res.json(500).json(e)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -12,7 +12,7 @@ const {
|
||||
updateNoStudents,
|
||||
updateDaily
|
||||
} = require('../services/updaterService')
|
||||
const { updateSISMetadata, updateSISStudents } = require('../services/sisUpdaterService')
|
||||
const { updateSISMetadata, updateSISStudents, updateSISProgrammes } = require('../services/sisUpdaterService')
|
||||
const { refreshStatistics, refreshStatisticsV2 } = require('../events')
|
||||
|
||||
router.post('/update/oldest', async (req, res) => {
|
||||
@@ -180,6 +180,21 @@ router.get('/update/v2/students', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/update/v2/programmes', async (req, res) => {
|
||||
const { roles } = req
|
||||
if (!roles.includes('dev')) return res.status(403).send('No rights, please stop')
|
||||
|
||||
try {
|
||||
const response = await updateSISProgrammes()
|
||||
if (response) {
|
||||
res.status(200).json('Update SIS programmes scheduled')
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
res.status(500).json({ error: 'error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/refresh_statistic_v2', async (req, res) => {
|
||||
try {
|
||||
refreshStatisticsV2()
|
||||
|
||||
@@ -4,29 +4,25 @@ const { ElementDetails, Organisation } = require('../models')
|
||||
const { sequelize } = require('../database/connection')
|
||||
const { mapToProviders } = require('../util/utils')
|
||||
const { USERSERVICE_URL } = require('../conf-backend')
|
||||
const { getAssociations } = require('../services/studyrights')
|
||||
const { redisClient } = require('./redis')
|
||||
const userServiceClient = axios.create({
|
||||
baseURL: USERSERVICE_URL,
|
||||
headers: { secret: process.env.USERSERVICE_SECRET }
|
||||
})
|
||||
|
||||
const targetCreditsForStartDate = startDate => {
|
||||
const year = new Date(startDate)
|
||||
switch (year.getFullYear()) {
|
||||
case 2017:
|
||||
return 180
|
||||
case 2018:
|
||||
return 120
|
||||
case 2019:
|
||||
return 60
|
||||
default:
|
||||
throw new Error('wtf')
|
||||
}
|
||||
}
|
||||
const STUDYRIGHT_START_DATE = '2017-07-31 21:00:00+00'
|
||||
const CURRENT_DATE = new Date()
|
||||
|
||||
const getTargetStudentCounts = _.memoize(
|
||||
async ({ codes, includeOldAttainments, excludeNonEnrolled }) => {
|
||||
return await sequelize.query(
|
||||
`
|
||||
const REDIS_KEY_PROTOC = 'PROTOC_DATA'
|
||||
const REDIS_KEY_PROTOC_PROGRAMME = 'PROTOC_PROGRAMME_DATA'
|
||||
const REDIS_KEY_STATUS = 'STATUS_DATA'
|
||||
const REDIS_KEY_UBER = 'UBER_DATA'
|
||||
const REDIS_KEY_GRADUATED = 'GRADUATED_DATA'
|
||||
|
||||
const getTargetStudentCounts = async ({ codes, includeOldAttainments, excludeNonEnrolled }) => {
|
||||
return await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
ss.org_code "orgCode",
|
||||
ss.org_name "orgName",
|
||||
@@ -164,100 +160,15 @@ const getTargetStudentCounts = _.memoize(
|
||||
GROUP BY (1,2), (3,4) ,5 , 6, 7
|
||||
) ss
|
||||
GROUP BY (1, 2), (3, 4), 5;
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: {
|
||||
codes: codes
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
args => JSON.stringify(args)
|
||||
)
|
||||
|
||||
const get3yStudentsWithDrilldownPerYear = _.memoize(async startDate => {
|
||||
return await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
ss.org_code "orgCode",
|
||||
ss.org_name "orgName",
|
||||
ss.programme_code "programmeCode",
|
||||
ss.programme_name "programmeName",
|
||||
COUNT(ss.studentnumber) "programmeTotalStudents",
|
||||
SUM(
|
||||
public.is_in_target(
|
||||
CURRENT_TIMESTAMP,
|
||||
ss.studystartdate,
|
||||
next_date_occurrence(ss.studystartdate),
|
||||
ss.credits,
|
||||
:targetCredits
|
||||
)
|
||||
) "targetStudents"
|
||||
FROM (
|
||||
SELECT
|
||||
org_code,
|
||||
org_name,
|
||||
programme_code,
|
||||
programme_name,
|
||||
studystartdate,
|
||||
studentnumber,
|
||||
SUM(credits) credits
|
||||
FROM (
|
||||
SELECT
|
||||
org.code org_code,
|
||||
org.name->>'fi' org_name,
|
||||
element_details.code programme_code,
|
||||
element_details.name->>'fi' programme_name,
|
||||
studyright.studystartdate studystartdate,
|
||||
studyright.student_studentnumber studentnumber,
|
||||
credit.credits credits
|
||||
FROM
|
||||
organization org
|
||||
INNER JOIN studyright
|
||||
ON org.code = studyright.faculty_code
|
||||
INNER JOIN (
|
||||
-- HACK: fix updater writing duplicates where enddate has changed
|
||||
SELECT DISTINCT ON (studyrightid, startdate, code, studentnumber)
|
||||
*
|
||||
FROM studyright_elements
|
||||
) s_elements
|
||||
ON studyright.studyrightid = s_elements.studyrightid
|
||||
INNER JOIN element_details
|
||||
ON s_elements.code = element_details.code
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
student_studentnumber,
|
||||
attainment_date,
|
||||
credits
|
||||
FROM credit
|
||||
WHERE credit.credittypecode IN (4, 9) -- Completed or Transferred
|
||||
AND credit."isStudyModule" = false
|
||||
AND credit.attainment_date >= :startDate
|
||||
) credit
|
||||
ON credit.student_studentnumber = studyright.student_studentnumber
|
||||
WHERE
|
||||
studyright.extentcode = 1 -- Bachelor's
|
||||
AND studyright.prioritycode IN (1, 30) -- Primary or Graduated
|
||||
AND studyright.studystartdate = :startDate
|
||||
AND element_details.type = 20 -- Programme's name
|
||||
AND transfers.studyrightid IS NULL -- Not transferred within faculty
|
||||
) s
|
||||
GROUP BY (1,2), (3,4), 5, 6
|
||||
) ss
|
||||
GROUP BY (1, 2), (3, 4);
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: {
|
||||
startDate: startDate,
|
||||
targetCredits: targetCreditsForStartDate(startDate)
|
||||
codes: codes
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const isSameDateIgnoringYear = (a, b) =>
|
||||
a.getUTCMonth() === b.getUTCMonth() &&
|
||||
@@ -305,12 +216,11 @@ const makeCheckpoints = startDate => {
|
||||
return checkpoints
|
||||
}
|
||||
|
||||
const getUberData = _.memoize(
|
||||
async ({ startDate, includeOldAttainments }) => {
|
||||
const checkpoints = makeCheckpoints(startDate)
|
||||
const getUberData = async ({ startDate, includeOldAttainments }) => {
|
||||
const checkpoints = makeCheckpoints(startDate)
|
||||
|
||||
return sequelize.query(
|
||||
`
|
||||
return sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
cp "checkpoint",
|
||||
ss.org_code "orgCode",
|
||||
@@ -397,19 +307,11 @@ const getUberData = _.memoize(
|
||||
) ss
|
||||
GROUP BY 1, (2, 3), (4, 5);
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
bind: [checkpoints, startDate]
|
||||
}
|
||||
)
|
||||
},
|
||||
({ startDate, includeOldAttainments }) => `${startDate.toISOString()}-${includeOldAttainments}`
|
||||
)
|
||||
|
||||
const sorters = {
|
||||
target: (a, b) => a.targetStudents - b.targetStudents,
|
||||
total: (a, b) => a.totalStudents - b.totalStudents,
|
||||
targetRelative: (a, b) => a.targetStudents / a.totalStudents - b.targetStudents / b.totalStudents
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
bind: [checkpoints, startDate]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const withErr = handler => (req, res, next) =>
|
||||
@@ -494,11 +396,13 @@ const getCurrentYearStartDate = () => {
|
||||
return new Date(new Date().getFullYear(), 0, 1)
|
||||
}
|
||||
|
||||
const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum') => {
|
||||
const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum', alias2 = 'sum2') => {
|
||||
return sequelize.query(
|
||||
`
|
||||
SELECT SUM(cr.credits) AS ` +
|
||||
alias + // HAX, alias doesn't come from user so no sql injection
|
||||
`,COUNT(DISTINCT(cr.student_studentnumber)) AS ` +
|
||||
alias2 +
|
||||
`, cp.providercode, co.code, co.name FROM credit cr
|
||||
INNER JOIN course co ON cr.course_code = co.code
|
||||
INNER JOIN course_providers cp ON cp.coursecode = co.code
|
||||
@@ -516,6 +420,25 @@ const getTotalCreditsOfCoursesBetween = async (a, b, alias = 'sum') => {
|
||||
)
|
||||
}
|
||||
|
||||
const getGraduatedBetween = async (start, end) => {
|
||||
return sequelize.query(
|
||||
`
|
||||
select studyright.faculty_code, s.code, count(distinct(s.code, s.studentnumber)) as sum
|
||||
from studyright_elements s
|
||||
inner join studyright on s.studyrightid = studyright.studyrightid
|
||||
where (s.code ILIKE 'MH%' or s.code ILIKE 'KH%')
|
||||
and studyright.graduated = '1'
|
||||
and studyright.enddate between :start and :end
|
||||
group by s.code, studyright.faculty_code
|
||||
order by studyright.faculty_code
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: { start, end }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const sumMerge = (a, b) => {
|
||||
if (!a) return b
|
||||
if (!b) return a
|
||||
@@ -528,13 +451,13 @@ const mergele = (a, b) => {
|
||||
return _.mergeWith(a, b, sumMerge)
|
||||
}
|
||||
|
||||
const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum') => {
|
||||
const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum', alias2 = 'sum2') => {
|
||||
return years.map(
|
||||
year =>
|
||||
new Promise(async res => {
|
||||
const diff = currentYear - year
|
||||
const { from, to } = getRange(diff)
|
||||
const creditsByCourse = await getTotalCreditsOfCoursesBetween(from, to, alias)
|
||||
const creditsByCourse = await getTotalCreditsOfCoursesBetween(from, to, alias, alias2)
|
||||
res(
|
||||
creditsByCourse.map(c => {
|
||||
c['year'] = year
|
||||
@@ -545,148 +468,621 @@ const makeYearlyCreditsPromises = (currentYear, years, getRange, alias = 'sum')
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusStatistics = _.memoize(
|
||||
async (unixMillis, showByYear) => {
|
||||
const Y_TO_MS = 31556952000
|
||||
/* Memoize parses booleans into strings... */
|
||||
const startDate = showByYear === 'true' ? getCurrentYearStartDate() : await getCurrentStudyYearStartDate(unixMillis)
|
||||
const startYear = startDate.getFullYear()
|
||||
const startTime = startDate.getTime()
|
||||
const calculateStatusStatistics = async (unixMillis, showByYear) => {
|
||||
const Y_TO_MS = 31556952000
|
||||
/* Memoize parses booleans into strings... */
|
||||
const startDate = showByYear === 'true' ? getCurrentYearStartDate() : await getCurrentStudyYearStartDate(unixMillis)
|
||||
const startYear = startDate.getFullYear()
|
||||
const startTime = startDate.getTime()
|
||||
const yearRange = _.range(2017, startYear + 1)
|
||||
const yearlyAccCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange,
|
||||
diff => ({
|
||||
from: new Date(startTime - diff * Y_TO_MS),
|
||||
to: new Date(unixMillis - diff * Y_TO_MS)
|
||||
}),
|
||||
'acc',
|
||||
'students'
|
||||
)
|
||||
|
||||
const yearRange = _.range(2017, startYear + 1)
|
||||
const yearlyAccCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange,
|
||||
diff => ({
|
||||
from: new Date(startTime - diff * Y_TO_MS),
|
||||
to: new Date(unixMillis - diff * Y_TO_MS)
|
||||
}),
|
||||
'acc'
|
||||
)
|
||||
const yearlyTotalCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange.slice(0, -1),
|
||||
diff => ({
|
||||
from: new Date(startTime - diff * Y_TO_MS),
|
||||
to: new Date(startTime - (diff - 1) * Y_TO_MS)
|
||||
}),
|
||||
'total',
|
||||
'students'
|
||||
)
|
||||
|
||||
const yearlyTotalCreditsPromises = makeYearlyCreditsPromises(
|
||||
startYear,
|
||||
yearRange.slice(0, -1),
|
||||
diff => ({
|
||||
from: new Date(startTime - diff * Y_TO_MS),
|
||||
to: new Date(startTime - (diff - 1) * Y_TO_MS)
|
||||
}),
|
||||
'total'
|
||||
)
|
||||
/* Gather all required data */
|
||||
const [
|
||||
yearlyAccCredits,
|
||||
yearlyTotalCredits,
|
||||
elementDetails,
|
||||
faculties,
|
||||
{ data: facultyProgrammes }
|
||||
] = await Promise.all([
|
||||
Promise.all(yearlyAccCreditsPromises),
|
||||
Promise.all(yearlyTotalCreditsPromises),
|
||||
ElementDetails.findAll(),
|
||||
Organisation.findAll(),
|
||||
userServiceClient.get('/faculty_programmes')
|
||||
])
|
||||
|
||||
/* Gather all required data */
|
||||
const [
|
||||
yearlyAccCredits,
|
||||
yearlyTotalCredits,
|
||||
elementDetails,
|
||||
faculties,
|
||||
{ data: facultyProgrammes }
|
||||
] = await Promise.all([
|
||||
Promise.all(yearlyAccCreditsPromises),
|
||||
Promise.all(yearlyTotalCreditsPromises),
|
||||
ElementDetails.findAll(),
|
||||
Organisation.findAll(),
|
||||
userServiceClient.get('/faculty_programmes')
|
||||
])
|
||||
/* Construct some helper maps */
|
||||
const facultyCodeToFaculty = faculties.reduce((res, curr) => {
|
||||
res[curr.code] = curr
|
||||
return res
|
||||
}, {})
|
||||
|
||||
/* Construct some helper maps */
|
||||
const facultyCodeToFaculty = faculties.reduce((res, curr) => {
|
||||
res[curr.code] = curr
|
||||
return res
|
||||
}, {})
|
||||
const programmeToFaculties = facultyProgrammes.reduce((res, curr) => {
|
||||
if (!res[curr.programme_code]) res[curr.programme_code] = []
|
||||
res[curr.programme_code].push(curr.faculty_code)
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const programmeToFaculties = facultyProgrammes.reduce((res, curr) => {
|
||||
if (!res[curr.programme_code]) res[curr.programme_code] = []
|
||||
res[curr.programme_code].push(curr.faculty_code)
|
||||
return res
|
||||
}, {})
|
||||
const providerToProgramme = elementDetails.reduce((res, curr) => {
|
||||
const [p] = mapToProviders([curr.code])
|
||||
res[p] = {
|
||||
code: curr.code,
|
||||
name: curr.name
|
||||
}
|
||||
return res
|
||||
}, {})
|
||||
|
||||
const providerToProgramme = elementDetails.reduce((res, curr) => {
|
||||
const [p] = mapToProviders([curr.code])
|
||||
res[p] = {
|
||||
code: curr.code,
|
||||
name: curr.name
|
||||
}
|
||||
return res
|
||||
}, {})
|
||||
/* Calculate course level stats and group by providers */
|
||||
const coursesGroupedByProvider = Object.entries(
|
||||
_.groupBy([..._.flatten(yearlyAccCredits), ..._.flatten(yearlyTotalCredits)], 'providercode')
|
||||
).reduce((acc, [providerCode, courseCredits]) => {
|
||||
acc[providerCode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
|
||||
(acc, [courseCode, yearlyInstances]) => {
|
||||
acc[courseCode] = { yearly: {}, name: yearlyInstances[0].name }
|
||||
const array = [2020, 2019, 2018, 2017]
|
||||
array.forEach(year => {
|
||||
acc[courseCode]['yearly'][year] = {}
|
||||
acc[courseCode]['yearly'][year]['acc'] = 0
|
||||
acc[courseCode]['yearly'][year]['accStudents'] = 0
|
||||
acc[courseCode]['yearly'][year]['total'] = 0
|
||||
acc[courseCode]['yearly'][year]['totalStudents'] = 0
|
||||
})
|
||||
yearlyInstances.forEach(instance => {
|
||||
if (!acc[courseCode]['yearly'][instance.year]) acc[courseCode]['yearly'][instance.year] = {}
|
||||
|
||||
/* Calculate course level stats and group by providers */
|
||||
const coursesGroupedByProvider = Object.entries(
|
||||
_.groupBy([..._.flatten(yearlyAccCredits), ..._.flatten(yearlyTotalCredits)], 'providercode')
|
||||
).reduce((acc, [providerCode, courseCredits]) => {
|
||||
acc[providerCode] = Object.entries(_.groupBy(courseCredits, 'code')).reduce(
|
||||
(acc, [courseCode, yearlyInstances]) => {
|
||||
acc[courseCode] = { yearly: {}, name: yearlyInstances[0].name }
|
||||
yearlyInstances.forEach(instance => {
|
||||
if (!acc[courseCode]['yearly'][instance.year]) acc[courseCode]['yearly'][instance.year] = {}
|
||||
if (instance.acc !== undefined) {
|
||||
acc[courseCode]['yearly'][instance.year]['acc'] = instance.acc
|
||||
} else {
|
||||
acc[courseCode]['yearly'][instance.year]['total'] = instance.total
|
||||
}
|
||||
})
|
||||
acc[courseCode]['current'] = _.get(acc, [courseCode, 'yearly', startYear, 'acc']) || 0
|
||||
acc[courseCode]['previous'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'acc']) || 0
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
/* Map providers into proper programmes and calculate programme level stats */
|
||||
const groupedByProgramme = Object.entries(coursesGroupedByProvider).reduce((acc, [providerCode, courses]) => {
|
||||
const programme = providerToProgramme[providerCode]
|
||||
const courseValues = Object.values(courses)
|
||||
const yearlyValues = courseValues.map(c => c.yearly)
|
||||
|
||||
if (programme && programme.code) {
|
||||
acc[programme.code] = {
|
||||
name: programme.name,
|
||||
drill: courses,
|
||||
yearly: _.mergeWith({}, ...yearlyValues, mergele),
|
||||
current: _.sumBy(courseValues, 'current'),
|
||||
previous: _.sumBy(courseValues, 'previous')
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
/* Group programmes into faculties and calculate faculty level stats */
|
||||
const groupedByFaculty = Object.entries(groupedByProgramme).reduce((acc, [programmeCode, programmeStats]) => {
|
||||
const facultyCodes = programmeToFaculties[programmeCode]
|
||||
if (!facultyCodes) return acc
|
||||
facultyCodes.forEach(facultyCode => {
|
||||
if (!facultyCode) return
|
||||
if (!acc[facultyCode]) {
|
||||
acc[facultyCode] = {
|
||||
drill: {},
|
||||
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
|
||||
yearly: {},
|
||||
current: 0,
|
||||
previous: 0
|
||||
if (instance.acc !== undefined) {
|
||||
acc[courseCode]['yearly'][instance.year]['acc'] = instance.acc
|
||||
acc[courseCode]['yearly'][instance.year]['accStudents'] = Number(instance.students)
|
||||
} else {
|
||||
acc[courseCode]['yearly'][instance.year]['total'] = instance.total
|
||||
acc[courseCode]['yearly'][instance.year]['totalStudents'] = Number(instance.students)
|
||||
}
|
||||
}
|
||||
acc[facultyCode]['drill'][programmeCode] = programmeStats
|
||||
acc[facultyCode]['yearly'] = _.mergeWith(acc[facultyCode]['yearly'], programmeStats.yearly, mergele)
|
||||
acc[facultyCode]['current'] += programmeStats.current
|
||||
acc[facultyCode]['previous'] += programmeStats.previous
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
})
|
||||
acc[courseCode]['current'] = _.get(acc, [courseCode, 'yearly', startYear, 'acc']) || 0
|
||||
acc[courseCode]['previous'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'acc']) || 0
|
||||
acc[courseCode]['currentStudents'] = _.get(acc, [courseCode, 'yearly', startYear, 'accStudents']) || 0
|
||||
acc[courseCode]['previousStudents'] = _.get(acc, [courseCode, 'yearly', startYear - 1, 'accStudents']) || 0
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return groupedByFaculty
|
||||
},
|
||||
/* For memoize */
|
||||
(...args) => JSON.stringify(args)
|
||||
)
|
||||
/* Map providers into proper programmes and calculate programme level stats */
|
||||
const groupedByProgramme = Object.entries(coursesGroupedByProvider).reduce((acc, [providerCode, courses]) => {
|
||||
const programme = providerToProgramme[providerCode]
|
||||
const courseValues = Object.values(courses)
|
||||
const yearlyValues = courseValues.map(c => c.yearly)
|
||||
if (programme && programme.code) {
|
||||
acc[programme.code] = {
|
||||
name: programme.name,
|
||||
drill: courses,
|
||||
yearly: _.mergeWith({}, ...yearlyValues, mergele),
|
||||
current: _.sumBy(courseValues, 'current'),
|
||||
previous: _.sumBy(courseValues, 'previous')
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
/* Group programmes into faculties and calculate faculty level stats */
|
||||
const groupedByFaculty = Object.entries(groupedByProgramme).reduce((acc, [programmeCode, programmeStats]) => {
|
||||
const facultyCodes = programmeToFaculties[programmeCode]
|
||||
if (!facultyCodes) return acc
|
||||
facultyCodes.forEach(facultyCode => {
|
||||
if (!facultyCode) return
|
||||
if (!acc[facultyCode]) {
|
||||
acc[facultyCode] = {
|
||||
drill: {},
|
||||
name: facultyCodeToFaculty[facultyCode] ? facultyCodeToFaculty[facultyCode].name : null,
|
||||
yearly: {},
|
||||
current: 0,
|
||||
previous: 0
|
||||
}
|
||||
}
|
||||
acc[facultyCode]['drill'][programmeCode] = programmeStats
|
||||
acc[facultyCode]['yearly'] = _.mergeWith(acc[facultyCode]['yearly'], programmeStats.yearly, mergele)
|
||||
acc[facultyCode]['current'] += programmeStats.current
|
||||
acc[facultyCode]['previous'] += programmeStats.previous
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return groupedByFaculty
|
||||
}
|
||||
|
||||
const calculateProtoC = async query => {
|
||||
const associations = await getAssociations()
|
||||
|
||||
const data = await getTargetStudentCounts({
|
||||
includeOldAttainments: query.include_old_attainments === 'true',
|
||||
excludeNonEnrolled: query.exclude_non_enrolled === 'true'
|
||||
})
|
||||
|
||||
const programmeData = data.filter(d => d.programmeType !== 30)
|
||||
const studytrackData = data.filter(d => d.programmeType !== 20)
|
||||
|
||||
// mankel through studytracks
|
||||
const studytrackMankelid = _(studytrackData)
|
||||
// seems to return the numerical columns as strings, parse them first
|
||||
.map(programmeRow => ({
|
||||
...programmeRow,
|
||||
programmeTotalStudents: parseInt(programmeRow.programmeTotalStudents, 10),
|
||||
students3y: parseInt(programmeRow.students3y, 10),
|
||||
// 4y group includes 3y group, make 4y count exclusive:
|
||||
students4y: parseInt(programmeRow.students4y, 10) - parseInt(programmeRow.students3y, 10),
|
||||
currentlyCancelled: parseInt(programmeRow.currentlyCancelled, 10)
|
||||
}))
|
||||
.value()
|
||||
|
||||
// associate studytracks with bachelor programme
|
||||
const studytrackToBachelorProgrammes = Object.keys(associations.programmes).reduce((acc, curr) => {
|
||||
if (!curr.includes('KH')) return acc
|
||||
const studytracksForProgramme = studytrackMankelid.reduce((acc2, studytrackdata) => {
|
||||
if (associations.programmes[curr].studytracks.includes(studytrackdata.programmeCode)) {
|
||||
acc2.push({
|
||||
code: studytrackdata.programmeCode,
|
||||
name: studytrackdata.programmeName,
|
||||
totalStudents: studytrackdata.programmeTotalStudents,
|
||||
students3y: studytrackdata.students3y,
|
||||
students4y: studytrackdata.students4y,
|
||||
currentlyCancelled: studytrackdata.currentlyCancelled
|
||||
})
|
||||
}
|
||||
return acc2
|
||||
}, [])
|
||||
if (studytracksForProgramme) acc[curr] = studytracksForProgramme
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// combine studytracks data to programme data
|
||||
const newmankelid = _(programmeData)
|
||||
// seems to return the numerical columns as strings, parse them first
|
||||
.map(programmeRow => ({
|
||||
...programmeRow,
|
||||
programmeTotalStudents: parseInt(programmeRow.programmeTotalStudents, 10),
|
||||
students3y: parseInt(programmeRow.students3y, 10),
|
||||
// 4y group includes 3y group, make 4y count exclusive:
|
||||
students4y: parseInt(programmeRow.students4y, 10) - parseInt(programmeRow.students3y, 10),
|
||||
currentlyCancelled: parseInt(programmeRow.currentlyCancelled, 10)
|
||||
}))
|
||||
.groupBy(r => r.orgCode)
|
||||
.mapValues(rows => ({
|
||||
// all of these rows have the same orgCode and orgName, just pick it from the first
|
||||
code: rows[0].orgCode,
|
||||
name: rows[0].orgName,
|
||||
totalStudents: _.sumBy(rows, row => row.programmeTotalStudents),
|
||||
students3y: _.sumBy(rows, row => row.students3y),
|
||||
students4y: _.sumBy(rows, row => row.students4y),
|
||||
currentlyCancelled: _.sumBy(rows, row => row.currentlyCancelled),
|
||||
programmes: rows.map(
|
||||
({
|
||||
programmeCode: code,
|
||||
programmeName: name,
|
||||
programmeTotalStudents: totalStudents,
|
||||
students3y,
|
||||
students4y,
|
||||
currentlyCancelled
|
||||
}) => ({
|
||||
code,
|
||||
name,
|
||||
totalStudents,
|
||||
students3y,
|
||||
students4y,
|
||||
currentlyCancelled,
|
||||
studytracks: studytrackToBachelorProgrammes[code]
|
||||
})
|
||||
)
|
||||
}))
|
||||
.value()
|
||||
return newmankelid
|
||||
}
|
||||
|
||||
const calculateProtoCProgramme = async query => {
|
||||
const associations = await getAssociations()
|
||||
const codes = associations.programmes[query.code]
|
||||
? [...associations.programmes[query.code].studytracks, query.code]
|
||||
: []
|
||||
const data = await getTargetStudentCounts({
|
||||
codes: codes,
|
||||
includeOldAttainments: query.include_old_attainments === 'true',
|
||||
excludeNonEnrolled: query.exclude_non_enrolled === 'true'
|
||||
})
|
||||
const programmeData = data.find(d => d.programmeType === 20)
|
||||
const studytrackData = data.filter(d => d.programmeType !== 20)
|
||||
|
||||
// mankel through studytracks
|
||||
const studytrackMankelid = _(studytrackData)
|
||||
// seems to return the numerical columns as strings, parse them first
|
||||
.map(programmeRow => ({
|
||||
...programmeRow,
|
||||
programmeTotalStudents: parseInt(programmeRow.programmeTotalStudents, 10),
|
||||
students3y: parseInt(programmeRow.students3y, 10),
|
||||
// 4y group includes 3y group, make 4y count exclusive:
|
||||
students4y: parseInt(programmeRow.students4y, 10) - parseInt(programmeRow.students3y, 10),
|
||||
currentlyCancelled: parseInt(programmeRow.currentlyCancelled, 10)
|
||||
}))
|
||||
.value()
|
||||
|
||||
// associate studytracks with bachelor programme
|
||||
const studytrackToBachelorProgrammes = Object.keys(associations.programmes).reduce((acc, curr) => {
|
||||
if (!curr.includes('KH')) return acc
|
||||
const studytracksForProgramme = studytrackMankelid.reduce((acc2, studytrackdata) => {
|
||||
if (associations.programmes[curr].studytracks.includes(studytrackdata.programmeCode)) {
|
||||
acc2.push({
|
||||
code: studytrackdata.programmeCode,
|
||||
name: studytrackdata.programmeName,
|
||||
totalStudents: studytrackdata.programmeTotalStudents,
|
||||
students3y: studytrackdata.students3y,
|
||||
students4y: studytrackdata.students4y,
|
||||
currentlyCancelled: studytrackdata.currentlyCancelled
|
||||
})
|
||||
}
|
||||
return acc2
|
||||
}, [])
|
||||
if (studytracksForProgramme) acc[curr] = studytracksForProgramme
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// combine studytracks data to programme data
|
||||
|
||||
const programmeDataMankeld = {
|
||||
code: programmeData.programmeCode,
|
||||
name: programmeData.programmeName,
|
||||
totalStudents: parseInt(programmeData.programmeTotalStudents, 10),
|
||||
students3y: parseInt(programmeData.students3y, 10),
|
||||
// 4y group includes 3y group, make 4y count exclusive:
|
||||
students4y: parseInt(programmeData.students4y, 10) - parseInt(programmeData.students3y, 10),
|
||||
currentlyCancelled: parseInt(programmeData.currentlyCancelled, 10),
|
||||
studytracks: studytrackToBachelorProgrammes[programmeData.programmeCode]
|
||||
}
|
||||
return programmeDataMankeld
|
||||
}
|
||||
|
||||
const calculateUber = async query => {
|
||||
const data = await getUberData({
|
||||
startDate: new Date(query.start_date),
|
||||
includeOldAttainments: query.include_old_attainments === 'true'
|
||||
})
|
||||
const mankeld = mankeliUberData(data)
|
||||
return mankeld
|
||||
}
|
||||
|
||||
const calculateStatusGraduated = async (unixMillis, showByYear) => {
|
||||
const startDate = showByYear === 'true' ? getCurrentYearStartDate() : await getCurrentStudyYearStartDate(unixMillis)
|
||||
|
||||
const startYear = startDate.getFullYear()
|
||||
const yearRange = _.range(2017, startYear + 1)
|
||||
const Y_TO_MS = 31556952000
|
||||
const startTime = startDate.getTime()
|
||||
|
||||
const yearlyRange = diff => ({
|
||||
from: new Date(startTime - diff * Y_TO_MS),
|
||||
to: new Date(unixMillis - diff * Y_TO_MS)
|
||||
})
|
||||
|
||||
const totalRange = diff => ({
|
||||
from: new Date(startTime - diff * Y_TO_MS),
|
||||
to: new Date(startTime - (diff - 1) * Y_TO_MS)
|
||||
})
|
||||
|
||||
const dataArray = yearRange.map(async year => {
|
||||
const diff = startYear - year
|
||||
const { from, to } = yearlyRange(diff)
|
||||
const yearlyData = await getGraduatedBetween(from, to)
|
||||
const data = { year, yearlyData }
|
||||
return data
|
||||
})
|
||||
|
||||
const totalDataArray = yearRange.map(async year => {
|
||||
const diff = startYear - year
|
||||
const { from, to } = totalRange(diff)
|
||||
|
||||
const yearlyData = await getGraduatedBetween(from, to)
|
||||
const data = { year, yearlyData }
|
||||
return data
|
||||
})
|
||||
|
||||
const promisedData = await Promise.all(dataArray)
|
||||
const promisedTotalData = await Promise.all(totalDataArray)
|
||||
|
||||
const elements = await ElementDetails.findAll()
|
||||
const orgs = await Organisation.findAll()
|
||||
|
||||
// go through acc
|
||||
const mankeled = promisedData.reduce((acc, data) => {
|
||||
data.yearlyData.forEach(curr => {
|
||||
// init faculty
|
||||
if (!acc[curr.faculty_code]) {
|
||||
const org = orgs.find(o => o.code === curr.faculty_code)
|
||||
acc[curr.faculty_code] = { name: org.name, yearly: {}, drill: {} }
|
||||
}
|
||||
if (!acc[curr.faculty_code]['yearly'][data.year])
|
||||
acc[curr.faculty_code]['yearly'][data.year] = { acc: 0, total: 0 }
|
||||
// init programme
|
||||
if (!acc[curr.faculty_code]['drill'][curr.code]) {
|
||||
const element = elements.find(e => e.code === curr.code)
|
||||
|
||||
acc[curr.faculty_code]['drill'][curr.code] = {
|
||||
code: curr.code,
|
||||
name: element.name,
|
||||
current: 0,
|
||||
previous: 0,
|
||||
yearly: {}
|
||||
}
|
||||
}
|
||||
acc[curr.faculty_code]['drill'][curr.code]['yearly'][data.year] = { acc: Number(curr.sum) }
|
||||
acc[curr.faculty_code]['drill'][curr.code]['current'] =
|
||||
_.get(acc, [curr.faculty_code, 'drill', curr.code, 'yearly', startYear, 'acc']) || 0
|
||||
acc[curr.faculty_code]['drill'][curr.code]['previous'] =
|
||||
_.get(acc, [curr.faculty_code, 'drill', curr.code, 'yearly', startYear - 1, 'acc']) || 0
|
||||
|
||||
acc[curr.faculty_code]['yearly'][data.year].acc += Number(curr.sum)
|
||||
acc[curr.faculty_code]['current'] = _.get(acc, [curr.faculty_code, 'yearly', startYear, 'acc']) || 0
|
||||
acc[curr.faculty_code]['previous'] = _.get(acc, [curr.faculty_code, 'yearly', startYear - 1, 'acc']) || 0
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// add totals to mankeled
|
||||
promisedTotalData.forEach(data => {
|
||||
data.yearlyData.forEach(curr => {
|
||||
// if no accumulated init programme
|
||||
if (!mankeled[curr.faculty_code]['drill'][curr.code]) {
|
||||
const element = elements.find(e => e.code === curr.code)
|
||||
mankeled[curr.faculty_code]['drill'][curr.code] = { name: element.name, code: curr.code, yearly: {} }
|
||||
}
|
||||
// if no year in programme yearly add year
|
||||
if (!mankeled[curr.faculty_code]['drill'][curr.code]['yearly'][data.year])
|
||||
mankeled[curr.faculty_code]['drill'][curr.code]['yearly'][data.year] = { total: 0, acc: 0 }
|
||||
// if no year in yearly total for faculty
|
||||
if (!mankeled[curr.faculty_code]['yearly'][data.year])
|
||||
mankeled[curr.faculty_code]['yearly'][data.year] = { total: 0, acc: 0 }
|
||||
// do not add anything to total if current year. might fuck up in fall :D
|
||||
if (data.year !== startYear) {
|
||||
mankeled[curr.faculty_code]['yearly'][data.year].total += Number(curr.sum)
|
||||
mankeled[curr.faculty_code]['drill'][curr.code]['yearly'][data.year].total = Number(curr.sum)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return mankeled
|
||||
}
|
||||
|
||||
const getRedisCDS = async REDIS_KEY => {
|
||||
const raw = await redisClient.getAsync(REDIS_KEY)
|
||||
return raw && JSON.parse(raw)
|
||||
}
|
||||
|
||||
const saveToRedis = async (data, REDIS_KEY, expire = false) => {
|
||||
await redisClient.setAsync(REDIS_KEY, JSON.stringify(data))
|
||||
if (expire) {
|
||||
// expire redis keys that are created daily after 24 hours
|
||||
redisClient.expireat(REDIS_KEY, parseInt(new Date().valueOf() / 1000) + 86400)
|
||||
}
|
||||
}
|
||||
|
||||
const getGraduatedStatus = async (unixMillis, showByYear, doRefresh = false) => {
|
||||
const KEY = `${REDIS_KEY_GRADUATED}_DATE_${unixMillis}_YEARLY_${showByYear.toUpperCase()}`
|
||||
const graduated = await getRedisCDS(KEY)
|
||||
if (!graduated || doRefresh) {
|
||||
const data = await calculateStatusGraduated(unixMillis, showByYear)
|
||||
await saveToRedis(data, KEY, true)
|
||||
return data
|
||||
}
|
||||
return graduated
|
||||
}
|
||||
|
||||
const getProtoC = async (query, doRefresh = false) => {
|
||||
const { include_old_attainments, exclude_non_enrolled } = query
|
||||
|
||||
// redis keys for different queries
|
||||
const KEY = `${REDIS_KEY_PROTOC}_OLD_${include_old_attainments.toUpperCase()}_ENR_${exclude_non_enrolled.toUpperCase()}`
|
||||
const protoC = await getRedisCDS(KEY)
|
||||
if (!protoC || doRefresh) {
|
||||
const data = await calculateProtoC(query)
|
||||
await saveToRedis(data, KEY)
|
||||
return data
|
||||
}
|
||||
return protoC
|
||||
}
|
||||
|
||||
// used for studytrack view
|
||||
const getProtoCProgramme = async (query, doRefresh = false) => {
|
||||
const { include_old_attainments, exclude_non_enrolled, code } = query
|
||||
const KEY = `${REDIS_KEY_PROTOC_PROGRAMME}_CODE_${code}_OLD_${include_old_attainments.toUpperCase()}_ENR_${exclude_non_enrolled.toUpperCase()}`
|
||||
const protoCProgramme = await getRedisCDS(KEY)
|
||||
|
||||
if (!protoCProgramme || doRefresh) {
|
||||
const data = await calculateProtoCProgramme(query)
|
||||
await saveToRedis(data, KEY)
|
||||
return data
|
||||
}
|
||||
return protoCProgramme
|
||||
}
|
||||
|
||||
const getStatus = async (unixMillis, showByYear, doRefresh = false) => {
|
||||
// redis keys for different queries. adds a new key for every queried day.
|
||||
// might cause issues, might not but def not until I am out :D
|
||||
const KEY = `${REDIS_KEY_STATUS}_DATE_${unixMillis}_YEARLY_${showByYear.toUpperCase()}`
|
||||
const status = await getRedisCDS(KEY)
|
||||
if (!status || doRefresh) {
|
||||
const data = await calculateStatusStatistics(unixMillis, showByYear)
|
||||
await saveToRedis(data, KEY, true)
|
||||
return data
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
const getUber = async (query, doRefresh = false) => {
|
||||
// redis keys for different queries
|
||||
const KEY = `${REDIS_KEY_UBER}_OLD_${query.include_old_attainments.toUpperCase()}_${new Date(
|
||||
query.start_date
|
||||
).getFullYear()}`
|
||||
|
||||
const uber = await getRedisCDS(KEY)
|
||||
if (!uber || doRefresh) {
|
||||
const data = await calculateUber(query)
|
||||
await saveToRedis(data, KEY)
|
||||
return data
|
||||
}
|
||||
return uber
|
||||
}
|
||||
|
||||
const refreshProtoCProgramme = async query => {
|
||||
const { include_old_attainments, exclude_non_enrolled, code } = query
|
||||
|
||||
const KEY = `${REDIS_KEY_PROTOC_PROGRAMME}_CODE_${code}_OLD_${include_old_attainments.toUpperCase()}_ENR_${exclude_non_enrolled.toUpperCase()}`
|
||||
|
||||
const data = await calculateProtoCProgramme(query)
|
||||
await saveToRedis(data, KEY)
|
||||
}
|
||||
|
||||
const refreshProtoC = async query => {
|
||||
const { include_old_attainments, exclude_non_enrolled } = query
|
||||
const KEY = `${REDIS_KEY_PROTOC}_OLD_${include_old_attainments.toUpperCase()}_ENR_${exclude_non_enrolled.toUpperCase()}`
|
||||
|
||||
const data = await calculateProtoC(query)
|
||||
await saveToRedis(data, KEY)
|
||||
}
|
||||
|
||||
const refreshStatus = async (unixMillis, showByYear) => {
|
||||
const KEY = `${REDIS_KEY_STATUS}_DATE_${unixMillis}_YEARLY_${showByYear.toUpperCase()}`
|
||||
const data = await calculateStatusStatistics(unixMillis, showByYear)
|
||||
await saveToRedis(data, KEY, true)
|
||||
}
|
||||
|
||||
const refreshStatusGraduated = async (unixMillis, showByYear) => {
|
||||
const KEY = `${REDIS_KEY_GRADUATED}_DATE_${unixMillis}_YEARLY_${showByYear.toUpperCase()}`
|
||||
const data = await calculateStatusGraduated(unixMillis, showByYear)
|
||||
await saveToRedis(data, KEY, true)
|
||||
}
|
||||
|
||||
const refreshUber = async query => {
|
||||
const KEY = `${REDIS_KEY_UBER}_OLD_${query.include_old_attainments.toUpperCase()}_${new Date(
|
||||
query.start_date
|
||||
).getFullYear()}`
|
||||
const data = await calculateUber(query)
|
||||
await saveToRedis(data, KEY)
|
||||
}
|
||||
|
||||
const getStartYears = async () => {
|
||||
return await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
DISTINCT studyright.studystartdate
|
||||
FROM organization org
|
||||
INNER JOIN studyright
|
||||
ON studyright.faculty_code = org.code
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
WHERE
|
||||
studyright.extentcode = 1
|
||||
AND studyright.studystartdate >= :startDate
|
||||
AND studyright.studystartdate <= :currentDate
|
||||
AND transfers.studyrightid IS NULL
|
||||
ORDER BY 1
|
||||
`,
|
||||
{
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: { startDate: STUDYRIGHT_START_DATE, currentDate: CURRENT_DATE }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// no idea what the comment below is, consider throwing into :roskis:
|
||||
|
||||
// mankel data from:
|
||||
/*
|
||||
[
|
||||
{
|
||||
checkpoint: "2017-11-30T13:00:00.000Z",
|
||||
orgCode: "H10",
|
||||
orgName: "Teologinen tiedekunta",
|
||||
programmeCode: "KH10_001",
|
||||
programmeName: "Teologian ja uskonnontutkimuksen kandiohjelma",
|
||||
programmeTotalStudents: "120",
|
||||
students3y: "9"
|
||||
students4y: "10"
|
||||
},
|
||||
...
|
||||
]
|
||||
*/
|
||||
// into:
|
||||
/*
|
||||
{
|
||||
H10: {
|
||||
name: "Teologinen tiedekunta",
|
||||
code: "H10",
|
||||
// snapshots of faculty level total & target numbers
|
||||
snapshots: [
|
||||
{
|
||||
date: '2017-11-30T13:00:00.000Z',
|
||||
totalStudents: 340, // sum of programme total students at this checkpoint
|
||||
students3y: 43, // ^^^ of programme 3y target students
|
||||
students4y: 55
|
||||
},
|
||||
...
|
||||
],
|
||||
// snapshots of programme level total & target numbers, grouped by programme
|
||||
programmes: [
|
||||
{
|
||||
code: 'KH10_001',
|
||||
name: 'Teologian ja uskonnontutkimuksen kandiohjelma',
|
||||
snapshots: [
|
||||
{
|
||||
date: '2017-11-30T13:00:00.000Z',
|
||||
totalStudents: 120,
|
||||
students3y: 9,
|
||||
students4y: 10
|
||||
},
|
||||
...
|
||||
},
|
||||
...
|
||||
]
|
||||
},
|
||||
...
|
||||
}
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
withErr,
|
||||
mankeliUberData,
|
||||
getTargetStudentCounts,
|
||||
get3yStudentsWithDrilldownPerYear,
|
||||
getStatusStatistics,
|
||||
getUberData,
|
||||
sorters
|
||||
calculateProtoC,
|
||||
calculateProtoCProgramme,
|
||||
getProtoC,
|
||||
getProtoCProgramme,
|
||||
getStatus,
|
||||
getUber,
|
||||
refreshProtoC,
|
||||
refreshStatus,
|
||||
refreshUber,
|
||||
refreshProtoCProgramme,
|
||||
refreshStatusGraduated,
|
||||
getStartYears,
|
||||
getGraduatedStatus
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const {
|
||||
Semester,
|
||||
Organisation
|
||||
} = require('../models')
|
||||
const { sequelizeKone, CourseDuplicates } = require('../models/models_kone')
|
||||
const { sequelizeKone, CourseDuplicates, MandatoryCourse } = require('../models/models_kone')
|
||||
const Op = Sequelize.Op
|
||||
const { CourseYearlyStatsCounter } = require('../services/course_yearly_stats_counter')
|
||||
const _ = require('lodash')
|
||||
@@ -503,15 +503,32 @@ const getIdToDuplicatesMapWithCourse = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const getMandatoryCourse = courses => {
|
||||
return MandatoryCourse.findOne({
|
||||
attributes: ['course_code'],
|
||||
where: {
|
||||
course_code: {
|
||||
[Op.in]: courses
|
||||
}
|
||||
},
|
||||
raw: true
|
||||
})
|
||||
}
|
||||
|
||||
const getMainCodeToDuplicates = async () => {
|
||||
const all = await getIdToDuplicatesMapWithCourse()
|
||||
const maincodeToDuplicates = Object.values(all).reduce((acc, courses) => {
|
||||
const maincodeToDuplicates = Object.values(all).reduce(async (acc, courses) => {
|
||||
const mandatoryCourse = await getMandatoryCourse(courses.map(course => course.code))
|
||||
const main = _.orderBy(
|
||||
courses,
|
||||
[
|
||||
c => {
|
||||
if (c.code.match(/^A/)) return 4 // open university codes come last
|
||||
if (c.code.match(/^\d/)) return 2 // old numeric codes come second
|
||||
if (mandatoryCourse) {
|
||||
if (mandatoryCourse.course_code === c.code) return 1 // check if course is mandatory course and put it as first
|
||||
return 2 // return as second otherwise
|
||||
}
|
||||
if (c.code.match(/^[A-Za-z]/)) return 1 // new letter based codes come first
|
||||
return 3 // unknown, comes before open uni?
|
||||
},
|
||||
@@ -520,11 +537,12 @@ const getMainCodeToDuplicates = async () => {
|
||||
],
|
||||
['asc', 'desc', 'desc']
|
||||
)[0]
|
||||
acc[main.code] = {
|
||||
const promisedAcc = await acc
|
||||
promisedAcc[main.code] = {
|
||||
maincourse: { code: main.code, name: main.name },
|
||||
duplicates: courses.map(c => ({ code: c.code, name: c.name }))
|
||||
}
|
||||
return acc
|
||||
return promisedAcc
|
||||
}, {})
|
||||
return maincodeToDuplicates
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ const byStudyprogramme = async studyProgrammeId => {
|
||||
return mandatoryCourses.map(mc => ({
|
||||
name: courseCodeToCourse[mc.course_code].name,
|
||||
code: mc.course_code,
|
||||
label: mc.mandatory_course_label
|
||||
label: mc.mandatory_course_label,
|
||||
// this is because fuck life
|
||||
visible: { visibility: true, id: null }
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ const getStudentsIncludeCoursesBetween = async (studentnumbers, startDate, endDa
|
||||
attributes: [
|
||||
'studyrightid',
|
||||
'startdate',
|
||||
'enddate',
|
||||
'extentcode',
|
||||
'graduated',
|
||||
'canceldate',
|
||||
@@ -356,13 +357,13 @@ const studentnumbersWithAllStudyrightElements = async (
|
||||
exchangeStudents,
|
||||
cancelledStudents,
|
||||
nondegreeStudents,
|
||||
transferredStudents,
|
||||
tag
|
||||
) => {
|
||||
// eslint-disable-line
|
||||
|
||||
// db startdate is formatted to utc so need to change it when querying
|
||||
const formattedStartDate = new Date(moment.tz(startDate, 'Europe/Helsinki').format()).toUTCString()
|
||||
|
||||
const filteredExtents = []
|
||||
let studyrightWhere = {
|
||||
extentcode: {
|
||||
@@ -460,7 +461,31 @@ const studentnumbersWithAllStudyrightElements = async (
|
||||
if (!newestStudytrack) return false
|
||||
return studyRights.includes(newestStudytrack.code)
|
||||
})
|
||||
return filteredStudentnumbers.length > 0 ? filteredStudentnumbers : studentnumbers
|
||||
|
||||
const studentnumberlist = filteredStudentnumbers.length > 0 ? filteredStudentnumbers : studentnumbers
|
||||
// fetch students that have transferred out of the programme and filter out these studentnumbers
|
||||
if (!transferredStudents) {
|
||||
const transferredOutStudents = await Transfers.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
where: {
|
||||
sourcecode: {
|
||||
[Op.in]: studyRights
|
||||
},
|
||||
transferdate: {
|
||||
[Op.gt]: startDate
|
||||
},
|
||||
studentnumber: {
|
||||
[Op.in]: studentnumberlist
|
||||
}
|
||||
},
|
||||
raw: true
|
||||
}).map(s => s.studentnumber)
|
||||
|
||||
const notTransferredStudents = studentnumberlist.filter(sn => !transferredOutStudents.includes(sn))
|
||||
return notTransferredStudents
|
||||
}
|
||||
|
||||
return studentnumberlist
|
||||
}
|
||||
|
||||
const parseQueryParams = query => {
|
||||
@@ -478,10 +503,12 @@ const parseQueryParams = query => {
|
||||
const exchangeStudents = studentStatuses && studentStatuses.includes('EXCHANGE')
|
||||
const cancelledStudents = studentStatuses && studentStatuses.includes('CANCELLED')
|
||||
const nondegreeStudents = studentStatuses && studentStatuses.includes('NONDEGREE')
|
||||
const transferredStudents = studentStatuses && studentStatuses.includes('TRANSFERRED')
|
||||
return {
|
||||
exchangeStudents,
|
||||
cancelledStudents,
|
||||
nondegreeStudents,
|
||||
transferredStudents,
|
||||
// if someone passes something falsy like null as the studyright, remove it so it doesn't break
|
||||
// the sequelize query
|
||||
studyRights: (Array.isArray(studyRights) ? studyRights : Object.values(studyRights)).filter(Boolean),
|
||||
@@ -591,10 +618,12 @@ const optimizedStatisticsOf = async (query, studentnumberlist) => {
|
||||
if (
|
||||
formattedQueryParams.studentStatuses &&
|
||||
!formattedQueryParams.studentStatuses
|
||||
.map(status => status === 'CANCELLED' || status === 'EXCHANGE' || status === 'NONDEGREE')
|
||||
.map(
|
||||
status => status === 'CANCELLED' || status === 'EXCHANGE' || status === 'NONDEGREE' || status === 'TRANSFERRED'
|
||||
)
|
||||
.every(e => e === true)
|
||||
) {
|
||||
return { error: 'Student status should be either CANCELLED or EXCHANGE or NONDEGREE' }
|
||||
return { error: 'Student status should be either CANCELLED or EXCHANGE or NONDEGREE or TRANSFERRED' }
|
||||
}
|
||||
const {
|
||||
studyRights,
|
||||
@@ -604,6 +633,7 @@ const optimizedStatisticsOf = async (query, studentnumberlist) => {
|
||||
exchangeStudents,
|
||||
cancelledStudents,
|
||||
nondegreeStudents,
|
||||
transferredStudents,
|
||||
tag
|
||||
} = parseQueryParams(formattedQueryParams)
|
||||
|
||||
@@ -618,7 +648,8 @@ const optimizedStatisticsOf = async (query, studentnumberlist) => {
|
||||
endDate,
|
||||
exchangeStudents,
|
||||
cancelledStudents,
|
||||
nondegreeStudents
|
||||
nondegreeStudents,
|
||||
transferredStudents
|
||||
)
|
||||
const { students, credits, extents, semesters, elementdetails, courses } = await getStudentsIncludeCoursesBetween(
|
||||
studentnumbers,
|
||||
@@ -688,15 +719,25 @@ const parseCreditInfo = credit => ({
|
||||
|
||||
const bottlenecksOf = async (query, studentnumberlist) => {
|
||||
const isValidRequest = async (query, params) => {
|
||||
const { studyRights, startDate, endDate, exchangeStudents, cancelledStudents, nondegreeStudents } = params
|
||||
const {
|
||||
studyRights,
|
||||
startDate,
|
||||
endDate,
|
||||
exchangeStudents,
|
||||
cancelledStudents,
|
||||
nondegreeStudents,
|
||||
transferredStudents
|
||||
} = params
|
||||
if (!query.semesters.every(semester => semester === 'FALL' || semester === 'SPRING')) {
|
||||
return { error: 'Semester should be either SPRING OR FALL' }
|
||||
}
|
||||
if (
|
||||
query.studentStatuses &&
|
||||
!query.studentStatuses.every(status => status === 'CANCELLED' || status === 'EXCHANGE' || status === 'NONDEGREE')
|
||||
!query.studentStatuses.every(
|
||||
status => status === 'CANCELLED' || status === 'EXCHANGE' || status === 'NONDEGREE' || status === 'TRANSFERRED'
|
||||
)
|
||||
) {
|
||||
return { error: 'Student status should be either CANCELLED or EXCHANGE or NONDEGREE' }
|
||||
return { error: 'Student status should be either CANCELLED or EXCHANGE or NONDEGREE or TRANSFERRED' }
|
||||
}
|
||||
if (query.selectedStudents) {
|
||||
const allStudents = await studentnumbersWithAllStudyrightElements(
|
||||
@@ -706,6 +747,7 @@ const bottlenecksOf = async (query, studentnumberlist) => {
|
||||
exchangeStudents,
|
||||
cancelledStudents,
|
||||
nondegreeStudents,
|
||||
transferredStudents,
|
||||
query.tag
|
||||
)
|
||||
const disallowedRequest = checkThatSelectedStudentsAreUnderRequestedStudyright(
|
||||
@@ -726,6 +768,7 @@ const bottlenecksOf = async (query, studentnumberlist) => {
|
||||
exchangeStudents,
|
||||
cancelledStudents,
|
||||
nondegreeStudents,
|
||||
transferredStudents,
|
||||
tag
|
||||
} = params
|
||||
const studentnumbers =
|
||||
@@ -737,6 +780,7 @@ const bottlenecksOf = async (query, studentnumberlist) => {
|
||||
exchangeStudents,
|
||||
cancelledStudents,
|
||||
nondegreeStudents,
|
||||
transferredStudents,
|
||||
tag
|
||||
))
|
||||
const allstudents = studentnumbers.reduce((numbers, num) => {
|
||||
|
||||
@@ -13,7 +13,13 @@ const updateSISMetadata = async () => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
const updateSISProgrammes = async () => {
|
||||
const response = await client.get('/v1/programmes', { params: { token: SECRET_TOKEN } })
|
||||
return response.data
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
updateSISMetadata,
|
||||
updateSISStudents
|
||||
updateSISStudents,
|
||||
updateSISProgrammes
|
||||
}
|
||||
|
||||
@@ -189,6 +189,20 @@ const findProgrammeThesisCredits = async code => {
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// to fix thesis numbers, (there are students who will do the thesis
|
||||
// for old programme but they will show up in the new one since same course code)
|
||||
// get first studentnumbers that are actually in the new programme
|
||||
|
||||
const studentNumbers = await StudyrightElement.findAll({
|
||||
attributes: ['studentnumber'],
|
||||
where: {
|
||||
code: {
|
||||
[Op.eq]: code
|
||||
}
|
||||
},
|
||||
raw: true
|
||||
}).map(sn => sn.studentnumber)
|
||||
|
||||
const credits = await Credit.findAll({
|
||||
include: {
|
||||
model: Course,
|
||||
@@ -202,6 +216,9 @@ const findProgrammeThesisCredits = async code => {
|
||||
where: {
|
||||
credittypecode: {
|
||||
[Op.ne]: 10
|
||||
},
|
||||
student_studentnumber: {
|
||||
[Op.in]: studentNumbers
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -260,7 +277,7 @@ const productivityStatsForStudytrack = async (studytrack, since) => {
|
||||
const endDate = `${moment(new Date(), 'YYYY')
|
||||
.add(1, 'years')
|
||||
.format('YYYY')}-${semesterEnd['SPRING']}`
|
||||
const studentnumbers = await studentnumbersWithAllStudyrightElements([studytrack], startDate, endDate, false, false)
|
||||
const studentnumbers = await studentnumbersWithAllStudyrightElements([studytrack], startDate, endDate, false, true)
|
||||
const promises = [
|
||||
graduatedStatsForStudytrack(studytrack, since),
|
||||
productivityStatsForProvider(providercode, since),
|
||||
@@ -509,8 +526,8 @@ const statsForClass = async (studentnumbers, startDate, studyprogramme, endDate)
|
||||
tranferredToStudyprogram(studentnumbers, startDate, studyprogramme, endDate),
|
||||
nationalitiesFromClass(studentnumbers),
|
||||
transferredFromStudyprogram(studentnumbers, startDate, studyprogramme, new Date()),
|
||||
cancelledStudyright(studentnumbers, startDate, studyprogramme, endDate),
|
||||
startedStudyright(studentnumbers, startDate, studyprogramme, endDate)
|
||||
cancelledStudyright(studentnumbers, startDate, studyprogramme, new Date()),
|
||||
startedStudyright(studentnumbers, startDate, studyprogramme, new Date())
|
||||
])
|
||||
}
|
||||
|
||||
@@ -580,7 +597,7 @@ const throughputStatsForStudytrack = async (studyprogramme, since) => {
|
||||
startDate,
|
||||
endDate,
|
||||
false,
|
||||
false
|
||||
true
|
||||
)
|
||||
const creditsForStudyprogramme = await productivityCreditsFromStudyprogrammeStudents(
|
||||
studyprogramme,
|
||||
@@ -687,7 +704,7 @@ const throughputStatsForStudytrack = async (studyprogramme, since) => {
|
||||
startDate,
|
||||
endDate,
|
||||
false,
|
||||
false
|
||||
true
|
||||
)
|
||||
const creditsForStudyprogramme = await productivityCreditsFromStudyprogrammeStudents(
|
||||
studyprogramme,
|
||||
@@ -706,6 +723,7 @@ const throughputStatsForStudytrack = async (studyprogramme, since) => {
|
||||
started
|
||||
] = await statsForClass(studentnumbers, startDate, studyprogramme, endDate)
|
||||
// theres so much shit in the data that transefferFrom doesnt rly mean anything
|
||||
|
||||
delete genders[null]
|
||||
const creditValues = credits.reduce(
|
||||
(acc, curr) => {
|
||||
|
||||
@@ -26,6 +26,12 @@ BEGIN
|
||||
(thesis, '{ "en": "Bachelor thesis" }', 'false')
|
||||
;
|
||||
|
||||
INSERT INTO student
|
||||
("studentnumber", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
('012111517', ts, ts)
|
||||
;
|
||||
|
||||
INSERT INTO credit_types
|
||||
("credittypecode", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
@@ -34,15 +40,15 @@ BEGIN
|
||||
;
|
||||
|
||||
INSERT INTO credit
|
||||
("id", "createddate", "lastmodifieddate", "semestercode", "course_code", "credittypecode", "attainment_date", "credits")
|
||||
("id", "createddate", "lastmodifieddate", "semestercode", "course_code", "credittypecode", "attainment_date", "credits", "student_studentnumber")
|
||||
VALUES
|
||||
('CREDIT_01', ts, ts, 5, course_01, 4, date_2016, 5),
|
||||
('CREDIT_02', ts, ts, 5, course_01, 10, date_2016, 5),
|
||||
('CREDIT_03', ts, ts, 5, module_01, 4, date_2016, 20),
|
||||
('CREDIT_04', ts, ts, 5, course_02, 4, date_2016, 5),
|
||||
('CREDIT_05', ts, ts, 5, course_01, 4, date_2015, 5),
|
||||
('CREDIT_06', ts, ts, 5, course_01, 4, date_2015, 5),
|
||||
('CREDIT_07', ts, ts, 5, thesis, 4, date_2015, 30)
|
||||
('CREDIT_01', ts, ts, 5, course_01, 4, date_2016, 5, '012111517'),
|
||||
('CREDIT_02', ts, ts, 5, course_01, 10, date_2016, 5, '012111517'),
|
||||
('CREDIT_03', ts, ts, 5, module_01, 4, date_2016, 20, '012111517'),
|
||||
('CREDIT_04', ts, ts, 5, course_02, 4, date_2016, 5, '012111517'),
|
||||
('CREDIT_05', ts, ts, 5, course_01, 4, date_2015, 5, '012111517'),
|
||||
('CREDIT_06', ts, ts, 5, course_01, 4, date_2015, 5, '012111517'),
|
||||
('CREDIT_07', ts, ts, 5, thesis, 4, date_2015, 30, '012111517')
|
||||
;
|
||||
|
||||
INSERT INTO providers
|
||||
@@ -77,11 +83,11 @@ BEGIN
|
||||
;
|
||||
|
||||
INSERT INTO studyright_elements
|
||||
("studyrightid", "code", "createdAt", "updatedAt")
|
||||
("studyrightid", "code", "createdAt", "updatedAt", "studentnumber")
|
||||
VALUES
|
||||
(10, studytrk_01, ts, ts),
|
||||
(11, studytrk_01, ts, ts),
|
||||
(12, studytrk_02, ts, ts)
|
||||
(10, studytrk_01, ts, ts, '012111517'),
|
||||
(11, studytrk_01, ts, ts, '012111517'),
|
||||
(12, studytrk_02, ts, ts, '012111517')
|
||||
;
|
||||
|
||||
END $$;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
const Sequelize = require('sequelize')
|
||||
const { ExcludedCourse } = require('../models/models_kone')
|
||||
const { Op } = Sequelize
|
||||
|
||||
const { dbConnections: sisConnections } = require('../databaseV2/connection')
|
||||
const { sequelizeKone } = require('../database/connection')
|
||||
|
||||
const byProgrammeCode = async code => {
|
||||
const connection = sisConnections.established ? sisConnections.sequelize : sequelizeKone
|
||||
const [result] = await connection.query(
|
||||
`
|
||||
WITH RECURSIVE children as (
|
||||
SELECT DISTINCT pm.*, 0 AS module_order, NULL::jsonb AS label_name, NULL AS label_code FROM programme_modules pm
|
||||
WHERE pm.code = ?
|
||||
UNION ALL
|
||||
SELECT pm.*, c.order AS module_order, c.name AS label_name, c.code AS label_code
|
||||
FROM children c, programme_modules pm, programme_module_children pmc
|
||||
WHERE c.id = pmc.parent_id AND pm.id = pmc.child_id
|
||||
GROUP BY pm.id, c.name, c.code, c.order
|
||||
) SELECT * FROM children WHERE type = 'course'
|
||||
`,
|
||||
{ replacements: [code] }
|
||||
)
|
||||
|
||||
const excluded = await ExcludedCourse.findAll({
|
||||
where: {
|
||||
programme_code: {
|
||||
[Op.eq]: code
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const labeled = result.map(module => {
|
||||
const label = {
|
||||
id: module.label_name.fi,
|
||||
label: `${module.label_code}\n${module.label_name.fi}`,
|
||||
orderNumber: module.module_order
|
||||
}
|
||||
// bit hacky
|
||||
const foundCourse = excluded.find(course => course.course_code === module.code)
|
||||
const visible = { visibility: !foundCourse, id: foundCourse ? foundCourse.id : null }
|
||||
|
||||
return { ...module, label, visible }
|
||||
})
|
||||
|
||||
return labeled
|
||||
}
|
||||
|
||||
const addExcludedCourses = async (programmecode, coursecodes) => {
|
||||
// return ExcludedCourse.create({
|
||||
// programme_code: programmecode,
|
||||
// course_code: coursecode
|
||||
// })
|
||||
return ExcludedCourse.bulkCreate(coursecodes.map(c => ({ programme_code: programmecode, course_code: c })))
|
||||
}
|
||||
// just copy pasted from above since almost same query
|
||||
const modulesByProgrammeCode = async code => {
|
||||
const connection = sisConnections.established ? sisConnections.sequelize : sequelizeKone
|
||||
const [result] = await connection.query(
|
||||
`
|
||||
WITH RECURSIVE children as (
|
||||
SELECT DISTINCT pm.*, 0 AS module_order, NULL::jsonb AS label_name, NULL AS label_code FROM programme_modules pm
|
||||
WHERE pm.code = ?
|
||||
UNION ALL
|
||||
SELECT pm.*, c.order AS module_order, c.name AS label_name, c.code AS label_code
|
||||
FROM children c, programme_modules pm, programme_module_children pmc
|
||||
WHERE c.id = pmc.parent_id AND pm.id = pmc.child_id
|
||||
GROUP BY pm.id, c.name, c.code, c.order
|
||||
) SELECT * FROM children WHERE type = 'module'
|
||||
`,
|
||||
{ replacements: [code] }
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const removeExcludedCourses = async ids => {
|
||||
return ExcludedCourse.destroy({
|
||||
where: {
|
||||
id: {
|
||||
[Op.or]: ids
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { byProgrammeCode, addExcludedCourses, removeExcludedCourses, modulesByProgrammeCode }
|
||||
@@ -9,7 +9,7 @@ if (process.env.LOG_PORT && process.env.LOG_HOST) {
|
||||
hostname: process.env.LOG_HOSTNAME || 'oodikone-backend',
|
||||
host: process.env.LOG_HOST,
|
||||
port: process.env.LOG_PORT,
|
||||
protocol: process.env.LOG_PROTOCOL || 'http',
|
||||
protocol: process.env.LOG_PROTOCOL || 'https',
|
||||
environment: process.env.NODE_ENV,
|
||||
protocolOptions: {
|
||||
path: process.env.LOG_PATH || '/gelf'
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
const { STRING, DATE, JSONB } = require('sequelize')
|
||||
|
||||
module.exports = {
|
||||
up: async queryInterface => {
|
||||
await queryInterface.createTable('programme_modules', {
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: STRING
|
||||
},
|
||||
code: {
|
||||
type: STRING
|
||||
},
|
||||
name: {
|
||||
type: JSONB
|
||||
},
|
||||
type: {
|
||||
type: STRING
|
||||
},
|
||||
created_at: {
|
||||
type: DATE
|
||||
},
|
||||
updated_at: {
|
||||
type: DATE
|
||||
}
|
||||
})
|
||||
await queryInterface.createTable('programme_module_children', {
|
||||
composite: {
|
||||
type: STRING,
|
||||
primaryKey: true
|
||||
},
|
||||
parent_id: {
|
||||
type: STRING,
|
||||
references: {
|
||||
model: 'programme_modules',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
child_id: {
|
||||
type: STRING,
|
||||
references: {
|
||||
model: 'programme_modules',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
created_at: {
|
||||
type: DATE
|
||||
},
|
||||
updated_at: {
|
||||
type: DATE
|
||||
}
|
||||
})
|
||||
},
|
||||
down: async () => {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const { INTEGER } = require('sequelize')
|
||||
|
||||
// Creates the exact same table as in last migration, but because staging data was messed,
|
||||
// this has to be done
|
||||
|
||||
module.exports = {
|
||||
up: async queryInterface => {
|
||||
await queryInterface.addColumn('programme_modules', 'order', {
|
||||
type: INTEGER
|
||||
})
|
||||
},
|
||||
down: async () => {}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
const { INTEGER, STRING, DATE } = require('sequelize')
|
||||
|
||||
module.exports = {
|
||||
up: async queryInterface => {
|
||||
await queryInterface.createTable(
|
||||
'excluded_courses',
|
||||
{
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: INTEGER,
|
||||
autoIncrement: true
|
||||
},
|
||||
programme_code: {
|
||||
type: STRING
|
||||
},
|
||||
course_code: {
|
||||
type: STRING
|
||||
},
|
||||
created_at: {
|
||||
type: DATE
|
||||
},
|
||||
updated_at: {
|
||||
type: DATE
|
||||
}
|
||||
},
|
||||
{
|
||||
uniqueKeys: {
|
||||
Items_unique: {
|
||||
fields: ['programme_code', 'course_code']
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
down: async () => {}
|
||||
}
|
||||
@@ -21,6 +21,32 @@ const CourseGroup = sequelizeKone.define('course_groups', {
|
||||
}
|
||||
})
|
||||
|
||||
const ExcludedCourse = sequelizeKone.define(
|
||||
'excluded_courses',
|
||||
{
|
||||
id: {
|
||||
primaryKey: true,
|
||||
type: Sequelize.INTEGER,
|
||||
autoIncrement: true
|
||||
},
|
||||
programme_code: {
|
||||
type: Sequelize.STRING
|
||||
},
|
||||
course_code: {
|
||||
type: Sequelize.STRING
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE
|
||||
}
|
||||
},
|
||||
{
|
||||
underscored: true
|
||||
}
|
||||
)
|
||||
|
||||
const TeacherCourseGroup = sequelizeKone.define('teacher_course_groups', {
|
||||
course_group_id: {
|
||||
type: Sequelize.BIGINT
|
||||
@@ -248,5 +274,6 @@ module.exports = {
|
||||
MandatoryCourseLabels,
|
||||
MigrationKone,
|
||||
CustomPopulationSearch,
|
||||
sequelizeKone
|
||||
sequelizeKone,
|
||||
ExcludedCourse
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ if (process.env.LOG_PORT && process.env.LOG_HOST) {
|
||||
hostname: process.env.LOG_HOSTNAME || 'UPDATER',
|
||||
host: process.env.LOG_HOST,
|
||||
port: process.env.LOG_PORT,
|
||||
protocol: process.env.LOG_PROTOCOL || 'http',
|
||||
protocol: process.env.LOG_PROTOCOL || 'https',
|
||||
environment: process.env.NODE_ENV,
|
||||
protocolOptions: {
|
||||
path: process.env.LOG_PATH || '/gelf'
|
||||
|
||||
+33
-14
@@ -2250,7 +2250,8 @@
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "2.1.1",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"aproba": {
|
||||
"version": "1.2.0",
|
||||
@@ -2268,11 +2269,13 @@
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -2285,15 +2288,18 @@
|
||||
},
|
||||
"code-point-at": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"core-util-is": {
|
||||
"version": "1.0.2",
|
||||
@@ -2396,7 +2402,8 @@
|
||||
},
|
||||
"inherits": {
|
||||
"version": "2.0.3",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.5",
|
||||
@@ -2406,6 +2413,7 @@
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"number-is-nan": "^1.0.0"
|
||||
}
|
||||
@@ -2418,17 +2426,20 @@
|
||||
"minimatch": {
|
||||
"version": "3.0.4",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
"version": "0.0.8",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"minipass": {
|
||||
"version": "2.3.5",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"safe-buffer": "^5.1.2",
|
||||
"yallist": "^3.0.0"
|
||||
@@ -2445,6 +2456,7 @@
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
@@ -2517,7 +2529,8 @@
|
||||
},
|
||||
"number-is-nan": {
|
||||
"version": "1.0.1",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
@@ -2527,6 +2540,7 @@
|
||||
"once": {
|
||||
"version": "1.4.0",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
@@ -2602,7 +2616,8 @@
|
||||
},
|
||||
"safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
@@ -2632,6 +2647,7 @@
|
||||
"string-width": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"code-point-at": "^1.0.0",
|
||||
"is-fullwidth-code-point": "^1.0.0",
|
||||
@@ -2649,6 +2665,7 @@
|
||||
"strip-ansi": {
|
||||
"version": "3.0.1",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^2.0.0"
|
||||
}
|
||||
@@ -2687,11 +2704,13 @@
|
||||
},
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.0.3",
|
||||
"bundled": true
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4097,9 +4116,9 @@
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
|
||||
"version": "4.17.19",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
|
||||
"integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
|
||||
},
|
||||
"lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"dependencies": {
|
||||
"dotenv": "^8.0.0",
|
||||
"jest": "^24.8.0",
|
||||
"lodash": "^4.17.15",
|
||||
"lodash": "^4.17.19",
|
||||
"node-nats-streaming": "^0.2.6",
|
||||
"pg": "^7.12.0",
|
||||
"sequelize": "^5.21.1",
|
||||
|
||||
+33
-14
@@ -1492,7 +1492,8 @@
|
||||
"ansi-regex": {
|
||||
"version": "2.1.1",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"aproba": {
|
||||
"version": "1.2.0",
|
||||
@@ -1513,12 +1514,14 @@
|
||||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -1533,17 +1536,20 @@
|
||||
"code-point-at": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"core-util-is": {
|
||||
"version": "1.0.2",
|
||||
@@ -1660,7 +1666,8 @@
|
||||
"inherits": {
|
||||
"version": "2.0.3",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.5",
|
||||
@@ -1672,6 +1679,7 @@
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"number-is-nan": "^1.0.0"
|
||||
}
|
||||
@@ -1686,6 +1694,7 @@
|
||||
"version": "3.0.4",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
@@ -1693,12 +1702,14 @@
|
||||
"minimist": {
|
||||
"version": "0.0.8",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"minipass": {
|
||||
"version": "2.3.5",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"safe-buffer": "^5.1.2",
|
||||
"yallist": "^3.0.0"
|
||||
@@ -1717,6 +1728,7 @@
|
||||
"version": "0.5.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
@@ -1797,7 +1809,8 @@
|
||||
"number-is-nan": {
|
||||
"version": "1.0.1",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
@@ -1809,6 +1822,7 @@
|
||||
"version": "1.4.0",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
@@ -1894,7 +1908,8 @@
|
||||
"safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
@@ -1930,6 +1945,7 @@
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"code-point-at": "^1.0.0",
|
||||
"is-fullwidth-code-point": "^1.0.0",
|
||||
@@ -1949,6 +1965,7 @@
|
||||
"version": "3.0.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^2.0.0"
|
||||
}
|
||||
@@ -1992,12 +2009,14 @@
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.0.3",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2567,9 +2586,9 @@
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
|
||||
"version": "4.17.19",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
|
||||
"integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ=="
|
||||
},
|
||||
"lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
|
||||
+1008
-230
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@
|
||||
"lint": "concurrently 'npm run lint:js' 'npm run lint:css'",
|
||||
"lint:css": "stylelint 'src/**/*.css'",
|
||||
"lint:js": "eslint --ext .jsx,.js ./",
|
||||
"lintfix": "eslint --ext .jsx,.js --fix ./",
|
||||
"lintfix": "eslint --ext .jsx,.js --fix ./ && stylelint --fix 'src/**/*.css'",
|
||||
"sentry-cli": "sentry-cli",
|
||||
"stats": "webpack --json --mode=production --profile --progress > stats.json",
|
||||
"test": "npm run lint",
|
||||
@@ -57,7 +57,7 @@
|
||||
"webpack": "^4.37.0",
|
||||
"webpack-cli": "^3.3.6",
|
||||
"webpack-deadcode-plugin": "^0.1.11",
|
||||
"webpack-dev-server": "^3.7.2"
|
||||
"webpack-dev-server": "^3.11.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.5.5",
|
||||
@@ -71,14 +71,17 @@
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"dotenv": "^6.2.0",
|
||||
"highcharts": "^6.2.0",
|
||||
"highcharts-custom-events": "^3.0.3",
|
||||
"history": "^4.10.1",
|
||||
"jStat": "^1.8.5",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"localforage": "^1.7.4",
|
||||
"lodash": "^4.17.15",
|
||||
"moment": "^2.24.0",
|
||||
"path-to-regexp": "^2.4.0",
|
||||
"piwik-react-router": "^0.12.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"qs": "^6.9.4",
|
||||
"query-string": "^6.8.1",
|
||||
"react": "^16.8.6",
|
||||
"react-datetime": "^2.16.3",
|
||||
@@ -87,7 +90,7 @@
|
||||
"react-jsx-highcharts": "^3.6.1",
|
||||
"react-localize-redux": "^2.17.5",
|
||||
"react-markdown": "^4.1.0",
|
||||
"react-redux": "^7.1.3",
|
||||
"react-redux": "^7.2.0",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-scroll-to-component": "^1.0.2",
|
||||
"redux": "^3.7.2",
|
||||
@@ -95,7 +98,7 @@
|
||||
"redux-thunk": "^2.3.0",
|
||||
"reselect": "^3.0.1",
|
||||
"semantic-ui-css": "^2.4.1",
|
||||
"semantic-ui-react": "^0.83.0",
|
||||
"semantic-ui-react": "^0.88.2",
|
||||
"terser-webpack-plugin": "^1.3.0",
|
||||
"uuid": "^3.3.2",
|
||||
"xlsx": "^0.14.4"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { FeatureToggleProvider } from './common/useFeatureToggle'
|
||||
import FilterContextProvider from './components/FilterTray/FilterContextProvider'
|
||||
|
||||
const CommonContext = ({ children }) => (
|
||||
<FeatureToggleProvider>
|
||||
<FilterContextProvider>{children}</FilterContextProvider>
|
||||
</FeatureToggleProvider>
|
||||
)
|
||||
|
||||
CommonContext.propTypes = {
|
||||
children: PropTypes.node.isRequired
|
||||
}
|
||||
|
||||
export default CommonContext
|
||||
@@ -12,6 +12,7 @@ import { BASE_PATH } from './constants'
|
||||
import store from './store'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import Main from './components/Main'
|
||||
import CommonContext from './CommonContext'
|
||||
|
||||
const IS_PRODUCTION = process.env.NODE_ENV === 'production'
|
||||
|
||||
@@ -24,14 +25,16 @@ try {
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e) // eslint-disable-line
|
||||
console.log(e) // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<ErrorBoundary>
|
||||
<Main />
|
||||
</ErrorBoundary>
|
||||
<CommonContext>
|
||||
<ErrorBoundary>
|
||||
<Main />
|
||||
</ErrorBoundary>
|
||||
</CommonContext>
|
||||
</Provider>,
|
||||
document.getElementById('root')
|
||||
)
|
||||
|
||||
@@ -48,5 +48,16 @@ export default {
|
||||
eikä lukuvuoden alusta. Kohdasta **Näytä päivänä** voi valita tarkastelupäivän, joka on oletusarvoisesti tämänhetkinen päivä.
|
||||
<br> <br>
|
||||
Avoimen yliopiston suorituksia ei vielä lasketa mukaan.
|
||||
`,
|
||||
statusGraduated: `
|
||||
**Tilannekuva** näyttää "reaaliaikaisesti" eri tiedekuntien ja ohjelmien valmistumiset nykyisen lukuvuoden aikana
|
||||
verrattuna edelliseen lukuvuoteen. **Nuoli** kuvaa muutosta, paljonko valmistuneita on kertynyt tämänhetkisen lukuvuoden alusta tarkastelupäivään mennessä
|
||||
suhteessa edellisen lukuvuoden alusta vastaavaan tarkastelupäivään viime vuonna.
|
||||
<br> <br>
|
||||
Tilannekuvassa voi **porautua** kortteja klikkaamalla ohjelmatasolle. **Asetukset**-nappi avaa valikon, josta voi valita missä muodossa data halutaan nähdä. Valinta **Näytä edelliset vuodet** näyttää
|
||||
vuosittaisen valmistumis-kertymän tähän päivään mennessä vuonna X sekä koko lukuvuoden X valmistuneet muodossa "\`kerääntymä vuonna X / koko lukuvuoden X valmistuneet\`". Valinta **Näytä vuositasolla**
|
||||
laskee kertymän kalenterivuoden alusta,
|
||||
eikä lukuvuoden alusta. Kohdasta **Näytä päivänä** voi valita tarkastelupäivän, joka on oletusarvoisesti tämänhetkinen päivä.
|
||||
<br> <br>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -103,10 +103,8 @@ export default {
|
||||
täsmää puolivuosi-lukumäärien summan kanssa, johtuen tuplakirjauksista Oodissa.`
|
||||
},
|
||||
Students: {
|
||||
AccordionTitle: `**Students** Taulukko näyttää oletusarvoisesti vain
|
||||
opiskelijanumerot, mutta harmaata liukukytkintä klikkaamalla saa näkyviin opiskelijoiden nimet.`,
|
||||
Infobox: `**Students:** harmaata "show" painiketta klikkaamalla saa listan kaikista populaation opiskelijoista
|
||||
(huom. suodattimet vaikuttavat tähän listaan!). Taulukko näyttää oletusarvoisesti vain
|
||||
AccordionTitle: `**Students** Taulukko joka näyttää informaatiota opiskelijoista.`,
|
||||
Infobox: `**Students:** Taulukko näyttää oletusarvoisesti vain
|
||||
opiskelijanumerot, mutta harmaata liukukytkintä klikkaamalla saa näkyviin opiskelijoiden nimet.
|
||||
<br> <br>
|
||||
**GENERAL:**
|
||||
@@ -135,6 +133,24 @@ export default {
|
||||
joka sisältää kaikki "General" ja "Mandatory Courses" välilehtien tiedot.
|
||||
`
|
||||
},
|
||||
CoursePopulationStudents: {
|
||||
Infobox: `**Students:** harmaata _Show_-painiketta klikkaamalla saa listan kaikista populaation opiskelijoista.
|
||||
(Huom. suodattimet vaikuttavat tähän listaan!) Taulukko näyttää oletusarvoisesti vain
|
||||
opiskelijanumerot, mutta harmaata liukukytkintä klikkaamalla saa näkyviin opiskelijoiden nimet.
|
||||
<br> <br>
|
||||
**Student Number:**Klikkaamalla opiskelijanumeron vieressä olevaa sinistä nuolta, siirtyy "student statistic" -näkymään valitun opiskelijan osalta.
|
||||
<br> <br>
|
||||
**All Credits:** listaa opiskelijan kaikki opintopisteet (myös aiemmat suoritukset,
|
||||
eli ne jotka tehty ennen valittuun tarkastelujaksoon ja populaatioon kuulumista)
|
||||
<br> <br>
|
||||
**Tags:**
|
||||
Opiskelijoille voi luoda tageja ryhmittelyä varten kohdassa _Study Program > Overview > haluttu koulutusohjelma_.
|
||||
<br> <br>
|
||||
**Study Programme:** Opiskelijan koulutusohjelma.
|
||||
<br> <br>
|
||||
**Start Year at Uni:** Aloitusvuosi yliopistolla.
|
||||
`
|
||||
},
|
||||
CreditStatistics: {
|
||||
AccordionTitle: `**Credit Statistics** Opintopistekertymä-kategoria skaalautuvat valitun tarkastelujakson mukaan. `,
|
||||
Infobox: `Opintopistekertymä-kategoria skaalautuvat valitun tarkastelujakson mukaan.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { chunk, isEqual } from 'lodash'
|
||||
import qs from 'query-string'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { SIS_DB_FLAG } from './index'
|
||||
|
||||
import { SEARCH_HISTORY_VERSION } from '../constants'
|
||||
@@ -255,20 +256,28 @@ export const useLocalStorage = (key, initialValue) => {
|
||||
const item = window.localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : initialValue
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
console.log(error) // eslint-disable-line no-console
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
|
||||
const setValue = value => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value
|
||||
setStoredValue(valueToStore)
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
const setValue = useCallback(
|
||||
value => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value
|
||||
setStoredValue(valueToStore)
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||
} catch (error) {
|
||||
console.log(error) // eslint-disable-line no-console
|
||||
}
|
||||
},
|
||||
[key]
|
||||
)
|
||||
|
||||
return [storedValue, setValue]
|
||||
}
|
||||
|
||||
export const useLanguage = () => {
|
||||
const { language } = useSelector(({ settings }) => settings)
|
||||
return language
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import moment from 'moment'
|
||||
import jwtDecode from 'jwt-decode'
|
||||
import Datetime from 'react-datetime'
|
||||
import { filter, maxBy, sortBy } from 'lodash'
|
||||
import { filter, maxBy, sortBy, intersection } from 'lodash'
|
||||
import pathToRegexp from 'path-to-regexp'
|
||||
import { API_DATE_FORMAT, DISPLAY_DATE_FORMAT } from '../constants'
|
||||
import toskaLogo from '../assets/toska.png'
|
||||
@@ -46,6 +46,10 @@ export const images = {
|
||||
export const getUserRoles = roles => (roles ? roles.map(r => r.group_code) : [])
|
||||
export const getUserIsAdmin = roles => getUserRoles(roles).includes('admin')
|
||||
|
||||
export const checkUserAccess = (requiredRoles, userRoles) => {
|
||||
return intersection(userRoles, requiredRoles).length > 0
|
||||
}
|
||||
|
||||
export const containsOnlyNumbers = str => str.match('^\\d+$')
|
||||
|
||||
export const momentFromFormat = (date, format) => moment(date, format)
|
||||
@@ -80,7 +84,7 @@ export const getStudentTotalCredits = student => {
|
||||
}
|
||||
|
||||
export const getStudentGradeMean = student => {
|
||||
const gradedCourses = student.courses.filter(c => Number(c.grade))
|
||||
const gradedCourses = student.courses.filter(c => Number(c.grade) && !c.isStudyModuleCredit)
|
||||
const gradeTotal = gradedCourses.reduce((a, b) => a + Number(b.grade), 0)
|
||||
const mean = gradeTotal / gradedCourses.length || 0
|
||||
return mean
|
||||
@@ -124,7 +128,7 @@ export const cancelablePromise = promise => {
|
||||
if (hasCanceled) res(false)
|
||||
res(true)
|
||||
} catch (e) {
|
||||
console.log('e', e)
|
||||
console.log('e', e) // eslint-disable-line no-console
|
||||
rej(e)
|
||||
}
|
||||
})
|
||||
@@ -170,6 +174,7 @@ export const getNewestProgramme = (studyrights, studentNumber, studentToTargetCo
|
||||
const facultyCode = sr.faculty_code
|
||||
const studyrightElements = sr.studyright_elements.filter(
|
||||
srE =>
|
||||
elementDetails[srE.code] &&
|
||||
elementDetails[srE.code].type === 20 &&
|
||||
(studentToTargetCourseDateMap && studentNumber
|
||||
? moment(studentToTargetCourseDateMap[studentNumber]).isBetween(moment(srE.startdate), moment(srE.enddate))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Global definitions for Population Statistics HTTP GET query.
|
||||
*/
|
||||
import qs from 'qs'
|
||||
|
||||
const parseParams = location => qs.parse(location.search, { ignoreQueryPrefix: true })
|
||||
|
||||
/**
|
||||
* Get `months` parameter from query.
|
||||
* @param {object} location React-Router's `location` object.
|
||||
*/
|
||||
/* eslint-disable-next-line import/prefer-default-export */
|
||||
export const getMonths = location => parseParams(location).months
|
||||
@@ -0,0 +1,15 @@
|
||||
// Enumerate analytics categories to avoid having (to copy-paste) hard-coded names in code.
|
||||
import TSA from './tsa'
|
||||
|
||||
const { sendEvent } = TSA.Matomo
|
||||
|
||||
export const ANALYTICS_CATEGORIES = {
|
||||
populationStudents: 'Population students'
|
||||
}
|
||||
|
||||
export default Object.fromEntries(
|
||||
Object.entries(ANALYTICS_CATEGORIES).map(([catKey, catName]) => [
|
||||
catKey,
|
||||
(action, name, value) => sendEvent(catName, action, name, value)
|
||||
])
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { createContext, useState, useContext, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import localforage from 'localforage'
|
||||
|
||||
const storageName = 'oodikoneFeatureToggle'
|
||||
|
||||
const FeatureToggleContext = createContext([[], () => {}])
|
||||
|
||||
export const FeatureToggleProvider = ({ children }) => {
|
||||
const [state, setState] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
setState((await localforage.getItem(storageName)) || {})
|
||||
})()
|
||||
}, [])
|
||||
|
||||
return <FeatureToggleContext.Provider value={[state, setState]}>{children}</FeatureToggleContext.Provider>
|
||||
}
|
||||
|
||||
FeatureToggleProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for implementing boolean feature toggles.
|
||||
* Toggle value is saved in client and restored on reload. Use this hook for temporary
|
||||
* feature toggles.
|
||||
* @param {string} name Name of the toggle you want to use.
|
||||
*/
|
||||
export default name => {
|
||||
const [state, setState] = useContext(FeatureToggleContext)
|
||||
|
||||
const featureToggle = state[name] || false
|
||||
|
||||
/**
|
||||
* Set toggle value.
|
||||
* @param {bool} value New value
|
||||
*/
|
||||
const setFeatureToggle = value => {
|
||||
setState(prev => {
|
||||
const next = { ...prev, [name]: !!value }
|
||||
localforage.setItem(storageName, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch toggle value between `true` and `false`.
|
||||
*/
|
||||
const toggleFeatureToggle = () => {
|
||||
setState(prev => {
|
||||
const next = { ...prev, [name]: !prev[name] }
|
||||
localforage.setItem(storageName, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return [featureToggle, setFeatureToggle, toggleFeatureToggle]
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import { string, arrayOf } from 'prop-types'
|
||||
import Highcharts from 'highcharts'
|
||||
import ReactHighchart from 'react-highcharts'
|
||||
import { graphDataType } from '../../constants/types'
|
||||
import { graphDataType } from '../../../constants/types'
|
||||
import './multicolorBarChart.css'
|
||||
|
||||
const MulticolorBarChart = props => {
|
||||
@@ -7,8 +7,9 @@ import { connect } from 'react-redux'
|
||||
import { random } from 'lodash'
|
||||
import { logout as logoutAction } from '../../redux/auth'
|
||||
import { images } from '../../common'
|
||||
import MulticolorBarChart from '../MulticolorBarChart'
|
||||
import MulticolorBarChart from './MulticolorBarChart'
|
||||
|
||||
// add github handles of oodikone devs here
|
||||
const names = [
|
||||
'mluukkai',
|
||||
'jakousa',
|
||||
@@ -19,11 +20,15 @@ const names = [
|
||||
'mitiaine',
|
||||
'rimi',
|
||||
'esakemp',
|
||||
'woltsu'
|
||||
'woltsu',
|
||||
'cxcorp',
|
||||
'ajhaa',
|
||||
'joonashak'
|
||||
]
|
||||
|
||||
const dummyData = names.map(name => ({
|
||||
name,
|
||||
data: [0, 1, 2, 3, 4, 5, 6, 7].reduce((acc, i) => {
|
||||
data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].reduce((acc, i) => {
|
||||
acc[i] = (acc[i - 1] || 0) + (random(0, 3) === 0 ? 0 : random(10.0, 100.0))
|
||||
return acc
|
||||
}, [])
|
||||
@@ -53,7 +58,7 @@ const AccessDenied = ({ notEnabled, logout }) => {
|
||||
constructorType="stockChart"
|
||||
config={{
|
||||
title: {
|
||||
text: 'Students of Computer Science 2018-2019'
|
||||
text: 'Students of Computer Science 2018-2020'
|
||||
},
|
||||
|
||||
yAxis: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import ProtoC from './ProtoC'
|
||||
import ProtoC2 from './ProtoC2'
|
||||
import ProtoG from './ProtoG'
|
||||
import Status from './Status'
|
||||
import Graduated from './StatusGraduated'
|
||||
import relativeGraphImg from '../../assets/graph-relative.png'
|
||||
import absoluteGraphImg from '../../assets/graph-absolute.png'
|
||||
import timelineGraphImg from '../../assets/graph-timeline.png'
|
||||
@@ -56,6 +57,7 @@ const CoolDataScience = () => {
|
||||
<NavTile to={`${baseURI}/absolute`} title="Compare numerical trends" imageSrc={absoluteGraphImg} />
|
||||
<NavTile to={`${baseURI}/timeline`} title="Compare trends over time" imageSrc={timelineGraphImg} />
|
||||
<NavTile to={`${baseURI}/status`} title="See current status" imageSrc={statusImg} />
|
||||
<NavTile to={`${baseURI}/lol`} title="See currentsss status" imageSrc={statusImg} />
|
||||
</NavTiles>
|
||||
</div>
|
||||
<div style={{ margin: '0 auto', marginBottom: '300px', maxWidth: '75vw' }}>
|
||||
@@ -64,6 +66,7 @@ const CoolDataScience = () => {
|
||||
<Route exact path={`${baseURI}/absolute`} component={ProtoC2} />
|
||||
<Route exact path={`${baseURI}/timeline`} component={ProtoG} />
|
||||
<Route exact path={`${baseURI}/status`} component={Status} />
|
||||
<Route exact path={`${baseURI}/lol`} component={Graduated} />
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable */
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import Highcharts from 'highcharts'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import { Segment, Loader, Dimmer, Checkbox, Button, Message, Icon } from 'semantic-ui-react'
|
||||
@@ -7,11 +8,15 @@ import _ from 'lodash'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import HighchartsCustomEvents from 'highcharts-custom-events'
|
||||
|
||||
import { callApi } from '../../apiConnection'
|
||||
import TSA from '../../common/tsa'
|
||||
import InfoToolTips from '../../common/InfoToolTips'
|
||||
import { getProtoC, getProtoCProgramme } from '../../redux/coolDataScience'
|
||||
|
||||
HighchartsCustomEvents(Highcharts)
|
||||
|
||||
const ANALYTICS_CATEGORY = 'Trends'
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
|
||||
|
||||
const defaultConfig = (pointer = true) => {
|
||||
return {
|
||||
chart: {
|
||||
@@ -32,6 +37,11 @@ const defaultConfig = (pointer = true) => {
|
||||
enabled: false
|
||||
},
|
||||
tooltip: {
|
||||
crosshairs: {
|
||||
enabled: true,
|
||||
color: 'grey',
|
||||
width: '2px'
|
||||
},
|
||||
shared: true,
|
||||
followPointer: true,
|
||||
pointFormatter() {
|
||||
@@ -96,7 +106,7 @@ const makeClickableChartConfig = (sortedData, onPointClicked, org) => {
|
||||
}
|
||||
},
|
||||
mouseOut: function(e) {
|
||||
if (this.selectedTick) {
|
||||
if (this.selectedTick && this.selectedTick.label) {
|
||||
this.selectedTick.label.css({
|
||||
color: '#666666',
|
||||
fontWeight: 'normal'
|
||||
@@ -109,6 +119,8 @@ const makeClickableChartConfig = (sortedData, onPointClicked, org) => {
|
||||
return serie
|
||||
}
|
||||
|
||||
if (sortedData.length < 2) sortedData.push(...sortedData)
|
||||
|
||||
const series = [
|
||||
{
|
||||
color: '#7f8c8d',
|
||||
@@ -165,16 +177,69 @@ const makeClickableChartConfig = (sortedData, onPointClicked, org) => {
|
||||
categories: sortedData.map(data => data.name),
|
||||
labels: {
|
||||
events: {
|
||||
click: function() {
|
||||
click: function(e) {
|
||||
const clickedLabel = sortedData.find(data => data.name === this.value)
|
||||
setImmediate(() => onPointClicked(clickedLabel))
|
||||
},
|
||||
mouseover: function() {
|
||||
mouseover: function(e) {
|
||||
const findLabel = (x, ticks) => {
|
||||
return ticks[x]
|
||||
}
|
||||
const tick = this.axis ? findLabel(this.pos, this.axis.ticks) : null
|
||||
this.selectedTick = tick
|
||||
|
||||
// create custom tooltip since highcharts does not
|
||||
// allow tooltip open on label hover
|
||||
const customToolTip = series.reduce((acc, curr) => {
|
||||
const percentage = (curr.data[tick.pos].z * 100).toFixed(1)
|
||||
acc = `${acc} <span style="color:${curr.color}">●</span> ${curr.name}: <b>${percentage}%</b> (${curr.data[tick.pos].y})<br/>`
|
||||
return acc
|
||||
}, `${tick.label.textStr}<br/>`)
|
||||
|
||||
// create custom crosshair line because again
|
||||
// highcharts doesnt offer this functionality
|
||||
// 23.5 for eläin lääkis,pls fix this logic
|
||||
const { chart } = this
|
||||
const path = [
|
||||
'M',
|
||||
chart.plotLeft,
|
||||
Number(tick.label.element.attributes.y.value) - 4.5,
|
||||
'L',
|
||||
chart.plotLeft + chart.plotWidth,
|
||||
Number(tick.label.element.attributes.y.value) - 4.5,
|
||||
'M'
|
||||
]
|
||||
|
||||
this.chart.customLines = this.chart.renderer
|
||||
.path(path)
|
||||
.attr({
|
||||
'stroke-width': 2,
|
||||
stroke: 'grey'
|
||||
})
|
||||
.add()
|
||||
|
||||
// renders custom tooltip.
|
||||
this.chart.myLabel = this.chart.renderer
|
||||
.label(
|
||||
customToolTip,
|
||||
Number(tick.label.element.attributes.x.value) + 100,
|
||||
Number(tick.label.element.attributes.y.value) - 27,
|
||||
'rectangle'
|
||||
)
|
||||
.css({
|
||||
color: 'black'
|
||||
})
|
||||
.attr({
|
||||
fill: 'white',
|
||||
padding: 8,
|
||||
r: 1,
|
||||
opacity: 0.8,
|
||||
'stroke-width': 1,
|
||||
stroke: 'black'
|
||||
})
|
||||
.add()
|
||||
.toFront()
|
||||
|
||||
if (tick) {
|
||||
tick.label.css({
|
||||
color: 'black',
|
||||
@@ -183,12 +248,14 @@ const makeClickableChartConfig = (sortedData, onPointClicked, org) => {
|
||||
}
|
||||
},
|
||||
mouseout: function() {
|
||||
if (this.selectedTick) {
|
||||
if (this.selectedTick && this.selectedTick.label) {
|
||||
this.selectedTick.label.css({
|
||||
color: '#666666',
|
||||
fontWeight: 'normal'
|
||||
})
|
||||
this.selectedTick = null
|
||||
this.chart.myLabel.destroy()
|
||||
this.chart.customLines.destroy()
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -218,7 +285,6 @@ const makeNonClickableChartConfig = programme => {
|
||||
}
|
||||
const tick = this.series.xAxis ? findLabel(this.x, this.series.xAxis.ticks) : null
|
||||
this.selectedTick = tick
|
||||
|
||||
if (tick) {
|
||||
tick.label.css({
|
||||
color: 'black',
|
||||
@@ -227,7 +293,7 @@ const makeNonClickableChartConfig = programme => {
|
||||
}
|
||||
},
|
||||
mouseOut: function(e) {
|
||||
if (this.selectedTick) {
|
||||
if (this.selectedTick && this.selectedTick.label) {
|
||||
this.selectedTick.label.css({
|
||||
color: 'grey',
|
||||
fontWeight: 'normal'
|
||||
@@ -240,6 +306,8 @@ const makeNonClickableChartConfig = programme => {
|
||||
return serie
|
||||
}
|
||||
|
||||
if (programme.studytracks.length < 2) programme.studytracks.push(...programme.studytracks)
|
||||
|
||||
const series = [
|
||||
{
|
||||
color: '#7f8c8d',
|
||||
@@ -282,7 +350,88 @@ const makeNonClickableChartConfig = programme => {
|
||||
text: `2017-2019 aloittaneet uudet kandiopiskelijat<br/>${programme.name}`
|
||||
},
|
||||
xAxis: {
|
||||
categories: programme.studytracks.map(data => data.name)
|
||||
categories: programme.studytracks.map(data => data.name),
|
||||
labels: {
|
||||
events: {
|
||||
mouseover: function(e) {
|
||||
const findLabel = (x, ticks) => {
|
||||
return ticks[x]
|
||||
}
|
||||
|
||||
const tick = this.axis ? findLabel(this.pos, this.axis.ticks) : null
|
||||
this.selectedTick = tick
|
||||
|
||||
// create custom crosshair line because again
|
||||
// highcharts doesnt offer this functionality
|
||||
const { chart } = this
|
||||
const path = [
|
||||
'M',
|
||||
chart.plotLeft,
|
||||
Number(tick.label.element.attributes.y.value) - 4.5,
|
||||
'L',
|
||||
chart.plotLeft + chart.plotWidth,
|
||||
Number(tick.label.element.attributes.y.value) - 4.5,
|
||||
'M'
|
||||
]
|
||||
|
||||
this.chart.customLines = this.chart.renderer
|
||||
.path(path)
|
||||
.attr({
|
||||
'stroke-width': 2,
|
||||
stroke: 'grey'
|
||||
})
|
||||
.add()
|
||||
|
||||
// create custom tooltip since highcharts does not
|
||||
// allow tooltip open on label hover
|
||||
const customToolTip = series.reduce((acc, curr) => {
|
||||
const percentage = (curr.data[tick.pos].z * 100).toFixed(1)
|
||||
acc = `${acc} <span style="color:${curr.color}">●</span> ${curr.name}: <b>${percentage}%</b> (${curr.data[tick.pos].y})<br/>`
|
||||
return acc
|
||||
}, `${tick.label.textStr}<br/>`)
|
||||
|
||||
// renders custom tooltip.
|
||||
this.chart.myLabel = this.chart.renderer
|
||||
.label(
|
||||
customToolTip,
|
||||
Number(tick.label.element.attributes.x.value) + 100,
|
||||
Number(tick.label.element.attributes.y.value) - 27,
|
||||
'rectangle'
|
||||
)
|
||||
.css({
|
||||
color: 'black'
|
||||
})
|
||||
.attr({
|
||||
fill: 'white',
|
||||
padding: 8,
|
||||
r: 1,
|
||||
opacity: 0.8,
|
||||
'stroke-width': 1,
|
||||
stroke: 'black'
|
||||
})
|
||||
.add()
|
||||
.toFront()
|
||||
|
||||
if (tick) {
|
||||
tick.label.css({
|
||||
color: 'black',
|
||||
fontWeight: 'bold'
|
||||
})
|
||||
}
|
||||
},
|
||||
mouseout: function() {
|
||||
if (this.selectedTick && this.selectedTick.label) {
|
||||
this.selectedTick.label.css({
|
||||
color: '#666666',
|
||||
fontWeight: 'normal'
|
||||
})
|
||||
this.selectedTick = null
|
||||
this.chart.myLabel.destroy()
|
||||
this.chart.customLines.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
title: {
|
||||
@@ -345,9 +494,15 @@ const StudytrackDrilldown = ({ programme, sorter, sortDir }) => {
|
||||
return <StudytrackChart programme={programmeSortedStudytracks} />
|
||||
}
|
||||
|
||||
const ProtoC = ({ programme }) => {
|
||||
const [data, setData] = useState(null)
|
||||
const [isLoading, setLoading] = useState(true)
|
||||
const ProtoC = ({
|
||||
programme,
|
||||
getProtoCDispatch,
|
||||
getProtoCProgrammeDispatch,
|
||||
protoC,
|
||||
protoCProgramme,
|
||||
loadingProtoC,
|
||||
loadingProtoCProgramme
|
||||
}) => {
|
||||
const [sorter, setSorter] = useState('3v tahti')
|
||||
const [sortDir, setSortDir] = useState(1)
|
||||
const [drilldownOrg, setDrilldownOrg] = useState(null)
|
||||
@@ -359,52 +514,56 @@ const ProtoC = ({ programme }) => {
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!!programme) {
|
||||
setLoading(true)
|
||||
const res = await callApi('/cool-data-science/proto-c-data-programme', 'get', null, {
|
||||
include_old_attainments: includeOldAttainments.toString(),
|
||||
exclude_non_enrolled: excludeNonEnrolled.toString(),
|
||||
getProtoCProgrammeDispatch({
|
||||
includeOldAttainments: includeOldAttainments.toString(),
|
||||
excludeNonEnrolled: excludeNonEnrolled.toString(),
|
||||
code: programme
|
||||
})
|
||||
setDrilldownProgramme(res.data)
|
||||
setLoading(false)
|
||||
} else {
|
||||
setLoading(true)
|
||||
const res = await callApi('/cool-data-science/proto-c-data', 'get', null, {
|
||||
include_old_attainments: includeOldAttainments.toString(),
|
||||
exclude_non_enrolled: excludeNonEnrolled.toString(),
|
||||
code: programme
|
||||
getProtoCDispatch({
|
||||
includeOldAttainments: includeOldAttainments.toString(),
|
||||
excludeNonEnrolled: excludeNonEnrolled.toString()
|
||||
})
|
||||
setData(res.data)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [includeOldAttainments, excludeNonEnrolled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!!programme) {
|
||||
setDrilldownProgramme(protoCProgramme)
|
||||
}
|
||||
}, [protoCProgramme])
|
||||
|
||||
const handleOldAttainmentToggled = useCallback(() => {
|
||||
setIncludeOldAttainments(previous => !previous)
|
||||
sendAnalytics('C Toggled old attainments', 'ProtoC')
|
||||
}, [])
|
||||
|
||||
const handleExcludeNonEnrolledToggled = useCallback(() => {
|
||||
setExcludeNonEnrolled(previous => !previous)
|
||||
sendAnalytics('C Toggled non enrolled', 'ProtoC')
|
||||
}, [])
|
||||
|
||||
const handleOrgClicked = useCallback(org => {
|
||||
setDrilldownOrg(org)
|
||||
setDrilldownProgramme(null)
|
||||
sendAnalytics('C Org drilldown clicked', 'ProtoC')
|
||||
}, [])
|
||||
|
||||
const handleProgrammeClicked = useCallback(programme => {
|
||||
setDrilldownProgramme(programme)
|
||||
sendAnalytics('C Programme drilldown clicked', 'ProtoC')
|
||||
}, [])
|
||||
|
||||
const sortedOrgs = useMemo(() => {
|
||||
return Object.values(data || {}).sort((a, b) => sorters[sorter](a, b) * sortDir)
|
||||
}, [data, sorter, sortDir])
|
||||
return Object.values(protoC || {}).sort((a, b) => sorters[sorter](a, b) * sortDir)
|
||||
}, [protoC, sorter, sortDir])
|
||||
|
||||
const handleClick = sorterName => {
|
||||
if (sorterName === sorter) setSortDir(-1 * sortDir)
|
||||
setSorter(sorterName)
|
||||
sendAnalytics('C Sorter clicked', 'ProtoC')
|
||||
}
|
||||
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
@@ -413,8 +572,9 @@ const ProtoC = ({ programme }) => {
|
||||
const sorterNames = Object.keys(sorters)
|
||||
.map(sorterName => sorterName)
|
||||
.sort((a, b) => {
|
||||
if (a === 'nimi') return false
|
||||
return a > b
|
||||
if (b === 'nimi') return 1
|
||||
if (a === 'nimi') return -1
|
||||
return a > b ? 1 : -1
|
||||
})
|
||||
|
||||
const SorterButtons = () => (
|
||||
@@ -468,11 +628,22 @@ const ProtoC = ({ programme }) => {
|
||||
</>
|
||||
)
|
||||
|
||||
const DrilldownMessage = () => (
|
||||
<Message color='blue'
|
||||
content="Graafissa pystyy pisteitä klikkaamalla porautumaan ensin ohjelmatasolle ja edelleen opintosuuntatasolle.
|
||||
Uusi graafi avautuu nykyisen alle."
|
||||
/>
|
||||
)
|
||||
|
||||
if (!!programme && drilldownProgramme) {
|
||||
return (
|
||||
<Segment>
|
||||
<SorterButtons />
|
||||
<StudytrackDrilldown programme={drilldownProgramme} sorter={sorter} sortDir={sortDir} />
|
||||
<Segment placeholder={loadingProtoC || loadingProtoCProgramme} vertical>
|
||||
<Dimmer inverted active={loadingProtoC || loadingProtoCProgramme} />
|
||||
<Loader active={loadingProtoC || loadingProtoCProgramme} />
|
||||
<StudytrackDrilldown programme={drilldownProgramme} sorter={sorter} sortDir={sortDir} />
|
||||
</Segment>
|
||||
<RenderBelowGraph />
|
||||
</Segment>
|
||||
)
|
||||
@@ -481,14 +652,17 @@ const ProtoC = ({ programme }) => {
|
||||
return (
|
||||
<Segment>
|
||||
<div align="center">
|
||||
<h2>Prototyyppi: Suhteellinen tavoiteaikaerittely, 2017-2019 aloittaneet</h2>
|
||||
<h2>Kandiohjelmat: Suhteellinen tavoiteaikaerittely, 2017-2019 aloittaneet</h2>
|
||||
</div>
|
||||
<DrilldownMessage />
|
||||
<SorterButtons />
|
||||
<Segment placeholder={isLoading} vertical>
|
||||
<Dimmer inverted active={isLoading} />
|
||||
<Loader active={isLoading} />
|
||||
{!isLoading && data && <OrgChart orgs={sortedOrgs} onOrgClicked={handleOrgClicked} />}
|
||||
{!isLoading && data && drilldownOrg && (
|
||||
<Segment placeholder={loadingProtoC || loadingProtoCProgramme} vertical>
|
||||
<Dimmer inverted active={loadingProtoC || loadingProtoCProgramme} />
|
||||
<Loader active={loadingProtoC || loadingProtoCProgramme} />
|
||||
{!(loadingProtoC || loadingProtoCProgramme) && protoC && (
|
||||
<OrgChart orgs={sortedOrgs} onOrgClicked={handleOrgClicked} />
|
||||
)}
|
||||
{!(loadingProtoC || loadingProtoCProgramme) && protoC && drilldownOrg && (
|
||||
<ProgrammeDrilldown
|
||||
org={drilldownOrg}
|
||||
sorter={sorter}
|
||||
@@ -496,7 +670,7 @@ const ProtoC = ({ programme }) => {
|
||||
onProgrammeClicked={handleProgrammeClicked}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && data && drilldownProgramme && (
|
||||
{!(loadingProtoC || loadingProtoCProgramme) && protoC && drilldownProgramme && (
|
||||
<StudytrackDrilldown programme={drilldownProgramme} sorter={sorter} sortDir={sortDir} />
|
||||
)}
|
||||
</Segment>
|
||||
@@ -505,4 +679,14 @@ const ProtoC = ({ programme }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default ProtoC
|
||||
const mapStateToProps = ({ coolDataScience }) => ({
|
||||
protoC: coolDataScience.data.protoC || {},
|
||||
protoCProgramme: coolDataScience.data.protoCProgramme || {},
|
||||
loadingProtoC: coolDataScience.pending.protoC,
|
||||
loadingProtoCProgramme: coolDataScience.pending.protoCProgramme
|
||||
})
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ getProtoCDispatch: getProtoC, getProtoCProgrammeDispatch: getProtoCProgramme }
|
||||
)(ProtoC)
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import PropTypes, { func, bool, shape, string } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import Highcharts from 'highcharts'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import { Segment, Loader, Dimmer, Checkbox, Button, Message } from 'semantic-ui-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import HighchartsCustomEvents from 'highcharts-custom-events'
|
||||
|
||||
import { callApi } from '../../apiConnection'
|
||||
import TSA from '../../common/tsa'
|
||||
import InfoToolTips from '../../common/InfoToolTips'
|
||||
import { getProtoC, getProtoCProgramme } from '../../redux/coolDataScience'
|
||||
|
||||
HighchartsCustomEvents(Highcharts)
|
||||
|
||||
const ANALYTICS_CATEGORY = 'Trends'
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
|
||||
|
||||
const defaultConfig = () => {
|
||||
return {
|
||||
@@ -68,75 +76,162 @@ const defaultConfig = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const changeSeries = (chart, categories, series) => {
|
||||
chart.showLoading()
|
||||
|
||||
chart.xAxis[0].setCategories(categories)
|
||||
while (chart.series.length > 0) {
|
||||
chart.series[0].remove(false)
|
||||
const makeConfig = (data, sorter, type = 'column', clickHandler) => {
|
||||
const addMouseOverHandler = serie => {
|
||||
serie.point = {
|
||||
events: {
|
||||
mouseOver() {
|
||||
const findLabel = (x, ticks) => {
|
||||
return ticks[x]
|
||||
}
|
||||
const tick = this.series.xAxis ? findLabel(this.x, this.series.xAxis.ticks) : null
|
||||
this.selectedTick = tick
|
||||
if (tick) {
|
||||
tick.label.css({
|
||||
color: 'black',
|
||||
fontWeight: 'bold'
|
||||
})
|
||||
}
|
||||
},
|
||||
mouseOut() {
|
||||
if (this.selectedTick && this.selectedTick.label) {
|
||||
this.selectedTick.label.css({
|
||||
color: 'grey',
|
||||
fontWeight: 'normal'
|
||||
})
|
||||
this.selectedTick = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return serie
|
||||
}
|
||||
|
||||
series.forEach(serie => chart.addSeries(serie, false))
|
||||
|
||||
chart.redraw()
|
||||
chart.hideLoading()
|
||||
}
|
||||
|
||||
const makeConfig = (organisations, sorter, type = 'column') => {
|
||||
const orgSeries = [
|
||||
{
|
||||
color: '#7f8c8d',
|
||||
name: 'tällä hetkellä peruutettu',
|
||||
data: organisations.map(org => ({
|
||||
y: org.currentlyCancelled,
|
||||
data: data.map(entry => ({
|
||||
y: entry.currentlyCancelled,
|
||||
// pass % of total as z so we can display it in the tooltip
|
||||
z: org.currentlyCancelled / org.totalStudents
|
||||
z: entry.currentlyCancelled / entry.totalStudents
|
||||
}))
|
||||
},
|
||||
{
|
||||
color: '#ff7979',
|
||||
name: 'ei tahdissa',
|
||||
data: organisations.map(org => ({
|
||||
data: data.map(entry => ({
|
||||
custom: {
|
||||
orgCode: org.code
|
||||
code: entry.code
|
||||
},
|
||||
y: org.totalStudents - org.students3y - org.students4y - org.currentlyCancelled,
|
||||
y: entry.totalStudents - entry.students3y - entry.students4y - entry.currentlyCancelled,
|
||||
// pass % of total as z so we can display it in the tooltip
|
||||
z: (org.totalStudents - org.students3y - org.students4y - org.currentlyCancelled) / org.totalStudents
|
||||
z: (entry.totalStudents - entry.students3y - entry.students4y - entry.currentlyCancelled) / entry.totalStudents
|
||||
}))
|
||||
},
|
||||
{
|
||||
color: '#f9ca24',
|
||||
name: '4v tahdissa',
|
||||
data: organisations.map(org => ({
|
||||
data: data.map(entry => ({
|
||||
custom: {
|
||||
orgCode: org.code
|
||||
code: entry.code
|
||||
},
|
||||
y: org.students4y,
|
||||
z: org.students4y / org.totalStudents
|
||||
y: entry.students4y,
|
||||
z: entry.students4y / entry.totalStudents
|
||||
}))
|
||||
},
|
||||
{
|
||||
color: '#6ab04c',
|
||||
name: '3v tahdissa',
|
||||
data: organisations.map(org => ({
|
||||
data: data.map(entry => ({
|
||||
custom: {
|
||||
orgCode: org.code
|
||||
code: entry.code
|
||||
},
|
||||
y: org.students3y,
|
||||
z: org.students3y / org.totalStudents
|
||||
y: entry.students3y,
|
||||
z: entry.students3y / entry.totalStudents
|
||||
}))
|
||||
}
|
||||
]
|
||||
].map(addMouseOverHandler)
|
||||
|
||||
const orgCategories = organisations.map(org => org.name)
|
||||
const categories = data.map(entry => entry.name)
|
||||
|
||||
return Highcharts.merge(defaultConfig(), {
|
||||
chart: {
|
||||
type
|
||||
},
|
||||
xAxis: {
|
||||
categories: orgCategories
|
||||
categories,
|
||||
labels: {
|
||||
events: {
|
||||
click() {
|
||||
const { chart } = this
|
||||
chart.myLabel.destroy()
|
||||
const clickedLabel = data.find(entry => entry.name === this.value)
|
||||
if (clickedLabel) clickHandler(clickedLabel.programmes || clickedLabel.studytracks)
|
||||
sendAnalytics('Drilldown clicked', 'ProtoC2')
|
||||
},
|
||||
mouseover() {
|
||||
const findLabel = (x, ticks) => {
|
||||
return ticks[x]
|
||||
}
|
||||
const tick = this.axis ? findLabel(this.pos, this.axis.ticks) : null
|
||||
this.selectedTick = tick
|
||||
|
||||
// create custom tooltip since highcharts does not
|
||||
// allow tooltip open on label hover
|
||||
const customToolTip = orgSeries.reduce((acc, curr) => {
|
||||
const percentage = (curr.data[tick.pos].z * 100).toFixed(1)
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
acc = `${acc} <span style="color:${curr.color}">●</span> ${curr.name}: <b>${curr.data[tick.pos].y}</b> (${percentage}%)<br/>`
|
||||
return acc
|
||||
}, `${tick.label.textStr}<br/>`)
|
||||
|
||||
// renders custom tooltip. 320 and (tick.axis...) defines the position of tooltip
|
||||
// if you have better ideas/ways to handle this please feel free to fix since
|
||||
// this does not work all that well
|
||||
this.chart.myLabel = this.chart.renderer
|
||||
.label(
|
||||
customToolTip,
|
||||
320,
|
||||
(tick.axis.height / Object.keys(this.axis.ticks).length) * tick.pos,
|
||||
'rectangle'
|
||||
)
|
||||
.css({
|
||||
color: 'black'
|
||||
})
|
||||
.attr({
|
||||
fill: 'white',
|
||||
padding: 8,
|
||||
r: 1,
|
||||
opacity: 0.8,
|
||||
'stroke-width': 1,
|
||||
stroke: 'black'
|
||||
})
|
||||
.add()
|
||||
.toFront()
|
||||
|
||||
if (tick) {
|
||||
tick.label.css({
|
||||
color: 'black',
|
||||
fontWeight: 'bold'
|
||||
})
|
||||
}
|
||||
},
|
||||
mouseout() {
|
||||
if (this.selectedTick && this.selectedTick.label) {
|
||||
this.selectedTick.label.css({
|
||||
color: '#666666',
|
||||
fontWeight: 'normal'
|
||||
})
|
||||
this.selectedTick = null
|
||||
this.chart.myLabel.destroy()
|
||||
}
|
||||
}
|
||||
},
|
||||
style: {
|
||||
cursor: 'pointer'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: orgSeries,
|
||||
plotOptions: {
|
||||
@@ -146,51 +241,11 @@ const makeConfig = (organisations, sorter, type = 'column') => {
|
||||
events: {
|
||||
click(e) {
|
||||
const { point } = e
|
||||
const { chart } = this.series
|
||||
|
||||
if (point.custom && point.custom.orgCode) {
|
||||
if (point.custom && point.custom.code) {
|
||||
// clicked on top-level, drill down
|
||||
const org = organisations.find(org => org.code === point.custom.orgCode)
|
||||
const programmes = [...org.programmes].sort(sorter)
|
||||
changeSeries(chart, programmes.map(p => p.name), [
|
||||
{
|
||||
color: '#7f8c8d',
|
||||
name: 'tällä hetkellä peruutettu',
|
||||
data: programmes.map(p => ({
|
||||
y: p.currentlyCancelled,
|
||||
// pass % of total as z so we can display it in the tooltip
|
||||
z: p.currentlyCancelled / p.totalStudents
|
||||
}))
|
||||
},
|
||||
{
|
||||
color: '#ff7979',
|
||||
name: 'ei tahdissa',
|
||||
data: programmes.map(p => ({
|
||||
y: p.totalStudents - p.students3y - p.students4y - p.currentlyCancelled,
|
||||
// pass % of total as z so we can display it in the tooltip
|
||||
z: (p.totalStudents - p.students3y - p.students4y - p.currentlyCancelled) / p.totalStudents
|
||||
}))
|
||||
},
|
||||
{
|
||||
color: '#f9ca24',
|
||||
name: '4v tahdissa',
|
||||
data: programmes.map(p => ({
|
||||
y: p.students4y,
|
||||
z: p.students4y / p.totalStudents
|
||||
}))
|
||||
},
|
||||
{
|
||||
color: '#6ab04c',
|
||||
name: '3v tahdissa',
|
||||
data: programmes.map(p => ({
|
||||
y: p.students3y,
|
||||
z: p.students3y / p.totalStudents
|
||||
}))
|
||||
}
|
||||
])
|
||||
} else {
|
||||
// drill up
|
||||
changeSeries(chart, orgCategories, orgSeries)
|
||||
sendAnalytics('Org drilldown clicked', 'ProtoC2')
|
||||
const datapoint = data.find(entry => entry.code === point.custom.code)
|
||||
clickHandler(datapoint.programmes || datapoint.studytracks)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,7 +255,7 @@ const makeConfig = (organisations, sorter, type = 'column') => {
|
||||
})
|
||||
}
|
||||
|
||||
const countNotInTarget = org => org.totalStudents - org.students4y - org.students3y - org.currentlyCancelled
|
||||
const countNotInTarget = entry => entry.totalStudents - entry.students4y - entry.students3y - entry.currentlyCancelled
|
||||
const sorters = {
|
||||
nimi: (a, b) => a.name.localeCompare(b.name),
|
||||
'4v tahti': (a, b) => a.students4y - b.students4y,
|
||||
@@ -209,67 +264,110 @@ const sorters = {
|
||||
peruutettu: (a, b) => a.currentlyCancelled - b.currentlyCancelled
|
||||
}
|
||||
|
||||
const OrgChart = React.memo(({ orgs, sorter, isSideways }) => {
|
||||
return <ReactHighcharts highcharts={Highcharts} config={makeConfig(orgs, sorter, isSideways ? 'bar' : 'column')} />
|
||||
const ClickableChart = React.memo(({ data, sorter, isSideways, clickHandler }) => {
|
||||
return (
|
||||
<ReactHighcharts
|
||||
highcharts={Highcharts}
|
||||
config={makeConfig(data, sorter, isSideways ? 'bar' : 'column', clickHandler)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
OrgChart.defaultProps = {
|
||||
const NonClickableChart = React.memo(({ data, sorter, isSideways }) => {
|
||||
if (data.length < 1)
|
||||
return (
|
||||
<Segment>
|
||||
<h3 align="center">no studytrack data available for selected programme</h3>
|
||||
</Segment>
|
||||
)
|
||||
return <ReactHighcharts highcharts={Highcharts} config={makeConfig(data, sorter, isSideways ? 'bar' : 'column')} />
|
||||
})
|
||||
|
||||
NonClickableChart.defaultProps = {
|
||||
isSideways: false
|
||||
}
|
||||
|
||||
OrgChart.propTypes = {
|
||||
orgs: PropTypes.arrayOf(
|
||||
NonClickableChart.propTypes = {
|
||||
data: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
code: PropTypes.string,
|
||||
students3y: PropTypes.number,
|
||||
students4y: PropTypes.number,
|
||||
totalStudents: PropTypes.number,
|
||||
currentlyCancelled: PropTypes.number,
|
||||
programmes: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
students3y: PropTypes.number,
|
||||
students4y: PropTypes.number,
|
||||
totalStudents: PropTypes.number,
|
||||
currentlyCancelled: PropTypes.number
|
||||
})
|
||||
).isRequired
|
||||
currentlyCancelled: PropTypes.number
|
||||
})
|
||||
).isRequired,
|
||||
sorter: PropTypes.func.isRequired,
|
||||
isSideways: PropTypes.bool
|
||||
}
|
||||
|
||||
const ProtoC = () => {
|
||||
const [data, setData] = useState(null)
|
||||
const [isLoading, setLoading] = useState(true)
|
||||
ClickableChart.defaultProps = {
|
||||
isSideways: false
|
||||
}
|
||||
|
||||
ClickableChart.propTypes = {
|
||||
data: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
code: PropTypes.string,
|
||||
students3y: PropTypes.number,
|
||||
students4y: PropTypes.number,
|
||||
totalStudents: PropTypes.number,
|
||||
currentlyCancelled: PropTypes.number
|
||||
})
|
||||
).isRequired,
|
||||
sorter: PropTypes.func.isRequired,
|
||||
clickHandler: func.isRequired,
|
||||
isSideways: PropTypes.bool
|
||||
}
|
||||
|
||||
const ProtoC = ({
|
||||
getProtoCDispatch,
|
||||
getProtoCProgrammeDispatch,
|
||||
data,
|
||||
isLoading,
|
||||
programme = 'KH50_005',
|
||||
protoCProgrammeData,
|
||||
protoCProgrammeLoading
|
||||
}) => {
|
||||
const [sorter, setSorter] = useState('3v tahti')
|
||||
const [sortDir, setSortDir] = useState(1)
|
||||
const [drilldownOrg, setDrilldownOrg] = useState(null)
|
||||
const [drilldownProgramme, setDrilldownProgramme] = useState(null)
|
||||
|
||||
const [includeOldAttainments, setIncludeOldAttainments] = useState(false)
|
||||
const [excludeNonEnrolled, setExcludeNonEnrolled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
const res = await callApi('/cool-data-science/proto-c-data', 'get', null, {
|
||||
include_old_attainments: includeOldAttainments.toString(),
|
||||
exclude_non_enrolled: excludeNonEnrolled.toString()
|
||||
if (programme) {
|
||||
getProtoCProgrammeDispatch({
|
||||
includeOldAttainments: includeOldAttainments.toString(),
|
||||
excludeNonEnrolled: excludeNonEnrolled.toString(),
|
||||
code: programme
|
||||
})
|
||||
} else {
|
||||
getProtoCDispatch({
|
||||
includeOldAttainments: includeOldAttainments.toString(),
|
||||
excludeNonEnrolled: excludeNonEnrolled.toString()
|
||||
})
|
||||
setData(res.data)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
load()
|
||||
}, [includeOldAttainments, excludeNonEnrolled])
|
||||
|
||||
useEffect(() => {
|
||||
if (programme) {
|
||||
setDrilldownProgramme(protoCProgrammeData.studytracks)
|
||||
}
|
||||
}, [protoCProgrammeData])
|
||||
|
||||
const handleOldAttainmentToggled = useCallback(() => {
|
||||
setIncludeOldAttainments(previous => !previous)
|
||||
sendAnalytics('C2 Toggled old attainments', 'ProtoC2')
|
||||
}, [])
|
||||
|
||||
const handleExcludeNonEnrolledToggled = useCallback(() => {
|
||||
setExcludeNonEnrolled(previous => !previous)
|
||||
sendAnalytics('C2 Toggled non enrolled', 'ProtoC2')
|
||||
}, [])
|
||||
|
||||
const currentSorter = useCallback((a, b) => sorters[sorter](a, b) * sortDir, [sorter, sortDir])
|
||||
@@ -278,25 +376,106 @@ const ProtoC = () => {
|
||||
return Object.values(data || {}).sort(currentSorter)
|
||||
}, [data, currentSorter])
|
||||
|
||||
const sortedProgrammes = useMemo(() => {
|
||||
return (drilldownOrg || []).sort(currentSorter)
|
||||
}, [drilldownOrg, currentSorter])
|
||||
|
||||
const sortedStudytracks = useMemo(() => {
|
||||
return (drilldownProgramme || []).sort(currentSorter)
|
||||
}, [drilldownProgramme, currentSorter])
|
||||
|
||||
const drilldownOrgClick = useCallback(org => {
|
||||
setDrilldownOrg(org)
|
||||
})
|
||||
|
||||
const drilldownProgrammeClick = useCallback(programme => {
|
||||
setDrilldownProgramme(programme)
|
||||
})
|
||||
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
|
||||
const handleClick = sorterName => {
|
||||
if (sorterName === sorter) setSortDir(-1 * sortDir)
|
||||
setSorter(sorterName)
|
||||
sendAnalytics('C2 Sorter clicked', 'ProtoC2')
|
||||
}
|
||||
|
||||
const DrilldownMessage = () => (
|
||||
<Message
|
||||
color="blue"
|
||||
content=" Graafissa pystyy palkkeja klikkaamalla porautumaan kunkin tiedekunnan ohjelmatasolle.
|
||||
Ohjelmatasolla jotakin palkkia klikkaamalla pystyy porautumaan opintosuuntaus tasolle."
|
||||
/>
|
||||
)
|
||||
|
||||
const sorterNames = Object.keys(sorters)
|
||||
.map(sorterName => sorterName)
|
||||
.sort((a, b) => {
|
||||
if (a === 'nimi') return false
|
||||
return a > b
|
||||
if (b === 'nimi') return 1
|
||||
if (a === 'nimi') return -1
|
||||
return a > b ? 1 : -1
|
||||
})
|
||||
|
||||
if (programme && drilldownProgramme) {
|
||||
return (
|
||||
<Segment>
|
||||
<div align="center">
|
||||
<h2>Prototyyppi: Tavoiteaikaerittely, 2017-2019 aloittaneet</h2>
|
||||
</div>
|
||||
<div align="center" style={{ marginTop: '10px' }}>
|
||||
<Button.Group>
|
||||
<Button style={{ cursor: 'default' }} active color="black">
|
||||
Sort by:
|
||||
</Button>
|
||||
{sorterNames.map(sorterName => (
|
||||
<Button
|
||||
basic={sorter !== sorterName}
|
||||
color={sorter === sorterName ? 'blue' : 'black'}
|
||||
key={sorterName}
|
||||
active={sorter === sorterName}
|
||||
onClick={() => handleClick(sorterName)}
|
||||
style={{ borderRadius: '1px' }}
|
||||
icon={sortDir === 1 ? 'triangle down' : 'triangle up'}
|
||||
content={sorterName}
|
||||
/>
|
||||
))}
|
||||
</Button.Group>
|
||||
</div>
|
||||
<Segment placeholder={isLoading} vertical>
|
||||
<Dimmer inverted active={isLoading} />
|
||||
<Loader active={isLoading} />
|
||||
{!protoCProgrammeLoading && protoCProgrammeData && (
|
||||
<>
|
||||
<NonClickableChart data={drilldownProgramme} sorter={currentSorter} isSideways />
|
||||
</>
|
||||
)}
|
||||
<div align="center">
|
||||
<Checkbox
|
||||
label="Include only at least once enrolled students"
|
||||
onChange={handleExcludeNonEnrolledToggled}
|
||||
checked={excludeNonEnrolled}
|
||||
/>
|
||||
<Checkbox
|
||||
style={{ marginLeft: '10px' }}
|
||||
label="Include old attainments"
|
||||
onChange={handleOldAttainmentToggled}
|
||||
checked={includeOldAttainments}
|
||||
/>
|
||||
</div>
|
||||
<Message>
|
||||
<ReactMarkdown source={CoolDataScience.protoC2} escapeHtml={false} />
|
||||
</Message>
|
||||
</Segment>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<div align="center">
|
||||
<h2>Prototyyppi: Tavoiteaikaerittely, 2017-2019 aloittaneet</h2>
|
||||
<h2>Kandiohjelmat: Tavoiteaikaerittely, 2017-2019 aloittaneet</h2>
|
||||
</div>
|
||||
<DrilldownMessage />
|
||||
<div align="center" style={{ marginTop: '10px' }}>
|
||||
<Button.Group>
|
||||
<Button style={{ cursor: 'default' }} active color="black">
|
||||
@@ -321,7 +500,16 @@ const ProtoC = () => {
|
||||
<Loader active={isLoading} />
|
||||
{!isLoading && data && (
|
||||
<>
|
||||
<OrgChart orgs={sortedOrgs} sorter={currentSorter} isSideways />
|
||||
<ClickableChart data={sortedOrgs} sorter={currentSorter} isSideways clickHandler={drilldownOrgClick} />
|
||||
{drilldownOrg && (
|
||||
<ClickableChart
|
||||
data={sortedProgrammes}
|
||||
sorter={currentSorter}
|
||||
isSideways
|
||||
clickHandler={drilldownProgrammeClick}
|
||||
/>
|
||||
)}
|
||||
{drilldownProgramme && <NonClickableChart data={sortedStudytracks} sorter={currentSorter} isSideways />}
|
||||
</>
|
||||
)}
|
||||
<div align="center">
|
||||
@@ -345,4 +533,28 @@ const ProtoC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default ProtoC
|
||||
ProtoC.defaultProps = {
|
||||
programme: ''
|
||||
}
|
||||
|
||||
ProtoC.propTypes = {
|
||||
isLoading: bool.isRequired,
|
||||
data: shape({}).isRequired,
|
||||
getProtoCDispatch: func.isRequired,
|
||||
getProtoCProgrammeDispatch: func.isRequired,
|
||||
protoCProgrammeLoading: bool.isRequired,
|
||||
protoCProgrammeData: shape({}).isRequired,
|
||||
programme: string
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ coolDataScience }) => ({
|
||||
data: coolDataScience.data.protoC,
|
||||
protoCProgrammeData: coolDataScience.data.protoCProgramme,
|
||||
isLoading: coolDataScience.pending.protoC,
|
||||
protoCProgrammeLoading: coolDataScience.pending.protoCProgramme
|
||||
})
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ getProtoCDispatch: getProtoC, getProtoCProgrammeDispatch: getProtoCProgramme }
|
||||
)(ProtoC)
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
/* eslint-disable */
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Highcharts from 'highcharts'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import { Table, Checkbox, Form } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import { dataPropTypes } from './data'
|
||||
|
||||
const defaultConfig = () => ({
|
||||
chart: {
|
||||
backgroundColor: null,
|
||||
borderWidth: 0,
|
||||
type: 'area',
|
||||
margin: [2, 0, 2, 0],
|
||||
width: 120,
|
||||
height: 20,
|
||||
style: {
|
||||
overflow: 'visible'
|
||||
},
|
||||
// small optimalization, saves 1-2 ms each sparkline
|
||||
skipClone: true
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
},
|
||||
credits: {
|
||||
enabled: false
|
||||
},
|
||||
xAxis: {
|
||||
labels: {
|
||||
enabled: false
|
||||
},
|
||||
title: {
|
||||
text: null
|
||||
},
|
||||
startOnTick: false,
|
||||
endOnTick: false,
|
||||
tickPositions: []
|
||||
},
|
||||
yAxis: {
|
||||
endOnTick: false,
|
||||
startOnTick: false,
|
||||
labels: {
|
||||
enabled: false
|
||||
},
|
||||
title: {
|
||||
text: null
|
||||
},
|
||||
tickPositions: [0]
|
||||
},
|
||||
legend: {
|
||||
enabled: false
|
||||
},
|
||||
tooltip: {
|
||||
hideDelay: 0,
|
||||
outside: true,
|
||||
shared: true
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
animation: false,
|
||||
lineWidth: 1,
|
||||
shadow: false,
|
||||
states: {
|
||||
hover: {
|
||||
lineWidth: 1
|
||||
}
|
||||
},
|
||||
marker: {
|
||||
radius: 1,
|
||||
states: {
|
||||
hover: {
|
||||
radius: 2
|
||||
}
|
||||
}
|
||||
},
|
||||
fillOpacity: 0.25
|
||||
},
|
||||
column: {
|
||||
negativeColor: '#910000',
|
||||
borderColor: 'silver'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// xAxis.categories: start year
|
||||
// series.pointStart: 1
|
||||
// series.data: the numbers lol
|
||||
|
||||
const makeSegmentConfig = ({ facultyRows, relative, color, header }) => {
|
||||
const startDates = facultyRows.map(row => new Date(row[2]))
|
||||
const config = {
|
||||
xAxis: {
|
||||
categories: startDates
|
||||
},
|
||||
series: [
|
||||
{
|
||||
color,
|
||||
data: facultyRows.map(row => row[3])
|
||||
}
|
||||
],
|
||||
tooltip: {
|
||||
headerFormat: `<span style="font-size:10px">${header}:</span><br/>`,
|
||||
pointFormatter() {
|
||||
const y = relative ? `${(this.y * 100).toFixed(1)}%` : `${this.y}`
|
||||
return `<span style="color:${this.color}">●</span> ${this.category.getFullYear()}: <b>${y}</b><br/>`
|
||||
}
|
||||
}
|
||||
}
|
||||
return Highcharts.merge(defaultConfig(), config)
|
||||
}
|
||||
|
||||
const getFaculties = _.memoize(data =>
|
||||
_(data.ok)
|
||||
.map(arr => arr[1])
|
||||
.uniq()
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.value()
|
||||
)
|
||||
|
||||
const Chunked = ({ children }) => {
|
||||
const [chunk, setChunk] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
setChunk(1)
|
||||
}, [children])
|
||||
|
||||
useEffect(() => {
|
||||
if (chunk >= children.length) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const handle = setTimeout(() => {
|
||||
setChunk(chunk + 3)
|
||||
}, 1)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handle)
|
||||
}
|
||||
}, [chunk])
|
||||
|
||||
return children.slice(0, chunk)
|
||||
}
|
||||
|
||||
Chunked.propTypes = {
|
||||
children: PropTypes.arrayOf(PropTypes.node).isRequired
|
||||
}
|
||||
|
||||
const SegmentChart = React.memo(({ facultyRows, relative, color, header }) => {
|
||||
return (
|
||||
<ReactHighcharts
|
||||
highcharts={Highcharts}
|
||||
config={makeSegmentConfig({
|
||||
facultyRows,
|
||||
relative,
|
||||
color,
|
||||
header
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}, _.isEqual)
|
||||
|
||||
const displayDataSorters = {
|
||||
facultyName: (a, b) => a.facultyName.localeCompare(b),
|
||||
estimated3y: (a, b) => a.estimated3y.total - b.estimated3y.total,
|
||||
estimated4y: (a, b) => a.estimated4y.total - b.estimated4y.total,
|
||||
estimatedTrash: (a, b) => a.estimatedTrash.total - b.estimatedTrash.total
|
||||
}
|
||||
|
||||
const ProtoD = ({ data }) => {
|
||||
const facultyNames = getFaculties(data)
|
||||
|
||||
const count3yAmount = useCallback(
|
||||
facultyName => {
|
||||
return data.ebin.filter(r => r[1] === facultyName).reduce((a, b) => a + b[3], 0)
|
||||
},
|
||||
[data.ebin]
|
||||
)
|
||||
|
||||
const countInclusive4yAmount = useCallback(
|
||||
facultyName => data.ok.filter(r => r[1] === facultyName).reduce((a, b) => a + b[3], 0),
|
||||
[data.ok]
|
||||
)
|
||||
|
||||
const countExclusive4yAmount = useCallback(
|
||||
facultyName => {
|
||||
// not including 3y rate students
|
||||
const ebinAmount = count3yAmount(facultyName)
|
||||
return countInclusive4yAmount(facultyName) - ebinAmount
|
||||
},
|
||||
[count3yAmount, countInclusive4yAmount]
|
||||
)
|
||||
|
||||
const countTrashAmount = useCallback(
|
||||
facultyName => {
|
||||
return data.not_ok.filter(r => r[1] === facultyName).reduce((a, b) => a + b[3], 0)
|
||||
},
|
||||
[data.not_ok]
|
||||
)
|
||||
|
||||
const countTotal = useCallback(facultyName => countTrashAmount(facultyName) + countInclusive4yAmount(facultyName), [
|
||||
countTrashAmount,
|
||||
countInclusive4yAmount
|
||||
])
|
||||
|
||||
const [showRelative, setShowRelative] = useState(false)
|
||||
const [sortKey, setSortKey] = useState('estimated3y')
|
||||
const [sortDirection, setSortDirection] = useState(-1)
|
||||
|
||||
function mapDisplayData(facultyName) {
|
||||
const whereFacultySortByStartDate = (rows, facultyName) => {
|
||||
return _(rows)
|
||||
.filter(row => row[1] === facultyName)
|
||||
.sortBy(row => row[2])
|
||||
.value()
|
||||
}
|
||||
|
||||
const totalStudents = countTotal(facultyName)
|
||||
const estimated3yTotal = count3yAmount(facultyName)
|
||||
const estimated4yTotal = countExclusive4yAmount(facultyName)
|
||||
const estimatedTrashTotal = countTrashAmount(facultyName)
|
||||
|
||||
const relativeMapper = showRelative
|
||||
? row => {
|
||||
const dupe = [...row]
|
||||
dupe[3] /= totalStudents
|
||||
return dupe
|
||||
}
|
||||
: row => row
|
||||
|
||||
const rows3y = whereFacultySortByStartDate(data.ebin, facultyName).map(relativeMapper)
|
||||
const rows4y = whereFacultySortByStartDate(data.ok, facultyName).map(relativeMapper)
|
||||
const rowsTrash = whereFacultySortByStartDate(data.not_ok, facultyName).map(relativeMapper)
|
||||
|
||||
return {
|
||||
facultyName,
|
||||
estimated3y: {
|
||||
total: showRelative ? estimated3yTotal / totalStudents : estimated3yTotal,
|
||||
rows: rows3y
|
||||
},
|
||||
estimated4y: {
|
||||
total: showRelative ? estimated4yTotal / totalStudents : estimated4yTotal,
|
||||
rows: rows4y
|
||||
},
|
||||
estimatedTrash: {
|
||||
total: showRelative ? estimatedTrashTotal / totalStudents : estimatedTrashTotal,
|
||||
rows: rowsTrash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const displayData = facultyNames.map(mapDisplayData).sort((a, b) => sortDirection * displayDataSorters[sortKey](a, b))
|
||||
|
||||
const headerSortedProps = useCallback(
|
||||
key => {
|
||||
const getSortedProp = sortDirection => {
|
||||
if (sortDirection === -1) return 'descending'
|
||||
return 'ascending'
|
||||
}
|
||||
|
||||
return {
|
||||
sorted: sortKey === key ? getSortedProp(sortDirection) : undefined,
|
||||
onClick: () => {
|
||||
if (sortKey === key) {
|
||||
setSortDirection(-1 * sortDirection)
|
||||
return
|
||||
}
|
||||
|
||||
setSortKey(key)
|
||||
setSortDirection(1)
|
||||
}
|
||||
}
|
||||
},
|
||||
[sortKey, setSortKey, sortDirection]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form>
|
||||
<Form.Field
|
||||
onChange={() => setShowRelative(!showRelative)}
|
||||
checked={showRelative}
|
||||
control={Checkbox}
|
||||
label={{ children: 'Relative' }}
|
||||
/>
|
||||
</Form>
|
||||
<Table compact striped sortable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell {...headerSortedProps('facultyName')}>Tiedekunta</Table.HeaderCell>
|
||||
<Table.HeaderCell {...headerSortedProps('estimated3y')} colSpan="2">
|
||||
3v tahdissa
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell {...headerSortedProps('estimated4y')} colSpan="2">
|
||||
4v tahdissa
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell {...headerSortedProps('estimatedTrash')} colSpan="2">
|
||||
Ei tahdissa
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{displayData.map(({ facultyName, estimated3y, estimated4y, estimatedTrash }) => {
|
||||
return (
|
||||
<Table.Row key={facultyName}>
|
||||
<Table.Cell>{facultyName}</Table.Cell>
|
||||
<Table.Cell>{showRelative ? `${(estimated3y.total * 100).toFixed(1)}%` : estimated3y.total}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<SegmentChart
|
||||
facultyRows={estimated3y.rows}
|
||||
relative={showRelative}
|
||||
color="#6ab04c"
|
||||
header="3v tahdissa, aloitusvuosi:"
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{showRelative ? `${(estimated4y.total * 100).toFixed(1)}%` : estimated4y.total}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<SegmentChart
|
||||
facultyRows={estimated4y.rows}
|
||||
relative={showRelative}
|
||||
color="#f9ca24"
|
||||
header="4v tahdissa, aloitusvuosi:"
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{showRelative ? `${(estimatedTrash.total * 100).toFixed(1)}%` : estimatedTrash.total}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<SegmentChart
|
||||
facultyRows={estimatedTrash.rows}
|
||||
relative={showRelative}
|
||||
color="#ff7979"
|
||||
header="Ei tahdissa, aloitusvuosi:"
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ProtoD.propTypes = {
|
||||
data: PropTypes.shape(dataPropTypes).isRequired
|
||||
}
|
||||
|
||||
export default ProtoD
|
||||
@@ -1,409 +0,0 @@
|
||||
/* eslint-disable */
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import Highcharts from 'highcharts'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import moment from 'moment'
|
||||
import { Table, Checkbox, Form, Dropdown } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import { statsDate, statsNow, stats2MonthsAgo, stats4MonthsAgo } from './data_timed'
|
||||
|
||||
const stats = [stats4MonthsAgo, stats2MonthsAgo, statsNow]
|
||||
const targetDates = ['2017-07-31 21:00:00+00', '2018-07-31 21:00:00+00', '2019-07-31 21:00:00+00']
|
||||
|
||||
const defaultConfig = () => ({
|
||||
chart: {
|
||||
backgroundColor: null,
|
||||
borderWidth: 0,
|
||||
type: 'area',
|
||||
margin: [2, 0, 2, 0],
|
||||
width: 120,
|
||||
height: 20,
|
||||
style: {
|
||||
overflow: 'visible'
|
||||
},
|
||||
// small optimalization, saves 1-2 ms each sparkline
|
||||
skipClone: true
|
||||
},
|
||||
title: {
|
||||
text: ''
|
||||
},
|
||||
credits: {
|
||||
enabled: false
|
||||
},
|
||||
xAxis: {
|
||||
labels: {
|
||||
enabled: false
|
||||
},
|
||||
title: {
|
||||
text: null
|
||||
},
|
||||
startOnTick: false,
|
||||
endOnTick: false,
|
||||
tickPositions: []
|
||||
},
|
||||
yAxis: {
|
||||
endOnTick: false,
|
||||
startOnTick: false,
|
||||
labels: {
|
||||
enabled: false
|
||||
},
|
||||
title: {
|
||||
text: null
|
||||
},
|
||||
tickPositions: [0]
|
||||
},
|
||||
legend: {
|
||||
enabled: false
|
||||
},
|
||||
tooltip: {
|
||||
hideDelay: 0,
|
||||
outside: true,
|
||||
shared: true
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
animation: false,
|
||||
lineWidth: 1,
|
||||
shadow: false,
|
||||
states: {
|
||||
hover: {
|
||||
lineWidth: 1
|
||||
}
|
||||
},
|
||||
marker: {
|
||||
radius: 1,
|
||||
states: {
|
||||
hover: {
|
||||
radius: 2
|
||||
}
|
||||
}
|
||||
},
|
||||
fillOpacity: 0.25
|
||||
},
|
||||
column: {
|
||||
negativeColor: '#910000',
|
||||
borderColor: 'silver'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const makeSegmentConfig = ({ facultyRows, relative, color, header }) => {
|
||||
const config = {
|
||||
xAxis: {
|
||||
categories: [
|
||||
moment(statsDate)
|
||||
.subtract(4, 'months')
|
||||
.toDate(),
|
||||
moment(statsDate)
|
||||
.subtract(2, 'months')
|
||||
.toDate(),
|
||||
statsDate
|
||||
]
|
||||
},
|
||||
series: [
|
||||
{
|
||||
color,
|
||||
data: facultyRows.map(row => row[3])
|
||||
}
|
||||
],
|
||||
tooltip: {
|
||||
headerFormat: `<span style="font-size:10px">${header}:</span><br/>`,
|
||||
pointFormatter() {
|
||||
const y = relative ? `${(this.y * 100).toFixed(1)}%` : `${this.y}`
|
||||
return `<span style="color:${this.color}">●</span> ${moment(this.category).format('YYYY/MM')}: <b>${y}</b><br/>`
|
||||
}
|
||||
}
|
||||
}
|
||||
return Highcharts.merge(defaultConfig(), config)
|
||||
}
|
||||
|
||||
const getFaculties = _.memoize(() =>
|
||||
_(statsNow.ok)
|
||||
.map(arr => arr[1])
|
||||
.uniq()
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.value()
|
||||
)
|
||||
|
||||
const SegmentChart = React.memo(({ facultyRows, relative, color, header }) => {
|
||||
return (
|
||||
<ReactHighcharts
|
||||
highcharts={Highcharts}
|
||||
config={makeSegmentConfig({
|
||||
facultyRows,
|
||||
relative,
|
||||
color,
|
||||
header
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}, _.isEqual)
|
||||
|
||||
const yearOptions = targetDates.map((date, i) => ({
|
||||
key: i,
|
||||
value: i,
|
||||
text: `${new Date(date).getFullYear()}`
|
||||
}))
|
||||
|
||||
const displayDataSorters = {
|
||||
facultyName: (a, b) => a.facultyName.localeCompare(b),
|
||||
estimated3y: (a, b) => a.estimated3y.total - b.estimated3y.total,
|
||||
estimated4y: (a, b) => a.estimated4y.total - b.estimated4y.total,
|
||||
estimatedTrash: (a, b) => a.estimatedTrash.total - b.estimatedTrash.total
|
||||
}
|
||||
|
||||
const ProtoE = () => {
|
||||
const facultyNames = getFaculties()
|
||||
const [targetDate, setTargetDate] = useState(0)
|
||||
|
||||
const statsTarget = _(stats)
|
||||
.map(({ ebin, not_ok, ok }) => ({
|
||||
ebin: ebin.filter(row => row[2] === targetDates[targetDate]),
|
||||
not_ok: not_ok.filter(row => row[2] === targetDates[targetDate]),
|
||||
ok: ok.filter(row => row[2] === targetDates[targetDate])
|
||||
}))
|
||||
.value()
|
||||
|
||||
const count3yAmount = useCallback(
|
||||
facultyName => {
|
||||
return statsTarget[statsTarget.length - 1].ebin.filter(r => r[1] === facultyName).reduce((a, b) => a + b[3], 0)
|
||||
},
|
||||
[statsTarget]
|
||||
)
|
||||
|
||||
const countInclusive4yAmount = useCallback(
|
||||
facultyName =>
|
||||
statsTarget[statsTarget.length - 1].ok.filter(r => r[1] === facultyName).reduce((a, b) => a + b[3], 0),
|
||||
[statsTarget]
|
||||
)
|
||||
|
||||
const countExclusive4yAmount = useCallback(
|
||||
facultyName => {
|
||||
// not including 3y rate students
|
||||
const ebinAmount = count3yAmount(facultyName)
|
||||
return countInclusive4yAmount(facultyName) - ebinAmount
|
||||
},
|
||||
[count3yAmount, countInclusive4yAmount]
|
||||
)
|
||||
|
||||
const countTrashAmount = useCallback(
|
||||
facultyName => {
|
||||
return statsTarget[statsTarget.length - 1].not_ok.filter(r => r[1] === facultyName).reduce((a, b) => a + b[3], 0)
|
||||
},
|
||||
[statsTarget]
|
||||
)
|
||||
|
||||
const countTotal = useCallback(facultyName => countTrashAmount(facultyName) + countInclusive4yAmount(facultyName), [
|
||||
countTrashAmount,
|
||||
countInclusive4yAmount
|
||||
])
|
||||
|
||||
const [showRelative, setShowRelative] = useState(false)
|
||||
const [sortKey, setSortKey] = useState('estimated3y')
|
||||
const [sortDirection, setSortDirection] = useState(-1)
|
||||
|
||||
function mapDisplayData(facultyName) {
|
||||
const valuesForFaculty = (key, facultyName) => {
|
||||
return _(statsTarget)
|
||||
.map(stat => stat[key].filter(row => row[1] === facultyName))
|
||||
.map(year => year[0])
|
||||
.value()
|
||||
}
|
||||
|
||||
const totalStudents = countTotal(facultyName)
|
||||
const estimated3yTotal = count3yAmount(facultyName)
|
||||
const estimated4yTotal = countExclusive4yAmount(facultyName)
|
||||
const estimatedTrashTotal = countTrashAmount(facultyName)
|
||||
|
||||
const relativeMapper = showRelative
|
||||
? row => {
|
||||
const dupe = [...row]
|
||||
dupe[3] /= totalStudents
|
||||
return dupe
|
||||
}
|
||||
: row => row
|
||||
|
||||
const rows3y = valuesForFaculty('ebin', facultyName).map(relativeMapper)
|
||||
const rows4y = valuesForFaculty('ok', facultyName).map(relativeMapper)
|
||||
const rowsTrash = valuesForFaculty('not_ok', facultyName).map(relativeMapper)
|
||||
|
||||
return {
|
||||
facultyName,
|
||||
estimated3y: {
|
||||
total: showRelative ? estimated3yTotal / totalStudents : estimated3yTotal,
|
||||
rows: rows3y
|
||||
},
|
||||
estimated4y: {
|
||||
total: showRelative ? estimated4yTotal / totalStudents : estimated4yTotal,
|
||||
rows: rows4y
|
||||
},
|
||||
estimatedTrash: {
|
||||
total: showRelative ? estimatedTrashTotal / totalStudents : estimatedTrashTotal,
|
||||
rows: rowsTrash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const displayData = facultyNames.map(mapDisplayData).sort((a, b) => sortDirection * displayDataSorters[sortKey](a, b))
|
||||
|
||||
const headerSortedProps = useCallback(
|
||||
key => {
|
||||
const getSortedProp = sortDirection => {
|
||||
if (sortDirection === -1) return 'descending'
|
||||
return 'ascending'
|
||||
}
|
||||
|
||||
return {
|
||||
sorted: sortKey === key ? getSortedProp(sortDirection) : undefined,
|
||||
onClick: () => {
|
||||
if (sortKey === key) {
|
||||
setSortDirection(-1 * sortDirection)
|
||||
return
|
||||
}
|
||||
|
||||
setSortKey(key)
|
||||
setSortDirection(1)
|
||||
}
|
||||
}
|
||||
},
|
||||
[sortKey, setSortKey, sortDirection]
|
||||
)
|
||||
|
||||
const handleYearChange = useCallback((e, { value }) => {
|
||||
setTargetDate(value)
|
||||
}, [])
|
||||
|
||||
const maxMinDisplayDiff = _(displayData)
|
||||
.flatMap(({ estimated3y }) => {
|
||||
return [
|
||||
estimated3y.rows.map(row => row[3])[1] - estimated3y.rows.map(row => row[3])[0],
|
||||
estimated3y.rows.map(row => row[3])[2] - estimated3y.rows.map(row => row[3])[1]
|
||||
]
|
||||
})
|
||||
.reduce(
|
||||
([min, max], val) => {
|
||||
if (val > max) max = val
|
||||
if (val < min) min = val
|
||||
return [min, max]
|
||||
},
|
||||
[99999999999, -9999999999]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form>
|
||||
<Form.Field
|
||||
onChange={() => setShowRelative(!showRelative)}
|
||||
checked={showRelative}
|
||||
control={Checkbox}
|
||||
label={{ children: 'Relative' }}
|
||||
/>
|
||||
<Form.Field
|
||||
value={targetDate}
|
||||
selection
|
||||
options={yearOptions}
|
||||
onChange={handleYearChange}
|
||||
control={Dropdown}
|
||||
label={{ children: 'Start year' }}
|
||||
/>
|
||||
</Form>
|
||||
<Table compact striped sortable>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell {...headerSortedProps('facultyName')}>Tiedekunta</Table.HeaderCell>
|
||||
<Table.HeaderCell {...headerSortedProps('estimated3y')} colSpan="2">
|
||||
3v tahdissa
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell {...headerSortedProps('estimated4y')} colSpan="2">
|
||||
4v tahdissa
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell {...headerSortedProps('estimatedTrash')} colSpan="2">
|
||||
Ei tahdissa
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>3v tahdin muutos</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{displayData.map(({ facultyName, estimated3y, estimated4y, estimatedTrash }) => {
|
||||
return (
|
||||
<Table.Row key={facultyName}>
|
||||
<Table.Cell>{facultyName}</Table.Cell>
|
||||
<Table.Cell>{showRelative ? `${(estimated3y.total * 100).toFixed(1)}%` : estimated3y.total}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<SegmentChart
|
||||
facultyRows={estimated3y.rows}
|
||||
relative={showRelative}
|
||||
color="#6ab04c"
|
||||
header="3v tahdissa"
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{showRelative ? `${(estimated4y.total * 100).toFixed(1)}%` : estimated4y.total}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<SegmentChart
|
||||
facultyRows={estimated4y.rows}
|
||||
relative={showRelative}
|
||||
color="#f9ca24"
|
||||
header="4v tahdissa"
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{showRelative ? `${(estimatedTrash.total * 100).toFixed(1)}%` : estimatedTrash.total}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<SegmentChart
|
||||
facultyRows={estimatedTrash.rows}
|
||||
relative={showRelative}
|
||||
color="#ff7979"
|
||||
header="Ei tahdissa"
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<ReactHighcharts
|
||||
highcharts={Highcharts}
|
||||
config={Highcharts.merge(defaultConfig(), {
|
||||
chart: { type: 'column' },
|
||||
yAxis: {
|
||||
min: maxMinDisplayDiff[0],
|
||||
max: maxMinDisplayDiff[1]
|
||||
},
|
||||
xAxis: {
|
||||
categories: [
|
||||
`${moment(statsDate)
|
||||
.subtract(4, 'months')
|
||||
.format('YYYY/MM')} → ${moment(statsDate)
|
||||
.subtract(2, 'months')
|
||||
.format('YYYY/MM')}`,
|
||||
`${moment(statsDate)
|
||||
.subtract(2, 'months')
|
||||
.format('YYYY/MM')} → ${moment(statsDate).format('YYYY/MM')}`
|
||||
]
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: [
|
||||
estimated3y.rows.map(row => row[3])[1] - estimated3y.rows.map(row => row[3])[0],
|
||||
estimated3y.rows.map(row => row[3])[2] - estimated3y.rows.map(row => row[3])[1]
|
||||
]
|
||||
}
|
||||
],
|
||||
tooltip: {
|
||||
headerFormat: `<span style="font-size:10px">3v tahdin muutos:</span><br/>`,
|
||||
pointFormatter() {
|
||||
const y = showRelative ? `${(this.y * 100).toFixed(1)} prosenttiyksikköä` : `${this.y}`
|
||||
return `<span style="color:${this.color}">●</span> ${this.category}: <b>${y}</b><br/>`
|
||||
}
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProtoE
|
||||
@@ -1,214 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { Dropdown, Loader, Dimmer, Segment, Form } from 'semantic-ui-react'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import Highcharts from 'highcharts'
|
||||
import HighchartsVariablePie from 'highcharts/modules/variable-pie'
|
||||
import HighchartsDrilldown from 'highcharts/modules/drilldown'
|
||||
import { callApi } from '../../apiConnection'
|
||||
|
||||
HighchartsVariablePie(Highcharts)
|
||||
HighchartsDrilldown(Highcharts)
|
||||
|
||||
const COLORS = [
|
||||
'#636e72',
|
||||
'#a29bfe',
|
||||
'#ffeaa7',
|
||||
'#fd79a8',
|
||||
'#0984e3',
|
||||
'#74b9ff',
|
||||
'#ff7675',
|
||||
'#55efc4',
|
||||
'#fab1a0',
|
||||
'#81ecec',
|
||||
'#00cec9',
|
||||
'#00b894',
|
||||
'#fd79a8',
|
||||
'#6c5ce7',
|
||||
'#fdcb6e',
|
||||
'#e17055',
|
||||
'#d63031',
|
||||
'#e84393',
|
||||
'#2d3436'
|
||||
]
|
||||
|
||||
const sortOpts = [
|
||||
{ key: 0, text: 'by relative 3y target count', value: 'targetRelative' },
|
||||
{ key: 1, text: 'by 3y target count', value: 'target' },
|
||||
{ key: 2, text: 'by total count', value: 'total' }
|
||||
]
|
||||
|
||||
const ProtoF = () => {
|
||||
const [startOptions, setStartOptions] = useState([])
|
||||
const [selectedStartYear, setSelectedStartYear] = useState(null)
|
||||
const [selectedSort, setSelectedSort] = useState('targetRelative')
|
||||
const [data, setData] = useState(null)
|
||||
const [dataLoading, setDataLoading] = useState(false)
|
||||
const [nameToColorIndex, setNameToColorIndex] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
callApi('/cool-data-science/start-years').then(res => {
|
||||
setStartOptions(res.data.map((date, i) => ({ key: i, text: new Date(date).getFullYear(), value: date })))
|
||||
setSelectedStartYear(res.data[0])
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedStartYear) {
|
||||
return
|
||||
}
|
||||
|
||||
setDataLoading(true)
|
||||
callApi('/cool-data-science/3y-students', 'get', null, { sort: selectedSort, startDate: selectedStartYear }).then(
|
||||
res => {
|
||||
const sortedByName = [...res.data].sort((a, b) => a.name.localeCompare(b.name))
|
||||
if (!setNameToColorIndex) {
|
||||
setNameToColorIndex(
|
||||
sortedByName.reduce((acc, val, i) => {
|
||||
acc[val.name] = i
|
||||
return acc
|
||||
}, {})
|
||||
)
|
||||
} else {
|
||||
const newMapper = { ...nameToColorIndex }
|
||||
for (let i = 0; i < sortedByName.length; i++) {
|
||||
const tdk = sortedByName[i]
|
||||
|
||||
if (typeof newMapper[tdk.name] === 'undefined') {
|
||||
newMapper[tdk.name] = Object.keys(newMapper).length
|
||||
}
|
||||
}
|
||||
setNameToColorIndex(newMapper)
|
||||
}
|
||||
setData(res.data)
|
||||
setDataLoading(false)
|
||||
}
|
||||
)
|
||||
}, [selectedStartYear, selectedSort])
|
||||
|
||||
const handleYearChanged = useCallback((e, { value }) => {
|
||||
setSelectedStartYear(value)
|
||||
}, [])
|
||||
const handleSortChanged = useCallback((e, { value }) => {
|
||||
setSelectedSort(value)
|
||||
}, [])
|
||||
const handleSubmit = useCallback(e => {
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<h3>Proto F</h3>
|
||||
<h4>3v tahdissa</h4>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
<label>Start year</label>
|
||||
<Dropdown
|
||||
onChange={handleYearChanged}
|
||||
options={startOptions}
|
||||
placholder="Choose year"
|
||||
selection
|
||||
value={selectedStartYear}
|
||||
/>
|
||||
</Form.Field>
|
||||
|
||||
<Form.Field>
|
||||
<label>Sort</label>
|
||||
<Dropdown
|
||||
onChange={handleSortChanged}
|
||||
options={sortOpts}
|
||||
placeholder="Choose sort"
|
||||
selection
|
||||
value={selectedSort}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
|
||||
<Segment padded>
|
||||
<Dimmer active={!data && dataLoading} />
|
||||
<Loader inverted active={dataLoading} />
|
||||
|
||||
{data && (
|
||||
<ReactHighcharts
|
||||
highcharts={Highcharts}
|
||||
config={{
|
||||
credits: {
|
||||
text: 'oodikone | TOSKA'
|
||||
},
|
||||
chart: {
|
||||
type: 'variablepie',
|
||||
plotBackgroundColor: null,
|
||||
plotBorderWidth: 0,
|
||||
plotShadow: false
|
||||
},
|
||||
title: {
|
||||
text: `${new Date(selectedStartYear).getFullYear()}`,
|
||||
align: 'center',
|
||||
verticalAlign: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
pointFormatter() {
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
return `<span style="color:${this.color}">\u25CF</span><b> ${this.series.name}</b>: <b>${(
|
||||
this.y * 100
|
||||
).toFixed(2)}%</b> (${this.targetStudents}/${this.z})<br/>`
|
||||
}
|
||||
},
|
||||
plotOptions: {
|
||||
variablepie: {
|
||||
startAngle: -110,
|
||||
endAngle: 110,
|
||||
center: ['50%', '75%'],
|
||||
size: '110%',
|
||||
dataLabels: {
|
||||
formatter() {
|
||||
return `<b>${this.point.name.replace(' tiedekunta', '')}</b>: ${(this.y * 100).toFixed(1)}%`
|
||||
}
|
||||
/* eslint-enable react/no-this-in-sfc */
|
||||
}
|
||||
}
|
||||
},
|
||||
colors: COLORS,
|
||||
series: [
|
||||
{
|
||||
minPointsize: 10,
|
||||
zMin: 0,
|
||||
name: '3V tavoite-ajassa',
|
||||
innerSize: '30%',
|
||||
data: data.map(({ code, name, totalStudents, targetStudents }) => ({
|
||||
drilldown: `drilldown-${code}`,
|
||||
color: COLORS[nameToColorIndex[name]],
|
||||
name,
|
||||
z: totalStudents,
|
||||
y: targetStudents / totalStudents,
|
||||
targetStudents
|
||||
}))
|
||||
}
|
||||
],
|
||||
drilldown: {
|
||||
series: data.map(org => {
|
||||
return {
|
||||
id: `drilldown-${org.code}`,
|
||||
minPointSize: 10,
|
||||
zMin: 0,
|
||||
name: `3V tavoite-ajassa, ${org.name.replace(' tiedekunta', '')}`,
|
||||
innerSize: '60%',
|
||||
data: org.programmes.map(({ name, totalStudents, targetStudents }) => ({
|
||||
name,
|
||||
z: totalStudents,
|
||||
y: targetStudents / totalStudents,
|
||||
targetStudents
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Segment>
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProtoF
|
||||
@@ -1,15 +1,20 @@
|
||||
/* eslint-disable */
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import Highcharts from 'highcharts'
|
||||
import ReactHighcharts from 'react-highcharts'
|
||||
import { Segment, Loader, Dimmer, Table, Form, Dropdown, Icon, Checkbox, Message } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
import { callApi } from '../../apiConnection'
|
||||
import TSA from '../../common/tsa'
|
||||
import InfoToolTips from '../../common/InfoToolTips'
|
||||
import { getUber, getYears } from '../../redux/coolDataScience'
|
||||
import './protoG.css'
|
||||
|
||||
const ANALYTICS_CATEGORY = 'Trends'
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
|
||||
|
||||
const defaultConfig = () => ({
|
||||
chart: {
|
||||
backgroundColor: null,
|
||||
@@ -154,35 +159,26 @@ const getSnapshotsStartYears = _.memoize(snapshots =>
|
||||
_.uniq(snapshots.map(s => new Date(s.date).getFullYear())).map(year => new Date(year, 8, 1))
|
||||
)
|
||||
|
||||
const ProtoG = () => {
|
||||
const ProtoG = ({ uberdata, years, isLoading, loadingYears, getUberDispatch, getYearsDispatch }) => {
|
||||
const [startOptions, setStartOptions] = useState([])
|
||||
const [startDate, setStartDate] = useState(null)
|
||||
const [uberdata, setUberdata] = useState(null)
|
||||
const [isLoading, setLoading] = useState(true)
|
||||
const [expandedOrgs, setExpandedOrgs] = useState({})
|
||||
const [includeOldAttainments, setIncludeOldAttainments] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
const res = await callApi('/cool-data-science/start-years')
|
||||
setLoading(false)
|
||||
setStartOptions(res.data.map((date, i) => ({ key: i, text: new Date(date).getFullYear(), value: date })))
|
||||
setStartDate(res.data[0])
|
||||
}
|
||||
|
||||
load()
|
||||
getYearsDispatch()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (years.length > 0) {
|
||||
setStartOptions(years.map((date, i) => ({ key: i, text: new Date(date).getFullYear(), value: date })))
|
||||
setStartDate(years[0])
|
||||
}
|
||||
}, [years])
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
const res = await callApi('/cool-data-science/uber-data', 'get', null, {
|
||||
start_date: startDate,
|
||||
include_old_attainments: includeOldAttainments.toString()
|
||||
})
|
||||
setUberdata(res.data)
|
||||
setLoading(false)
|
||||
getUberDispatch({ startDate, includeOldAttainments: includeOldAttainments.toString() })
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
@@ -192,23 +188,33 @@ const ProtoG = () => {
|
||||
|
||||
const handleOldAttainmentToggled = useCallback(() => {
|
||||
setIncludeOldAttainments(previous => !previous)
|
||||
sendAnalytics('G Toggled old attainments', 'ProtoG')
|
||||
}, [])
|
||||
|
||||
const handleYearChanged = useCallback((e, { value }) => {
|
||||
setStartDate(value)
|
||||
sendAnalytics('G Year changed', 'ProtoG')
|
||||
}, [])
|
||||
const preventDefault = useCallback(e => e.preventDefault(), [])
|
||||
|
||||
const makeHandleExpando = orgCode => {
|
||||
return () => {
|
||||
setExpandedOrgs({ ...expandedOrgs, [orgCode]: !expandedOrgs[orgCode] })
|
||||
sendAnalytics('G Toggled expanded orgs', 'ProtoG')
|
||||
}
|
||||
}
|
||||
|
||||
const DrilldownMessage = () => (
|
||||
<Message color='blue'
|
||||
content="Klikkaamalla tiedekuntaa pystyt tarkastelemaan koulutusohjelma kohtaista dataa."
|
||||
/>
|
||||
)
|
||||
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
return (
|
||||
<Segment>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<h3>Prototyyppi: Suhteellisen tavoiteaikaerittelyn kehitys</h3>
|
||||
<h2>Kandiohjelmat: Suhteellisen tavoiteaikaerittelyn kehitys</h2>
|
||||
<Checkbox
|
||||
style={{ marginLeft: 'auto' }}
|
||||
label="Include old attainments"
|
||||
@@ -216,6 +222,7 @@ const ProtoG = () => {
|
||||
checked={includeOldAttainments}
|
||||
/>
|
||||
</div>
|
||||
<DrilldownMessage />
|
||||
<Form onSubmit={preventDefault}>
|
||||
<Form.Group inline>
|
||||
<Form.Field>
|
||||
@@ -231,9 +238,9 @@ const ProtoG = () => {
|
||||
</Form.Group>
|
||||
</Form>
|
||||
|
||||
<Segment placeholder={isLoading} vertical>
|
||||
<Dimmer inverted active={isLoading} />
|
||||
<Loader active={isLoading} />
|
||||
<Segment placeholder={isLoading || loadingYears} vertical>
|
||||
<Dimmer inverted active={isLoading || loadingYears} />
|
||||
<Loader active={isLoading || loadingYears} />
|
||||
|
||||
{uberdata && (
|
||||
<Table compact striped className="proto-g-table">
|
||||
@@ -291,4 +298,14 @@ const ProtoG = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default ProtoG
|
||||
const mapStateToProps = ({ coolDataScience }) => ({
|
||||
uberdata: coolDataScience.data.uber || [],
|
||||
isLoading: coolDataScience.pending.uber,
|
||||
years: coolDataScience.data.years,
|
||||
loadingYears: coolDataScience.pending.years
|
||||
})
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ getUberDispatch: getUber, getYearsDispatch: getYears }
|
||||
)(ProtoG)
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import PropTypes, { shape, bool, func } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Loader, Dimmer, Icon, Accordion, Checkbox, Message, Form } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import moment from 'moment'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import Datetime from 'react-datetime'
|
||||
|
||||
import TSA from '../../common/tsa'
|
||||
import { getTextIn } from '../../common'
|
||||
import { useLocalStorage } from '../../common/hooks'
|
||||
import { callApi } from '../../apiConnection'
|
||||
import InfoToolTips from '../../common/InfoToolTips'
|
||||
import { getStatus } from '../../redux/coolDataScience'
|
||||
import './status.css'
|
||||
|
||||
const ANALYTICS_CATEGORY = 'Trends'
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
|
||||
|
||||
const getP = (a, b) => {
|
||||
if (a === 0 || b === 0) return 1
|
||||
return a / b
|
||||
@@ -93,26 +99,32 @@ const StatusContainer = ({
|
||||
</div>
|
||||
{showYearlyValues && (
|
||||
<div style={{ marginTop: '10px', textAlign: 'start' }}>
|
||||
{_.orderBy(Object.entries(yearlyValues), ([y]) => y, ['desc']).map(([year, { acc, total }]) => {
|
||||
return (
|
||||
<div style={{ margin: '5px 0' }} key={`${title}-${year}`}>
|
||||
<span>
|
||||
<b>
|
||||
{year}
|
||||
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
|
||||
</b>{' '}
|
||||
{acc ? Math.round(acc).toLocaleString('fi') : 0}
|
||||
{!!total && (
|
||||
<span>
|
||||
{' '}
|
||||
<span style={{ fontSize: '1.4em', verticalAlign: 'bottom' }}>/</span>{' '}
|
||||
{Math.round(total).toLocaleString('fi')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{_.orderBy(Object.entries(yearlyValues), ([y]) => y, ['desc']).map(
|
||||
([year, { acc, total, accStudents, totalStudents }]) => {
|
||||
return (
|
||||
<div style={{ margin: '5px 0' }} key={`${title}-${year}`}>
|
||||
<span>
|
||||
<b>
|
||||
{year}
|
||||
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
|
||||
</b>{' '}
|
||||
{/* render num of students instead of credits if no credits are given from the course */}
|
||||
{accStudents <= acc ? Math.round(acc).toLocaleString('fi') : accStudents}
|
||||
{/* if no total students add students after accStudents */}
|
||||
{acc <= accStudents && totalStudents < 1 && ' students'}
|
||||
{/* render total if there are credits or students for either */}
|
||||
{(total > 0 || totalStudents > 0) &&
|
||||
` / ${
|
||||
// same logic as before to check if render num of students or credits
|
||||
totalStudents <= total && total > 0
|
||||
? Math.round(total).toLocaleString('fi')
|
||||
: `${totalStudents} students`
|
||||
}`}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Segment>
|
||||
@@ -134,51 +146,65 @@ StatusContainer.propTypes = {
|
||||
|
||||
const VerticalLine = () => <div style={{ margin: '0 10px', fontSize: '20px' }}>|</div>
|
||||
|
||||
const Status = () => {
|
||||
const Status = ({ getStatusDispatch, data, loading }) => {
|
||||
const DATE_FORMAT = 'DD.MM.YYYY'
|
||||
const [showYearlyValues, setShowYearlyValues] = useLocalStorage('showYearlyValues', false)
|
||||
const [showYearlyValues, setShowYearlyValues] = useLocalStorage('showYearlyValues', true)
|
||||
const [showByYear, setShowByYear] = useLocalStorage('showByYear', false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState(null)
|
||||
const [drillStack, setDrillStack] = useState([])
|
||||
const [showSettings, setShowSettings] = useState(true)
|
||||
const [selectedDate, setSelectedDate] = useState(moment())
|
||||
const [codes, setCodes] = useState([])
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
|
||||
const isValidDate = d => moment.isMoment(d) && moment().diff(d) > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && isValidDate(selectedDate)) {
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
const res = await callApi('/cool-data-science/status', 'get', null, {
|
||||
date: selectedDate.valueOf(),
|
||||
showByYear
|
||||
})
|
||||
setData(res.data)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
load()
|
||||
getStatusDispatch({ date: selectedDate.valueOf(), showByYear })
|
||||
}
|
||||
}, [selectedDate, showByYear])
|
||||
|
||||
useEffect(() => {
|
||||
if (codes.length > 0) {
|
||||
const updatedDrillStack = codes.reduce((acc, code) => {
|
||||
const drilled = data[code]
|
||||
// check if the code is on first level of drilldown
|
||||
if (drilled) acc.push(drilled.drill)
|
||||
// if not on first level then use the previous object in array
|
||||
// this might be source of bugs but its the best I could come up with
|
||||
else if (acc.length > 0 && acc[0][code]) acc.push(acc[0][code].drill)
|
||||
return acc
|
||||
}, [])
|
||||
setDrillStack(updatedDrillStack)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const handleShowYearlyValuesToggled = () => {
|
||||
const yearlyValues = showYearlyValues
|
||||
setShowYearlyValues(!showYearlyValues)
|
||||
sendAnalytics(`S Show yearly values toggle ${!yearlyValues ? 'on' : 'off'}`, 'Status')
|
||||
}
|
||||
|
||||
const handleShowByYearToggled = () => {
|
||||
const byYear = showByYear
|
||||
setShowByYear(!showByYear)
|
||||
sendAnalytics(`S Show by year toggle ${!byYear ? 'on' : 'off'}`, 'Status')
|
||||
}
|
||||
|
||||
const pushToDrillStack = values => {
|
||||
const pushToDrillStack = (values, code) => {
|
||||
const updatedCodes = [...codes].concat(code)
|
||||
const updatedDrillStack = [...drillStack].concat(values)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('S Drilldown clicked', 'Status')
|
||||
}
|
||||
|
||||
const popFromDrillStack = () => {
|
||||
const updatedDrillStack = _.dropRight([...drillStack], 1)
|
||||
const updatedCodes = _.dropRight([...codes], 1)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('S Drillup clicked', 'Status')
|
||||
}
|
||||
|
||||
const renderSettings = () => {
|
||||
@@ -216,7 +242,7 @@ const Status = () => {
|
||||
<VerticalLine />
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä vuositasolla"
|
||||
label="Näytä kalenterivuosittain"
|
||||
onChange={handleShowByYearToggled}
|
||||
checked={showByYear}
|
||||
/>
|
||||
@@ -242,6 +268,14 @@ const Status = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const DrilldownMessage = () => (
|
||||
<Message
|
||||
color="blue"
|
||||
content="Klikkaamalla tiedekuntaa pystyt porautumaan koulutusohjelma tasolle ja ohjelmaa klikkaamalla pystyt porautumaan kurssitasolle.
|
||||
Vasemmassa yläkulmassa olevaa nuolta klikkaamalla pääset edelliseen näkymään."
|
||||
/>
|
||||
)
|
||||
|
||||
if (!data || loading)
|
||||
return (
|
||||
<Segment style={{ padding: '40px' }} textAlign="center">
|
||||
@@ -257,7 +291,9 @@ const Status = () => {
|
||||
)
|
||||
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<h2>Uusien ohjelmien tuottamat opintopisteet</h2>
|
||||
<DrilldownMessage />
|
||||
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px' }}>
|
||||
{drillStack.length > 0 && (
|
||||
<Icon onClick={popFromDrillStack} style={{ fontSize: '40px', cursor: 'pointer' }} name="arrow left" />
|
||||
@@ -268,21 +304,30 @@ const Status = () => {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, 240px)',
|
||||
gridTemplateRows: 'repeat(auto-fill) 20px',
|
||||
gridGap: '20px'
|
||||
gridGap: '20px',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{_.orderBy(Object.entries(_.last(drillStack) || data), ([, { current, previous }]) => getP(current, previous), [
|
||||
'desc'
|
||||
]).map(([code, stats]) => {
|
||||
const handleClick = () => pushToDrillStack(stats.drill)
|
||||
{_.orderBy(
|
||||
Object.entries(_.last(drillStack) || data),
|
||||
([, { current, previous, currentStudents, previousStudents }]) =>
|
||||
getP(current || currentStudents, previous || previousStudents), // oh god<r
|
||||
['desc']
|
||||
).map(([code, stats]) => {
|
||||
const handleClick = () => pushToDrillStack(stats.drill, code)
|
||||
// check if the course has credits or not (if credits is zero but there are students who have completed it)
|
||||
const current =
|
||||
!stats.drill && stats.current === 0 && stats.currentStudents > 1 ? stats.currentStudents : stats.current
|
||||
const previous =
|
||||
!stats.drill && stats.previous === 0 && stats.previousStudents > 1 ? stats.previousStudents : stats.previous
|
||||
return (
|
||||
<StatusContainer
|
||||
key={code}
|
||||
clickable={!!stats.drill}
|
||||
handleClick={handleClick}
|
||||
title={getTextIn(stats.name)}
|
||||
current={stats.current}
|
||||
previous={stats.previous}
|
||||
current={current}
|
||||
previous={previous}
|
||||
showYearlyValues={showYearlyValues}
|
||||
min1={-medianDiff * 2}
|
||||
max1={medianDiff * 2}
|
||||
@@ -296,8 +341,22 @@ const Status = () => {
|
||||
<Message>
|
||||
<ReactMarkdown source={CoolDataScience.status} escapeHtml={false} />
|
||||
</Message>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Status
|
||||
Status.propTypes = {
|
||||
data: shape({}).isRequired,
|
||||
loading: bool.isRequired,
|
||||
getStatusDispatch: func.isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ coolDataScience }) => ({
|
||||
data: coolDataScience.data.status,
|
||||
loading: coolDataScience.pending.status
|
||||
})
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ getStatusDispatch: getStatus }
|
||||
)(Status)
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import PropTypes, { shape, bool, func } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Loader, Dimmer, Icon, Accordion, Checkbox, Message, Form } from 'semantic-ui-react'
|
||||
import _ from 'lodash'
|
||||
import moment from 'moment'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import Datetime from 'react-datetime'
|
||||
|
||||
import TSA from '../../common/tsa'
|
||||
import { getTextIn } from '../../common'
|
||||
import { useLocalStorage } from '../../common/hooks'
|
||||
import InfoToolTips from '../../common/InfoToolTips'
|
||||
import { getStatusGraduated } from '../../redux/coolDataScience'
|
||||
import './status.css'
|
||||
|
||||
const ANALYTICS_CATEGORY = 'Trends'
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
|
||||
|
||||
const getP = (a, b) => {
|
||||
if (a === 0 || b === 0) return 1
|
||||
return a / b
|
||||
}
|
||||
|
||||
const mapValueToRange = (x, min1, max1, min2, max2) => {
|
||||
if (x < min1) return min2
|
||||
if (x > max1) return max2
|
||||
return ((x - min1) * (max2 - min2)) / (max1 - min1) + min2
|
||||
}
|
||||
|
||||
const StatusContainer = ({
|
||||
title,
|
||||
current,
|
||||
previous,
|
||||
clickable,
|
||||
handleClick,
|
||||
min1,
|
||||
max1,
|
||||
showYearlyValues,
|
||||
yearlyValues,
|
||||
showByYear
|
||||
}) => {
|
||||
const diff = Math.round(current - previous)
|
||||
const p = getP(current, previous)
|
||||
const change = Math.round((p - 1) * 1000) / 10
|
||||
|
||||
const getColor = v => {
|
||||
if (v > 2.5) return '#6ab04c'
|
||||
if (v < -2.5) return '#ff7979'
|
||||
return '#7B9FCF'
|
||||
}
|
||||
|
||||
const plussify = x => {
|
||||
if (x > 0) return `+${x.toLocaleString('fi')}`
|
||||
return x.toLocaleString('fi')
|
||||
}
|
||||
|
||||
return (
|
||||
<Segment
|
||||
className="status-card"
|
||||
onClick={clickable ? handleClick : null}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxWidth: '240px',
|
||||
minWidth: '240px',
|
||||
cursor: clickable ? 'pointer' : 'default',
|
||||
flex: 1,
|
||||
margin: 0
|
||||
}}
|
||||
textAlign="center"
|
||||
compact
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
overflowWrap: 'break-word'
|
||||
}}
|
||||
>
|
||||
<span className="status-title">{title}</span>
|
||||
<div style={{ margin: '10px 0' }}>
|
||||
<Icon
|
||||
style={{
|
||||
color: getColor(change),
|
||||
transform: `rotateZ(${-mapValueToRange(change, min1, max1, -45, 45)}deg)`
|
||||
}}
|
||||
size="huge"
|
||||
name="arrow right"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: 20, fontWeight: 'bold', color: getColor(change) }}>{plussify(diff)}</span>
|
||||
<br />
|
||||
<span style={{ fontWeight: 'bold', color: getColor(change) }}>({plussify(change)}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
{showYearlyValues && (
|
||||
<div style={{ marginTop: '10px', textAlign: 'start' }}>
|
||||
{_.orderBy(Object.entries(yearlyValues), ([y]) => y, ['desc']).map(([year, { acc, total }]) => {
|
||||
return (
|
||||
<div style={{ margin: '5px 0' }} key={`${title}-${year}`}>
|
||||
<span>
|
||||
<b>
|
||||
{year}
|
||||
{!showByYear && `-${`${Number(year) + 1}`.slice(-2)}`}:
|
||||
</b>{' '}
|
||||
{acc} {total > 0 ? `/ ${total}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Segment>
|
||||
)
|
||||
}
|
||||
|
||||
StatusContainer.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
current: PropTypes.number.isRequired,
|
||||
previous: PropTypes.number.isRequired,
|
||||
clickable: PropTypes.bool.isRequired,
|
||||
handleClick: PropTypes.func.isRequired,
|
||||
min1: PropTypes.number.isRequired,
|
||||
max1: PropTypes.number.isRequired,
|
||||
showYearlyValues: PropTypes.bool.isRequired,
|
||||
yearlyValues: PropTypes.shape({}).isRequired,
|
||||
showByYear: PropTypes.bool.isRequired
|
||||
}
|
||||
|
||||
const VerticalLine = () => <div style={{ margin: '0 10px', fontSize: '20px' }}>|</div>
|
||||
|
||||
const Status = ({ getStatusGraduatedDispatch, data, loading }) => {
|
||||
const DATE_FORMAT = 'DD.MM.YYYY'
|
||||
const [showYearlyValues, setShowYearlyValues] = useLocalStorage('showYearlyValues', true)
|
||||
const [showByYear, setShowByYear] = useLocalStorage('showByYear', true)
|
||||
const [drillStack, setDrillStack] = useState([])
|
||||
const [showSettings, setShowSettings] = useState(true)
|
||||
const [selectedDate, setSelectedDate] = useState(moment())
|
||||
const [codes, setCodes] = useState([])
|
||||
const { CoolDataScience } = InfoToolTips
|
||||
const isValidDate = d => moment.isMoment(d) && moment().diff(d) > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && isValidDate(selectedDate)) {
|
||||
getStatusGraduatedDispatch({ date: selectedDate.valueOf(), showByYear })
|
||||
}
|
||||
}, [selectedDate, showByYear])
|
||||
|
||||
useEffect(() => {
|
||||
if (codes.length > 0) {
|
||||
const updatedDrillStack = codes.reduce((acc, code) => {
|
||||
const drilled = data[code]
|
||||
// check if the code is on first level of drilldown
|
||||
if (drilled) acc.push(drilled.drill)
|
||||
// if not on first level then use the previous object in array
|
||||
// this might be source of bugs but its the best I could come up with
|
||||
else if (acc.length > 0 && acc[0][code]) acc.push(acc[0][code].drill)
|
||||
return acc
|
||||
}, [])
|
||||
setDrillStack(updatedDrillStack)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const handleShowYearlyValuesToggled = () => {
|
||||
const yearlyValues = showYearlyValues
|
||||
setShowYearlyValues(!showYearlyValues)
|
||||
sendAnalytics(`SG Show yearly values toggle ${!yearlyValues ? 'on' : 'off'}`, 'Status graduated')
|
||||
}
|
||||
|
||||
const handleShowByYearToggled = () => {
|
||||
const byYear = showByYear
|
||||
setShowByYear(!showByYear)
|
||||
sendAnalytics(`SG Show by year toggle ${!byYear ? 'on' : 'off'}`, 'Status graduated')
|
||||
}
|
||||
|
||||
const pushToDrillStack = (values, code) => {
|
||||
const updatedCodes = [...codes].concat(code)
|
||||
const updatedDrillStack = [...drillStack].concat(values)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('SG Drilldown clicked', 'Status graduated')
|
||||
}
|
||||
|
||||
const popFromDrillStack = () => {
|
||||
const updatedDrillStack = _.dropRight([...drillStack], 1)
|
||||
const updatedCodes = _.dropRight([...codes], 1)
|
||||
setCodes(updatedCodes)
|
||||
setDrillStack(updatedDrillStack)
|
||||
sendAnalytics('SG Drillup clicked', 'Status graduated')
|
||||
}
|
||||
|
||||
const renderSettings = () => {
|
||||
return (
|
||||
<Accordion style={{ padding: 0, paddingTop: '10px', flex: 1 }}>
|
||||
<Accordion.Title style={{ padding: 0, cursor: 'default' }} active={showSettings}>
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => setShowSettings(!showSettings)}>
|
||||
<Icon name="setting" />
|
||||
<span>Asetukset</span>
|
||||
</span>
|
||||
</Accordion.Title>
|
||||
<Accordion.Content style={{ padding: 0, marginTop: '10px' }} active={showSettings}>
|
||||
<Segment style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
background: 'red',
|
||||
transform: 'rotateY(0deg) rotate(45deg)',
|
||||
position: 'absolute',
|
||||
top: '-6px',
|
||||
left: '35px',
|
||||
border: '1px solid #dededf',
|
||||
borderRight: 'none',
|
||||
borderBottom: 'none',
|
||||
backgroundColor: 'white'
|
||||
}}
|
||||
/>
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä edelliset vuodet"
|
||||
onChange={handleShowYearlyValuesToggled}
|
||||
checked={showYearlyValues}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Checkbox
|
||||
style={{ fontSize: '0.9em', fontWeight: 'normal' }}
|
||||
label="Näytä kalenterivuosittain"
|
||||
onChange={handleShowByYearToggled}
|
||||
checked={showByYear}
|
||||
/>
|
||||
<VerticalLine />
|
||||
<Form>
|
||||
<Form.Field error={!isValidDate(selectedDate)} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.9em' }}>Näytä päivänä:</span>
|
||||
<Datetime
|
||||
className="status-date-time-input"
|
||||
dateFormat={DATE_FORMAT}
|
||||
timeFormat={false}
|
||||
closeOnSelect
|
||||
value={selectedDate}
|
||||
locale="fi"
|
||||
isValidDate={isValidDate}
|
||||
onChange={setSelectedDate}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Segment>
|
||||
</Accordion.Content>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
const DrilldownMessage = () => (
|
||||
<Message
|
||||
color="blue"
|
||||
content="Klikkaamalla tiedekuntaa pystyt porautumaan koulutusohjelma tasolle.
|
||||
Vasemmassa yläkulmassa olevaa nuolta klikkaamalla pääset edelliseen näkymään."
|
||||
/>
|
||||
)
|
||||
|
||||
if (!data || loading)
|
||||
return (
|
||||
<Segment style={{ padding: '40px' }} textAlign="center">
|
||||
<Dimmer inverted active />
|
||||
<Loader active={loading} />
|
||||
</Segment>
|
||||
)
|
||||
|
||||
const orderedAbsDiffs = _.orderBy(
|
||||
Object.values(_.last(drillStack) || data).map(({ current, previous }) => {
|
||||
return Math.abs(Math.floor((getP(current, previous) - 1) * 1000) / 10)
|
||||
})
|
||||
)
|
||||
const medianDiff = orderedAbsDiffs[Math.round((orderedAbsDiffs.length - 1) / 2)]
|
||||
return (
|
||||
<>
|
||||
<h2>Uusista ohjelmista valmistuneet</h2>
|
||||
<DrilldownMessage />
|
||||
<div style={{ display: 'flex', marginBottom: '20px', marginRight: '40px' }}>
|
||||
{drillStack.length > 0 && (
|
||||
<Icon onClick={popFromDrillStack} style={{ fontSize: '40px', cursor: 'pointer' }} name="arrow left" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, 240px)',
|
||||
gridTemplateRows: 'repeat(auto-fill) 20px',
|
||||
gridGap: '20px',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{_.orderBy(
|
||||
Object.entries(_.last(drillStack) || data),
|
||||
([, { current, previous }]) => getP(current, previous), // oh god<r
|
||||
['desc']
|
||||
).map(([code, stats]) => {
|
||||
const handleClick = () => pushToDrillStack(stats.drill, code)
|
||||
|
||||
return (
|
||||
<StatusContainer
|
||||
key={code}
|
||||
clickable={!!stats.drill}
|
||||
handleClick={handleClick}
|
||||
title={getTextIn(stats.name)}
|
||||
current={stats.current}
|
||||
previous={stats.previous}
|
||||
showYearlyValues={showYearlyValues}
|
||||
min1={-medianDiff * 2}
|
||||
max1={medianDiff * 2}
|
||||
yearlyValues={stats.yearly}
|
||||
showByYear={showByYear}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{renderSettings()}
|
||||
<Message>
|
||||
<ReactMarkdown source={CoolDataScience.statusGraduated} escapeHtml={false} />
|
||||
</Message>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Status.propTypes = {
|
||||
data: shape({}).isRequired,
|
||||
loading: bool.isRequired,
|
||||
getStatusGraduatedDispatch: func.isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ coolDataScience }) => ({
|
||||
data: coolDataScience.data.graduated,
|
||||
loading: coolDataScience.pending.graduated
|
||||
})
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ getStatusGraduatedDispatch: getStatusGraduated }
|
||||
)(Status)
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import { Tab } from 'semantic-ui-react'
|
||||
import Status from './Status'
|
||||
import Graduated from './StatusGraduated'
|
||||
|
||||
const StatusPanel = () => {
|
||||
const panes = [
|
||||
{ menuItem: 'Attainments', render: () => <Status /> },
|
||||
{ menuItem: 'Graduations', render: () => <Graduated /> }
|
||||
]
|
||||
return <Tab panes={panes} />
|
||||
}
|
||||
|
||||
export default StatusPanel
|
||||
@@ -1,125 +0,0 @@
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
const rowType = PropTypes.array.isRequired
|
||||
export const dataPropTypes = {
|
||||
ebin: PropTypes.arrayOf(rowType).isRequired,
|
||||
ok: PropTypes.arrayOf(rowType).isRequired,
|
||||
not_ok: PropTypes.arrayOf(rowType).isRequired
|
||||
}
|
||||
|
||||
export default {
|
||||
ebin: [
|
||||
['H10', 'Teologinen tiedekunta', '2017-07-31 21:00:00+00', 34],
|
||||
['H10', 'Teologinen tiedekunta', '2018-07-31 21:00:00+00', 78],
|
||||
['H10', 'Teologinen tiedekunta', '2019-07-31 21:00:00+00', 44],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 222],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 184],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 59],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 48],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 42],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 32],
|
||||
['H40', 'Humanistinen tiedekunta', '2017-07-31 21:00:00+00', 217],
|
||||
['H40', 'Humanistinen tiedekunta', '2018-07-31 21:00:00+00', 248],
|
||||
['H40', 'Humanistinen tiedekunta', '2019-07-31 21:00:00+00', 164],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2017-07-31 21:00:00+00', 164],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2018-07-31 21:00:00+00', 235],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2019-07-31 21:00:00+00', 290],
|
||||
['H55', 'Farmasian tiedekunta', '2017-07-31 21:00:00+00', 138],
|
||||
['H55', 'Farmasian tiedekunta', '2018-07-31 21:00:00+00', 134],
|
||||
['H55', 'Farmasian tiedekunta', '2019-07-31 21:00:00+00', 135],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2017-07-31 21:00:00+00', 84],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2018-07-31 21:00:00+00', 91],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2019-07-31 21:00:00+00', 114],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 194],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 191],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 133],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2017-07-31 21:00:00+00', 144],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2018-07-31 21:00:00+00', 160],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2019-07-31 21:00:00+00', 129],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2017-07-31 21:00:00+00', 36],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2018-07-31 21:00:00+00', 44],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2019-07-31 21:00:00+00', 30],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2017-07-31 21:00:00+00', 133],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2018-07-31 21:00:00+00', 149],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2019-07-31 21:00:00+00', 122],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 42],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 38],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 9]
|
||||
],
|
||||
ok: [
|
||||
['H10', 'Teologinen tiedekunta', '2017-07-31 21:00:00+00', 59],
|
||||
['H10', 'Teologinen tiedekunta', '2018-07-31 21:00:00+00', 115],
|
||||
['H10', 'Teologinen tiedekunta', '2019-07-31 21:00:00+00', 128],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 246],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 229],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 184],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 70],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 70],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 68],
|
||||
['H40', 'Humanistinen tiedekunta', '2017-07-31 21:00:00+00', 343],
|
||||
['H40', 'Humanistinen tiedekunta', '2018-07-31 21:00:00+00', 425],
|
||||
['H40', 'Humanistinen tiedekunta', '2019-07-31 21:00:00+00', 434],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2017-07-31 21:00:00+00', 303],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2018-07-31 21:00:00+00', 406],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2019-07-31 21:00:00+00', 551],
|
||||
['H55', 'Farmasian tiedekunta', '2017-07-31 21:00:00+00', 148],
|
||||
['H55', 'Farmasian tiedekunta', '2018-07-31 21:00:00+00', 149],
|
||||
['H55', 'Farmasian tiedekunta', '2019-07-31 21:00:00+00', 162],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2017-07-31 21:00:00+00', 133],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2018-07-31 21:00:00+00', 162],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2019-07-31 21:00:00+00', 189],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 366],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 423],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 478],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2017-07-31 21:00:00+00', 217],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2018-07-31 21:00:00+00', 235],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2019-07-31 21:00:00+00', 265],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2017-07-31 21:00:00+00', 69],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2018-07-31 21:00:00+00', 82],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2019-07-31 21:00:00+00', 101],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2017-07-31 21:00:00+00', 193],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2018-07-31 21:00:00+00', 206],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2019-07-31 21:00:00+00', 222],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 67],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 68],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 61]
|
||||
],
|
||||
not_ok: [
|
||||
['H10', 'Teologinen tiedekunta', '2017-07-31 21:00:00+00', 77],
|
||||
['H10', 'Teologinen tiedekunta', '2018-07-31 21:00:00+00', 52],
|
||||
['H10', 'Teologinen tiedekunta', '2019-07-31 21:00:00+00', 41],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 25],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 26],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 64],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 7],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 5],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 9],
|
||||
['H40', 'Humanistinen tiedekunta', '2017-07-31 21:00:00+00', 159],
|
||||
['H40', 'Humanistinen tiedekunta', '2018-07-31 21:00:00+00', 115],
|
||||
['H40', 'Humanistinen tiedekunta', '2019-07-31 21:00:00+00', 130],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2017-07-31 21:00:00+00', 351],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2018-07-31 21:00:00+00', 252],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2019-07-31 21:00:00+00', 170],
|
||||
['H55', 'Farmasian tiedekunta', '2017-07-31 21:00:00+00', 23],
|
||||
['H55', 'Farmasian tiedekunta', '2018-07-31 21:00:00+00', 21],
|
||||
['H55', 'Farmasian tiedekunta', '2019-07-31 21:00:00+00', 7],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2017-07-31 21:00:00+00', 58],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2018-07-31 21:00:00+00', 29],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2019-07-31 21:00:00+00', 10],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 111],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 103],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 60],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2017-07-31 21:00:00+00', 72],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2018-07-31 21:00:00+00', 56],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2019-07-31 21:00:00+00', 27],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2017-07-31 21:00:00+00', 34],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2018-07-31 21:00:00+00', 22],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2019-07-31 21:00:00+00', 12],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2017-07-31 21:00:00+00', 72],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2018-07-31 21:00:00+00', 68],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2019-07-31 21:00:00+00', 50],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 5],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 1],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 8]
|
||||
]
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
export const statsDate = new Date('2020-02-03T11:40:00Z')
|
||||
|
||||
export const statsNow = {
|
||||
ebin: [
|
||||
['H10', 'Teologinen tiedekunta', '2017-07-31 21:00:00+00', 35],
|
||||
['H10', 'Teologinen tiedekunta', '2018-07-31 21:00:00+00', 77],
|
||||
['H10', 'Teologinen tiedekunta', '2019-07-31 21:00:00+00', 43],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 224],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 187],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 59],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 48],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 42],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 29],
|
||||
['H40', 'Humanistinen tiedekunta', '2017-07-31 21:00:00+00', 226],
|
||||
['H40', 'Humanistinen tiedekunta', '2018-07-31 21:00:00+00', 247],
|
||||
['H40', 'Humanistinen tiedekunta', '2019-07-31 21:00:00+00', 193],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2017-07-31 21:00:00+00', 163],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2018-07-31 21:00:00+00', 246],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2019-07-31 21:00:00+00', 336],
|
||||
['H55', 'Farmasian tiedekunta', '2017-07-31 21:00:00+00', 139],
|
||||
['H55', 'Farmasian tiedekunta', '2018-07-31 21:00:00+00', 136],
|
||||
['H55', 'Farmasian tiedekunta', '2019-07-31 21:00:00+00', 137],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2017-07-31 21:00:00+00', 89],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2018-07-31 21:00:00+00', 99],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2019-07-31 21:00:00+00', 110],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 195],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 207],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 130],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2017-07-31 21:00:00+00', 153],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2018-07-31 21:00:00+00', 173],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2019-07-31 21:00:00+00', 140],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2017-07-31 21:00:00+00', 40],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2018-07-31 21:00:00+00', 42],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2019-07-31 21:00:00+00', 36],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2017-07-31 21:00:00+00', 133],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2018-07-31 21:00:00+00', 153],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2019-07-31 21:00:00+00', 124],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 40],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 37],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 9]
|
||||
],
|
||||
ok: [
|
||||
['H10', 'Teologinen tiedekunta', '2017-07-31 21:00:00+00', 61],
|
||||
['H10', 'Teologinen tiedekunta', '2018-07-31 21:00:00+00', 115],
|
||||
['H10', 'Teologinen tiedekunta', '2019-07-31 21:00:00+00', 126],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 246],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 231],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 178],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 70],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 70],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 68],
|
||||
['H40', 'Humanistinen tiedekunta', '2017-07-31 21:00:00+00', 348],
|
||||
['H40', 'Humanistinen tiedekunta', '2018-07-31 21:00:00+00', 428],
|
||||
['H40', 'Humanistinen tiedekunta', '2019-07-31 21:00:00+00', 461],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2017-07-31 21:00:00+00', 311],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2018-07-31 21:00:00+00', 412],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2019-07-31 21:00:00+00', 549],
|
||||
['H55', 'Farmasian tiedekunta', '2017-07-31 21:00:00+00', 150],
|
||||
['H55', 'Farmasian tiedekunta', '2018-07-31 21:00:00+00', 150],
|
||||
['H55', 'Farmasian tiedekunta', '2019-07-31 21:00:00+00', 162],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2017-07-31 21:00:00+00', 133],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2018-07-31 21:00:00+00', 162],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2019-07-31 21:00:00+00', 189],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 370],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 428],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 463],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2017-07-31 21:00:00+00', 220],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2018-07-31 21:00:00+00', 234],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2019-07-31 21:00:00+00', 265],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2017-07-31 21:00:00+00', 69],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2018-07-31 21:00:00+00', 81],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2019-07-31 21:00:00+00', 100],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2017-07-31 21:00:00+00', 193],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2018-07-31 21:00:00+00', 206],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2019-07-31 21:00:00+00', 226],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 67],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 68],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 58]
|
||||
],
|
||||
not_ok: [
|
||||
['H10', 'Teologinen tiedekunta', '2017-07-31 21:00:00+00', 75],
|
||||
['H10', 'Teologinen tiedekunta', '2018-07-31 21:00:00+00', 52],
|
||||
['H10', 'Teologinen tiedekunta', '2019-07-31 21:00:00+00', 43],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 25],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 24],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 70],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 7],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 5],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 9],
|
||||
['H40', 'Humanistinen tiedekunta', '2017-07-31 21:00:00+00', 154],
|
||||
['H40', 'Humanistinen tiedekunta', '2018-07-31 21:00:00+00', 112],
|
||||
['H40', 'Humanistinen tiedekunta', '2019-07-31 21:00:00+00', 103],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2017-07-31 21:00:00+00', 343],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2018-07-31 21:00:00+00', 246],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2019-07-31 21:00:00+00', 172],
|
||||
['H55', 'Farmasian tiedekunta', '2017-07-31 21:00:00+00', 21],
|
||||
['H55', 'Farmasian tiedekunta', '2018-07-31 21:00:00+00', 20],
|
||||
['H55', 'Farmasian tiedekunta', '2019-07-31 21:00:00+00', 7],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2017-07-31 21:00:00+00', 58],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2018-07-31 21:00:00+00', 29],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2019-07-31 21:00:00+00', 10],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 107],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 98],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 75],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2017-07-31 21:00:00+00', 69],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2018-07-31 21:00:00+00', 57],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2019-07-31 21:00:00+00', 27],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2017-07-31 21:00:00+00', 34],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2018-07-31 21:00:00+00', 23],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2019-07-31 21:00:00+00', 13],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2017-07-31 21:00:00+00', 72],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2018-07-31 21:00:00+00', 68],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2019-07-31 21:00:00+00', 46],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 5],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 1],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 11]
|
||||
]
|
||||
}
|
||||
|
||||
export const stats2MonthsAgo = {
|
||||
ebin: [
|
||||
['H10', 'Teologinen tiedekunta', '2017-07-31 21:00:00+00', 35],
|
||||
['H10', 'Teologinen tiedekunta', '2018-07-31 21:00:00+00', 77],
|
||||
['H10', 'Teologinen tiedekunta', '2019-07-31 21:00:00+00', 20],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 233],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 196],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 83],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 48],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 52],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 40],
|
||||
['H40', 'Humanistinen tiedekunta', '2017-07-31 21:00:00+00', 209],
|
||||
['H40', 'Humanistinen tiedekunta', '2018-07-31 21:00:00+00', 221],
|
||||
['H40', 'Humanistinen tiedekunta', '2019-07-31 21:00:00+00', 91],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2017-07-31 21:00:00+00', 161],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2018-07-31 21:00:00+00', 226],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2019-07-31 21:00:00+00', 249],
|
||||
['H55', 'Farmasian tiedekunta', '2017-07-31 21:00:00+00', 140],
|
||||
['H55', 'Farmasian tiedekunta', '2018-07-31 21:00:00+00', 138],
|
||||
['H55', 'Farmasian tiedekunta', '2019-07-31 21:00:00+00', 128],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2017-07-31 21:00:00+00', 88],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2018-07-31 21:00:00+00', 96],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2019-07-31 21:00:00+00', 112],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 213],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 175],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 140],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2017-07-31 21:00:00+00', 143],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2018-07-31 21:00:00+00', 149],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2019-07-31 21:00:00+00', 126],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2017-07-31 21:00:00+00', 34],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2018-07-31 21:00:00+00', 28],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2019-07-31 21:00:00+00', 15],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2017-07-31 21:00:00+00', 137],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2018-07-31 21:00:00+00', 143],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2019-07-31 21:00:00+00', 94],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 15],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 35],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 19]
|
||||
],
|
||||
ok: [
|
||||
['H10', 'Teologinen tiedekunta', '2017-07-31 21:00:00+00', 59],
|
||||
['H10', 'Teologinen tiedekunta', '2018-07-31 21:00:00+00', 117],
|
||||
['H10', 'Teologinen tiedekunta', '2019-07-31 21:00:00+00', 73],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 246],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 230],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 183],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 70],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 70],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 61],
|
||||
['H40', 'Humanistinen tiedekunta', '2017-07-31 21:00:00+00', 342],
|
||||
['H40', 'Humanistinen tiedekunta', '2018-07-31 21:00:00+00', 423],
|
||||
['H40', 'Humanistinen tiedekunta', '2019-07-31 21:00:00+00', 213],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2017-07-31 21:00:00+00', 303],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2018-07-31 21:00:00+00', 408],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2019-07-31 21:00:00+00', 510],
|
||||
['H55', 'Farmasian tiedekunta', '2017-07-31 21:00:00+00', 150],
|
||||
['H55', 'Farmasian tiedekunta', '2018-07-31 21:00:00+00', 150],
|
||||
['H55', 'Farmasian tiedekunta', '2019-07-31 21:00:00+00', 162],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2017-07-31 21:00:00+00', 135],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2018-07-31 21:00:00+00', 165],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2019-07-31 21:00:00+00', 188],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 368],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 431],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 380],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2017-07-31 21:00:00+00', 221],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2018-07-31 21:00:00+00', 235],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2019-07-31 21:00:00+00', 261],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2017-07-31 21:00:00+00', 70],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2018-07-31 21:00:00+00', 80],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2019-07-31 21:00:00+00', 94],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2017-07-31 21:00:00+00', 191],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2018-07-31 21:00:00+00', 206],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2019-07-31 21:00:00+00', 188],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 63],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 69],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 63]
|
||||
],
|
||||
not_ok: [
|
||||
['H10', 'Teologinen tiedekunta', '2017-07-31 21:00:00+00', 77],
|
||||
['H10', 'Teologinen tiedekunta', '2018-07-31 21:00:00+00', 50],
|
||||
['H10', 'Teologinen tiedekunta', '2019-07-31 21:00:00+00', 96],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 25],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 25],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 65],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 7],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 5],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 16],
|
||||
['H40', 'Humanistinen tiedekunta', '2017-07-31 21:00:00+00', 160],
|
||||
['H40', 'Humanistinen tiedekunta', '2018-07-31 21:00:00+00', 117],
|
||||
['H40', 'Humanistinen tiedekunta', '2019-07-31 21:00:00+00', 351],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2017-07-31 21:00:00+00', 351],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2018-07-31 21:00:00+00', 250],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2019-07-31 21:00:00+00', 211],
|
||||
['H55', 'Farmasian tiedekunta', '2017-07-31 21:00:00+00', 21],
|
||||
['H55', 'Farmasian tiedekunta', '2018-07-31 21:00:00+00', 20],
|
||||
['H55', 'Farmasian tiedekunta', '2019-07-31 21:00:00+00', 7],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2017-07-31 21:00:00+00', 56],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2018-07-31 21:00:00+00', 26],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2019-07-31 21:00:00+00', 11],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 109],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 95],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 158],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2017-07-31 21:00:00+00', 68],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2018-07-31 21:00:00+00', 56],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2019-07-31 21:00:00+00', 31],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2017-07-31 21:00:00+00', 33],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2018-07-31 21:00:00+00', 24],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2019-07-31 21:00:00+00', 19],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2017-07-31 21:00:00+00', 74],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2018-07-31 21:00:00+00', 68],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2019-07-31 21:00:00+00', 84],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 9],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 0],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 6]
|
||||
]
|
||||
}
|
||||
|
||||
export const stats4MonthsAgo = {
|
||||
ebin: [
|
||||
['H10', 'Teologinen tiedekunta', '2017-07-31 21:00:00+00', 37],
|
||||
['H10', 'Teologinen tiedekunta', '2018-07-31 21:00:00+00', 63],
|
||||
['H10', 'Teologinen tiedekunta', '2019-07-31 21:00:00+00', 22],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 221],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 205],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 77],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 54],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 38],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 26],
|
||||
['H40', 'Humanistinen tiedekunta', '2017-07-31 21:00:00+00', 223],
|
||||
['H40', 'Humanistinen tiedekunta', '2018-07-31 21:00:00+00', 226],
|
||||
['H40', 'Humanistinen tiedekunta', '2019-07-31 21:00:00+00', 72],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2017-07-31 21:00:00+00', 167],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2018-07-31 21:00:00+00', 200],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2019-07-31 21:00:00+00', 247],
|
||||
['H55', 'Farmasian tiedekunta', '2017-07-31 21:00:00+00', 138],
|
||||
['H55', 'Farmasian tiedekunta', '2018-07-31 21:00:00+00', 130],
|
||||
['H55', 'Farmasian tiedekunta', '2019-07-31 21:00:00+00', 18],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2017-07-31 21:00:00+00', 83],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2018-07-31 21:00:00+00', 79],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2019-07-31 21:00:00+00', 40],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 186],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 154],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 116],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2017-07-31 21:00:00+00', 142],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2018-07-31 21:00:00+00', 124],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2019-07-31 21:00:00+00', 83],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2017-07-31 21:00:00+00', 40],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2018-07-31 21:00:00+00', 22],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2019-07-31 21:00:00+00', 9],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2017-07-31 21:00:00+00', 134],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2018-07-31 21:00:00+00', 125],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2019-07-31 21:00:00+00', 43],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 36],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 36],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 10]
|
||||
],
|
||||
ok: [
|
||||
['H10', 'Teologinen tiedekunta', '2017-07-31 21:00:00+00', 62],
|
||||
['H10', 'Teologinen tiedekunta', '2018-07-31 21:00:00+00', 114],
|
||||
['H10', 'Teologinen tiedekunta', '2019-07-31 21:00:00+00', 30],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 245],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 230],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 140],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 70],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 70],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 29],
|
||||
['H40', 'Humanistinen tiedekunta', '2017-07-31 21:00:00+00', 355],
|
||||
['H40', 'Humanistinen tiedekunta', '2018-07-31 21:00:00+00', 445],
|
||||
['H40', 'Humanistinen tiedekunta', '2019-07-31 21:00:00+00', 102],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2017-07-31 21:00:00+00', 299],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2018-07-31 21:00:00+00', 407],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2019-07-31 21:00:00+00', 287],
|
||||
['H55', 'Farmasian tiedekunta', '2017-07-31 21:00:00+00', 148],
|
||||
['H55', 'Farmasian tiedekunta', '2018-07-31 21:00:00+00', 150],
|
||||
['H55', 'Farmasian tiedekunta', '2019-07-31 21:00:00+00', 32],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2017-07-31 21:00:00+00', 137],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2018-07-31 21:00:00+00', 169],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2019-07-31 21:00:00+00', 54],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 373],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 433],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 141],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2017-07-31 21:00:00+00', 219],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2018-07-31 21:00:00+00', 234],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2019-07-31 21:00:00+00', 92],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2017-07-31 21:00:00+00', 68],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2018-07-31 21:00:00+00', 79],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2019-07-31 21:00:00+00', 12],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2017-07-31 21:00:00+00', 189],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2018-07-31 21:00:00+00', 207],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2019-07-31 21:00:00+00', 58],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 65],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 68],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 22]
|
||||
],
|
||||
not_ok: [
|
||||
['H10', 'Teologinen tiedekunta', '2017-07-31 21:00:00+00', 74],
|
||||
['H10', 'Teologinen tiedekunta', '2018-07-31 21:00:00+00', 53],
|
||||
['H10', 'Teologinen tiedekunta', '2019-07-31 21:00:00+00', 139],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 26],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 25],
|
||||
['H20', 'Oikeustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 108],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 7],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 5],
|
||||
['H30', 'Lääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 48],
|
||||
['H40', 'Humanistinen tiedekunta', '2017-07-31 21:00:00+00', 147],
|
||||
['H40', 'Humanistinen tiedekunta', '2018-07-31 21:00:00+00', 95],
|
||||
['H40', 'Humanistinen tiedekunta', '2019-07-31 21:00:00+00', 462],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2017-07-31 21:00:00+00', 355],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2018-07-31 21:00:00+00', 251],
|
||||
['H50', 'Matemaattis-luonnontieteellinen tiedekunta', '2019-07-31 21:00:00+00', 434],
|
||||
['H55', 'Farmasian tiedekunta', '2017-07-31 21:00:00+00', 23],
|
||||
['H55', 'Farmasian tiedekunta', '2018-07-31 21:00:00+00', 20],
|
||||
['H55', 'Farmasian tiedekunta', '2019-07-31 21:00:00+00', 137],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2017-07-31 21:00:00+00', 54],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2018-07-31 21:00:00+00', 22],
|
||||
['H57', 'Bio- ja ympäristötieteellinen tiedekunta', '2019-07-31 21:00:00+00', 145],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2017-07-31 21:00:00+00', 104],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2018-07-31 21:00:00+00', 93],
|
||||
['H60', 'Kasvatustieteellinen tiedekunta', '2019-07-31 21:00:00+00', 397],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2017-07-31 21:00:00+00', 70],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2018-07-31 21:00:00+00', 57],
|
||||
['H70', 'Valtiotieteellinen tiedekunta', '2019-07-31 21:00:00+00', 200],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2017-07-31 21:00:00+00', 35],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2018-07-31 21:00:00+00', 25],
|
||||
['H74', 'Svenska social- och kommunalhögskolan', '2019-07-31 21:00:00+00', 101],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2017-07-31 21:00:00+00', 76],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2018-07-31 21:00:00+00', 67],
|
||||
['H80', 'Maatalous-metsätieteellinen tiedekunta', '2019-07-31 21:00:00+00', 214],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2017-07-31 21:00:00+00', 7],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2018-07-31 21:00:00+00', 1],
|
||||
['H90', 'Eläinlääketieteellinen tiedekunta', '2019-07-31 21:00:00+00', 47]
|
||||
]
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
-- Ebin 3y students 4 months ago
|
||||
SELECT
|
||||
org.code,
|
||||
org.name->>'fi' AS name,
|
||||
studyright.studystartdate,
|
||||
COUNT(DISTINCT studyright.student_studentnumber)
|
||||
FROM organization org
|
||||
INNER JOIN studyright
|
||||
ON studyright.faculty_code = org.code
|
||||
LEFT JOIN
|
||||
(
|
||||
SELECT student_studentnumber, sum(credits) credits_sum FROM credit
|
||||
WHERE attainment_date >= '2017-08-01 03:00:00+00'
|
||||
GROUP BY student_studentnumber
|
||||
) credits
|
||||
ON credits.student_studentnumber = studyright.student_studentnumber
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
WHERE
|
||||
(credits.credits_sum IS NOT NULL
|
||||
AND credits.credits_sum >= pg_temp.mapRange(
|
||||
EXTRACT(EPOCH FROM (now() - INTERVAL '4 months')),
|
||||
EXTRACT(EPOCH FROM studyright.studystartdate),
|
||||
EXTRACT(EPOCH FROM TIMESTAMP '2020-07-31'),
|
||||
0,
|
||||
1
|
||||
) * (
|
||||
CASE studyright.studystartdate
|
||||
WHEN '2017-07-31 21:00:00+00' THEN 180
|
||||
WHEN '2018-07-31 21:00:00+00' THEN 120
|
||||
WHEN '2019-07-31 21:00:00+00' THEN 60
|
||||
ELSE -1
|
||||
END
|
||||
)
|
||||
)
|
||||
AND studyright.extentcode = 1
|
||||
AND studyright.studystartdate >= '2017-07-31 00:00:00+00'
|
||||
AND transfers.studyrightid IS NULL -- not transferredA
|
||||
GROUP BY 1, 3
|
||||
ORDER BY 1, 3;
|
||||
|
||||
|
||||
-- Ok 4y students
|
||||
SELECT
|
||||
org.code,
|
||||
org.name->>'fi' AS name,
|
||||
studyright.studystartdate,
|
||||
COUNT(DISTINCT studyright.student_studentnumber)
|
||||
FROM organization org
|
||||
INNER JOIN studyright
|
||||
ON studyright.faculty_code = org.code
|
||||
LEFT JOIN
|
||||
(
|
||||
SELECT student_studentnumber, sum(credits) credits_sum FROM credit
|
||||
WHERE attainment_date >= '2017-08-01 03:00:00+00'
|
||||
GROUP BY student_studentnumber
|
||||
) credits
|
||||
ON credits.student_studentnumber = studyright.student_studentnumber
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
WHERE
|
||||
(credits.credits_sum IS NOT NULL
|
||||
AND credits.credits_sum >= pg_temp.mapRange(
|
||||
EXTRACT(EPOCH FROM (now() - INTERVAL '4 months')),
|
||||
EXTRACT(EPOCH FROM studyright.studystartdate),
|
||||
EXTRACT(EPOCH FROM TIMESTAMP '2021-07-31'),
|
||||
0,
|
||||
1
|
||||
) * (
|
||||
CASE studyright.studystartdate
|
||||
WHEN '2017-07-31 21:00:00+00' THEN 180
|
||||
WHEN '2018-07-31 21:00:00+00' THEN 120
|
||||
WHEN '2019-07-31 21:00:00+00' THEN 60
|
||||
ELSE -1
|
||||
END
|
||||
)
|
||||
)
|
||||
AND studyright.extentcode = 1
|
||||
AND studyright.studystartdate >= '2017-07-31 00:00:00+00'
|
||||
AND transfers.studyrightid IS NULL -- not transferredA
|
||||
GROUP BY 1, 3
|
||||
ORDER BY 1, 3;
|
||||
|
||||
-- below 4y students
|
||||
SELECT
|
||||
org.code,
|
||||
org.name->>'fi' AS name,
|
||||
studyright.studystartdate,
|
||||
COUNT(DISTINCT studyright.student_studentnumber)
|
||||
FROM organization org
|
||||
INNER JOIN studyright
|
||||
ON studyright.faculty_code = org.code
|
||||
LEFT JOIN
|
||||
(
|
||||
SELECT student_studentnumber, sum(credits) credits_sum FROM credit
|
||||
WHERE attainment_date >= '2017-08-01 03:00:00+00'
|
||||
GROUP BY student_studentnumber
|
||||
) credits
|
||||
ON credits.student_studentnumber = studyright.student_studentnumber
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
WHERE
|
||||
(credits.credits_sum IS NULL
|
||||
OR credits.credits_sum < pg_temp.mapRange(
|
||||
EXTRACT(EPOCH FROM (now() - INTERVAL '4 months')),
|
||||
EXTRACT(EPOCH FROM studyright.studystartdate),
|
||||
EXTRACT(EPOCH FROM TIMESTAMP '2021-07-31'),
|
||||
0,
|
||||
1
|
||||
) * (
|
||||
CASE studyright.studystartdate
|
||||
WHEN '2017-07-31 21:00:00+00' THEN 180
|
||||
WHEN '2018-07-31 21:00:00+00' THEN 120
|
||||
WHEN '2019-07-31 21:00:00+00' THEN 60
|
||||
ELSE -1
|
||||
END
|
||||
)
|
||||
)
|
||||
AND studyright.extentcode = 1
|
||||
AND studyright.studystartdate >= '2017-07-31 00:00:00+00'
|
||||
AND transfers.studyrightid IS NULL -- not transferredA
|
||||
GROUP BY 1, 3
|
||||
ORDER BY 1, 3;
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION pg_temp.mapRange(
|
||||
input DOUBLE PRECISION,
|
||||
inMin DOUBLE PRECISION,
|
||||
inMax DOUBLE PRECISION,
|
||||
outMin DOUBLE PRECISION,
|
||||
outMax DOUBLE PRECISION)
|
||||
RETURNS DOUBLE PRECISION AS $$
|
||||
SELECT (input - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
|
||||
$$ LANGUAGE SQL;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
-- Studyright element names (type 30) by faculty code
|
||||
SELECT
|
||||
element_details.name->>'fi',
|
||||
COUNT(DISTINCT studyright.student_studentnumber)
|
||||
FROM studyright
|
||||
LEFT JOIN
|
||||
(
|
||||
SELECT student_studentnumber, sum(credits) credits_sum FROM credit
|
||||
WHERE attainment_date >= '2017-08-01 03:00:00+00'
|
||||
GROUP BY student_studentnumber
|
||||
) credits
|
||||
ON credits.student_studentnumber = studyright.student_studentnumber
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
LEFT JOIN studyright_elements
|
||||
ON studyright.studyrightid = studyright_elements.studyrightid
|
||||
AND studyright.student_studentnumber = studyright_elements.studentnumber
|
||||
LEFT JOIN element_details
|
||||
ON studyright_elements.code = element_details.code
|
||||
WHERE
|
||||
studyright.faculty_code = 'H60'
|
||||
AND element_details.type IN (30)
|
||||
AND studyright.extentcode = 1
|
||||
AND studyright.studystartdate >= '2017-07-31 00:00:00+00'
|
||||
AND transfers.studyrightid IS NULL -- not transferredA
|
||||
GROUP BY 1
|
||||
ORDER BY 2 DESC;
|
||||
@@ -1,154 +0,0 @@
|
||||
-- Tavoiteaika~: >=55
|
||||
-- +1 aika: >= 40
|
||||
-- Ei-tavoiteajassa: < 40
|
||||
|
||||
|
||||
-- Studyrights of extent 1 (bachelors) under matlu (H50) who started 2017
|
||||
SELECT
|
||||
COUNT(DISTINCT studyright.student_studentnumber) AS student_count
|
||||
FROM studyright
|
||||
LEFT JOIN transfers
|
||||
ON studyright.student_studentnumber = transfers.studentnumber
|
||||
WHERE
|
||||
studyright.extentcode = 1 -- bachelor's
|
||||
AND studyright.faculty_code = 'H50' -- matlu
|
||||
AND studyright.studystartdate = '2017-07-31 21:00:00+00' -- 2017
|
||||
AND transfers.studyrightid IS NULL; -- not transferred
|
||||
|
||||
|
||||
-- ANY credits on first year
|
||||
SELECT COUNT(DISTINCT studyright.student_studentnumber)
|
||||
FROM studyright
|
||||
INNER JOIN
|
||||
(
|
||||
SELECT student_studentnumber FROM
|
||||
(
|
||||
SELECT student_studentnumber, sum(credits) credits_sum FROM credit
|
||||
WHERE attainment_date BETWEEN '2017-08-01 03:00:00+00' AND '2018-07-31 20:59:59+00'
|
||||
GROUP BY student_studentnumber
|
||||
) student_credits
|
||||
) opcount
|
||||
ON opcount.student_studentnumber = studyright.student_studentnumber
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
WHERE studyright.extentcode = 1
|
||||
AND studyright.faculty_code = 'H50'
|
||||
AND studyright.studystartdate = '2017-07-31 21:00:00+00'
|
||||
AND transfers.studyrightid IS NULL; -- not transferred
|
||||
|
||||
|
||||
-- Amount of students who earned 55 or more credits on first semester
|
||||
SELECT COUNT(DISTINCT studyright.student_studentnumber)
|
||||
FROM studyright
|
||||
INNER JOIN
|
||||
(
|
||||
SELECT student_studentnumber FROM
|
||||
(
|
||||
SELECT student_studentnumber, sum(credits) credits_sum FROM credit
|
||||
WHERE attainment_date BETWEEN '2017-08-01 03:00:00+00' AND '2018-07-31 20:59:59+00'
|
||||
GROUP BY student_studentnumber
|
||||
) student_credits
|
||||
WHERE student_credits.credits_sum >= 55
|
||||
) over55op
|
||||
ON over55op.student_studentnumber = studyright.student_studentnumber
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
WHERE studyright.extentcode = 1
|
||||
AND studyright.faculty_code = 'H50'
|
||||
AND studyright.studystartdate = '2017-07-31 21:00:00+00'
|
||||
AND transfers.studyrightid IS NULL; -- not transferred
|
||||
|
||||
-- Amount of students who earned >= 40 < 55 credits on first semester
|
||||
SELECT
|
||||
COUNT(DISTINCT studyright.student_studentnumber)
|
||||
FROM
|
||||
studyright INNER JOIN (
|
||||
SELECT student_studentnumber
|
||||
FROM (
|
||||
SELECT
|
||||
student_studentnumber,
|
||||
sum(credits) credits_sum
|
||||
FROM credit
|
||||
WHERE
|
||||
attainment_date BETWEEN '2017-08-01 03:00:00+00' AND '2018-07-31 20:59:59+00'
|
||||
GROUP BY student_studentnumber
|
||||
) student_credits
|
||||
WHERE
|
||||
student_credits.credits_sum >= 40
|
||||
AND student_credits.credits_sum < 55
|
||||
) over55op
|
||||
ON over55op.student_studentnumber = studyright.student_studentnumber
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
WHERE studyright.extentcode = 1
|
||||
AND studyright.faculty_code = 'H50'
|
||||
AND studyright.studystartdate = '2017-07-31 21:00:00+00'
|
||||
AND transfers.studyrightid IS NULL;
|
||||
|
||||
|
||||
-- Amount of students who earned < 40 credits on first semester
|
||||
SELECT COUNT(DISTINCT studyright.student_studentnumber)
|
||||
FROM studyright
|
||||
INNER JOIN
|
||||
(
|
||||
SELECT student_studentnumber FROM
|
||||
(
|
||||
SELECT student_studentnumber, sum(credits) credits_sum FROM credit
|
||||
WHERE attainment_date BETWEEN '2017-08-01 03:00:00+00' AND '2018-07-31 20:59:59+00'
|
||||
GROUP BY student_studentnumber
|
||||
) student_credits
|
||||
WHERE student_credits.credits_sum < 40
|
||||
) over55op
|
||||
ON over55op.student_studentnumber = studyright.student_studentnumber
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
WHERE studyright.extentcode = 1
|
||||
AND studyright.faculty_code = 'H50'
|
||||
AND studyright.studystartdate = '2017-07-31 21:00:00+00'
|
||||
AND transfers.studyrightid IS NULL; -- not transferred
|
||||
|
||||
|
||||
|
||||
-- No credits on first year
|
||||
SELECT
|
||||
COUNT(DISTINCT studyright.student_studentnumber) AS student_count
|
||||
FROM studyright
|
||||
LEFT JOIN transfers
|
||||
ON studyright.student_studentnumber = transfers.studentnumber
|
||||
LEFT JOIN credit
|
||||
ON studyright.student_studentnumber = credit.student_studentnumber
|
||||
AND attainment_date BETWEEN '2017-08-01 03:00:00+00' AND '2018-07-31 20:59:59+00'
|
||||
WHERE
|
||||
studyright.extentcode = 1 -- bachelor's
|
||||
AND credit.id IS NULL
|
||||
AND studyright.faculty_code = 'H50' -- matlu
|
||||
AND studyright.studystartdate = '2017-07-31 21:00:00+00' -- 2017
|
||||
AND transfers.studyrightid IS NULL; -- not transferred
|
||||
|
||||
|
||||
|
||||
|
||||
-- Those who graduated
|
||||
SELECT
|
||||
COUNT(DISTINCT studyright.student_studentnumber) AS student_count
|
||||
FROM studyright
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
WHERE studyright.extentcode = 1
|
||||
AND studyright.faculty_code = 'H50'
|
||||
AND studyright.studystartdate = '2017-07-31 21:00:00+00'
|
||||
AND studyright.graduated = 1
|
||||
AND transfers.studyrightid IS NULL; -- not transferred
|
||||
|
||||
|
||||
-- Those who were cancelled
|
||||
SELECT
|
||||
COUNT(DISTINCT studyright.student_studentnumber) AS student_count
|
||||
FROM studyright
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
WHERE studyright.extentcode = 1
|
||||
AND studyright.faculty_code = 'H50'
|
||||
AND studyright.studystartdate = '2017-07-31 21:00:00+00'
|
||||
AND studyright.canceldate IS NOT NULL
|
||||
AND transfers.studyrightid IS NULL; -- not transferred
|
||||
@@ -1,27 +0,0 @@
|
||||
-- Studyright element names (type 30) by faculty code
|
||||
SELECT
|
||||
element_details.name->>'fi',
|
||||
COUNT(DISTINCT studyright.student_studentnumber)
|
||||
FROM studyright
|
||||
LEFT JOIN
|
||||
(
|
||||
SELECT student_studentnumber, sum(credits) credits_sum FROM credit
|
||||
WHERE attainment_date >= '2017-08-01 03:00:00+00'
|
||||
GROUP BY student_studentnumber
|
||||
) credits
|
||||
ON credits.student_studentnumber = studyright.student_studentnumber
|
||||
LEFT JOIN transfers
|
||||
ON studyright.studyrightid = transfers.studyrightid
|
||||
LEFT JOIN studyright_elements
|
||||
ON studyright.studyrightid = studyright_elements.studyrightid
|
||||
AND studyright.student_studentnumber = studyright_elements.studentnumber
|
||||
LEFT JOIN element_details
|
||||
ON studyright_elements.code = element_details.code
|
||||
WHERE
|
||||
studyright.faculty_code = 'H60'
|
||||
AND element_details.type IN (30)
|
||||
AND studyright.extentcode = 1
|
||||
AND studyright.studystartdate >= '2017-07-31 00:00:00+00'
|
||||
AND transfers.studyrightid IS NULL -- not transferredA
|
||||
GROUP BY 1
|
||||
ORDER BY 2 DESC;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Component } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { orderBy } from 'lodash'
|
||||
import { connect } from 'react-redux'
|
||||
@@ -13,24 +13,28 @@ import { getMandatoryCourses } from '../../redux/populationMandatoryCourses'
|
||||
|
||||
const { func, shape, string, objectOf, arrayOf, bool } = PropTypes
|
||||
|
||||
class CourseCodeMapper extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
props.getDuplicates(props.studyprogramme)
|
||||
props.getMandatoryCourses(props.studyprogramme)
|
||||
}
|
||||
const CourseCodeMapper = ({
|
||||
studyprogramme,
|
||||
addDuplicate,
|
||||
removeDuplicate,
|
||||
getDuplicates,
|
||||
getMandatoryCourses,
|
||||
language,
|
||||
courseCodeDuplicates,
|
||||
mandatoryCourses,
|
||||
findCoursesDispatch
|
||||
}) => {
|
||||
const [codes, setCodes] = useState({})
|
||||
useEffect(() => {
|
||||
getDuplicates(studyprogramme)
|
||||
getMandatoryCourses(studyprogramme)
|
||||
}, [])
|
||||
|
||||
state = {
|
||||
codes: {}
|
||||
}
|
||||
|
||||
getName = name => {
|
||||
const { language } = this.props
|
||||
const getName = name => {
|
||||
return getTextIn(name, language)
|
||||
}
|
||||
|
||||
getTableRows = () => {
|
||||
const { courseCodeDuplicates, mandatoryCourses, findCoursesDispatch } = this.props
|
||||
const getTableRows = () => {
|
||||
const { data } = courseCodeDuplicates
|
||||
const find = (query, language) => findCoursesDispatch(query, language)
|
||||
const rows = mandatoryCourses.data.map(course => {
|
||||
@@ -38,18 +42,18 @@ class CourseCodeMapper extends Component {
|
||||
const duplicates = maincode ? orderBy(data[maincode].filter(e => e.code !== course.code), ['code'], ['ASC']) : []
|
||||
return (
|
||||
<Table.Row key={course.code}>
|
||||
<Table.Cell>{`${course.code} ${this.getName(course.name)}`}</Table.Cell>
|
||||
<Table.Cell>{`${course.code} ${getName(course.name)}`}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Label.Group>
|
||||
{duplicates.map(e => (
|
||||
<Label key={e.code}>
|
||||
{`${e.code} ${this.getName(e.name)}`}
|
||||
{`${e.code} ${getName(e.name)}`}
|
||||
<Icon
|
||||
style={{ margin: '0 0 0 5px' }}
|
||||
color="red"
|
||||
name="remove circle"
|
||||
title="Remove from group"
|
||||
onClick={this.removeDuplicate(e.code)}
|
||||
onClick={() => removeDuplicate(e.code)}
|
||||
/>
|
||||
</Label>
|
||||
))}
|
||||
@@ -59,14 +63,12 @@ class CourseCodeMapper extends Component {
|
||||
<Grid style={{ minWidth: '350px' }}>
|
||||
<Grid.Column width={11}>
|
||||
<CourseSearch
|
||||
handleResultSelect={(e, { result }) =>
|
||||
this.setState(old => ({ codes: { ...old.codes, [course.code]: result.code } }))
|
||||
}
|
||||
handleResultSelect={(e, { result }) => setCodes({ ...codes, [course.code]: result.code })}
|
||||
findFunction={find}
|
||||
/>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={5}>
|
||||
<Button fluid content="Add" onClick={this.addDuplicate(course.code, this.state.codes[course.code])} />
|
||||
<Button fluid content="Add" onClick={() => addDuplicate(course.code, codes[course.code])} />
|
||||
</Grid.Column>
|
||||
</Grid>
|
||||
</Table.Cell>
|
||||
@@ -76,41 +78,31 @@ class CourseCodeMapper extends Component {
|
||||
return rows
|
||||
}
|
||||
|
||||
addDuplicate = (code1, code2) => () => {
|
||||
this.props.addDuplicate(code1, code2)
|
||||
}
|
||||
|
||||
removeDuplicate = code => () => {
|
||||
this.props.removeDuplicate(code)
|
||||
}
|
||||
|
||||
render() {
|
||||
const pending = this.props.courseCodeDuplicates.pending || this.props.mandatoryCourses.pending
|
||||
const { data } = this.props.mandatoryCourses
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
<Segment className="contentSegment">
|
||||
<Message
|
||||
header="Map mandatory courses to alternative courses"
|
||||
content="By default courses with different codes are considered as separate courses.
|
||||
const pending = courseCodeDuplicates.pending || mandatoryCourses.pending
|
||||
const { data } = mandatoryCourses
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
<Segment className="contentSegment">
|
||||
<Message
|
||||
header="Map mandatory courses to alternative courses"
|
||||
content="By default courses with different codes are considered as separate courses.
|
||||
If this is not the case use this to combine old and new course codes to each other."
|
||||
/>
|
||||
<Loader active={pending} />
|
||||
<Table striped celled className="fixed-header">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Mandatory course</Table.HeaderCell>
|
||||
<Table.HeaderCell>Alternative courses</Table.HeaderCell>
|
||||
<Table.HeaderCell>Add alternative</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{this.getTableRows()}</Table.Body>
|
||||
</Table>
|
||||
{data.length === 0 && <Message info>You need to define mandatory courses first</Message>}
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Loader active={pending} />
|
||||
<Table striped celled className="fixed-header">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Mandatory course</Table.HeaderCell>
|
||||
<Table.HeaderCell>Alternative courses</Table.HeaderCell>
|
||||
<Table.HeaderCell>Add alternative</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>{getTableRows()}</Table.Body>
|
||||
</Table>
|
||||
{data.length === 0 && <Message info>You need to define mandatory courses first</Message>}
|
||||
</Segment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
CourseCodeMapper.propTypes = {
|
||||
|
||||
+53
-69
@@ -1,7 +1,7 @@
|
||||
import React, { Fragment, Component } from 'react'
|
||||
import React, { Fragment, useState, useEffect } from 'react'
|
||||
import { Header, List, Button, Radio, Icon } from 'semantic-ui-react'
|
||||
import sortBy from 'lodash/sortBy'
|
||||
import { bool, func, arrayOf, number } from 'prop-types'
|
||||
import { bool, func, arrayOf, number, shape } from 'prop-types'
|
||||
import { teacherType } from '../util'
|
||||
|
||||
import '../courseGroup.css'
|
||||
@@ -44,49 +44,30 @@ TeacherItem.propTypes = {
|
||||
isActive: bool.isRequired
|
||||
}
|
||||
|
||||
class Index extends Component {
|
||||
static propTypes = {
|
||||
activeTeacherIds: arrayOf(number).isRequired,
|
||||
handleFilterClick: func.isRequired,
|
||||
handleActiveToggleChange: func.isRequired,
|
||||
showOnlyActiveTeachers: bool.isRequired
|
||||
}
|
||||
|
||||
state = {
|
||||
viewableTeachers: [],
|
||||
sortColumn: teacherColumnTypes.NAME,
|
||||
sortReverse: false,
|
||||
activeTeacherCount: 0
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props) {
|
||||
const { teachers, showOnlyActiveTeachers, activeTeacherIds } = props
|
||||
const Index = ({ teachers, showOnlyActiveTeachers, activeTeacherIds, handleFilterClick, handleActiveToggleChange }) => {
|
||||
const [viewableTeachers, setViewableTeachers] = useState([])
|
||||
const [sortColumn, setSortColumn] = useState(teacherColumnTypes.NAME)
|
||||
const [sortReverse, setSortReverse] = useState(false)
|
||||
const [activeTeacherCount, setActiveTeacherCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const activeTeachers = teachers.filter(t => activeTeacherIds.includes(t.id))
|
||||
setActiveTeacherCount(activeTeachers.length)
|
||||
setViewableTeachers(showOnlyActiveTeachers ? activeTeachers : teachers)
|
||||
})
|
||||
|
||||
return {
|
||||
activeTeacherCount: activeTeachers.length,
|
||||
viewableTeachers: showOnlyActiveTeachers ? activeTeachers : teachers
|
||||
}
|
||||
}
|
||||
|
||||
handleTeacherHeaderClick = columnName => {
|
||||
const { sortColumn, sortReverse } = this.state
|
||||
const handleTeacherHeaderClick = columnName => {
|
||||
const isSortColumn = columnName === sortColumn
|
||||
this.setState({
|
||||
sortColumn: columnName,
|
||||
sortReverse: isSortColumn ? !sortReverse : sortReverse
|
||||
})
|
||||
setSortColumn(columnName)
|
||||
setSortReverse(isSortColumn ? !sortReverse : sortReverse)
|
||||
}
|
||||
|
||||
renderListHeader = () => {
|
||||
const { sortColumn, sortReverse } = this.state
|
||||
|
||||
const renderListHeader = () => {
|
||||
const getHeader = (className, label, columnName) => {
|
||||
const isActive = columnName === sortColumn
|
||||
|
||||
return (
|
||||
<div className={className} onClick={() => this.handleTeacherHeaderClick(columnName)}>
|
||||
<div className={className} onClick={() => handleTeacherHeaderClick(columnName)}>
|
||||
{label}
|
||||
{isActive ? <Icon name={`caret ${sortReverse ? 'down' : 'up'}`} /> : null}
|
||||
</div>
|
||||
@@ -105,42 +86,45 @@ class Index extends Component {
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { handleFilterClick, handleActiveToggleChange, showOnlyActiveTeachers, activeTeacherIds } = this.props
|
||||
const { viewableTeachers, activeTeacherCount, sortColumn, sortReverse } = this.state
|
||||
const toggleId = 'toggle'
|
||||
const sortedTeachers = sortBy(viewableTeachers, sortColumn)
|
||||
|
||||
const toggleId = 'toggle'
|
||||
const sortedTeachers = sortBy(viewableTeachers, sortColumn)
|
||||
|
||||
if (sortReverse) {
|
||||
sortedTeachers.reverse()
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Header size="medium" className="headerWithControl">
|
||||
Teachers
|
||||
<div className="activeToggleContainer">
|
||||
<label htmlFor={toggleId}>Show only active</label>
|
||||
<Radio
|
||||
id={toggleId}
|
||||
toggle
|
||||
checked={showOnlyActiveTeachers}
|
||||
onChange={handleActiveToggleChange}
|
||||
disabled={activeTeacherCount === 0}
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
<List celled>
|
||||
{this.renderListHeader()}
|
||||
{sortedTeachers.map(t => {
|
||||
const isActive = activeTeacherIds.includes(t.id)
|
||||
return <TeacherItem key={t.id} teacher={t} isActive={isActive} handleFilterClick={handleFilterClick} />
|
||||
})}
|
||||
</List>
|
||||
</Fragment>
|
||||
)
|
||||
if (sortReverse) {
|
||||
sortedTeachers.reverse()
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Header size="medium" className="headerWithControl">
|
||||
Teachers
|
||||
<div className="activeToggleContainer">
|
||||
<label htmlFor={toggleId}>Show only active</label>
|
||||
<Radio
|
||||
id={toggleId}
|
||||
toggle
|
||||
checked={showOnlyActiveTeachers}
|
||||
onChange={handleActiveToggleChange}
|
||||
disabled={activeTeacherCount === 0}
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
<List celled>
|
||||
{renderListHeader()}
|
||||
{sortedTeachers.map(t => {
|
||||
const isActive = activeTeacherIds.includes(t.id)
|
||||
return <TeacherItem key={t.id} teacher={t} isActive={isActive} handleFilterClick={handleFilterClick} />
|
||||
})}
|
||||
</List>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
Index.propTypes = {
|
||||
teachers: arrayOf(shape({})).isRequired,
|
||||
activeTeacherIds: arrayOf(number).isRequired,
|
||||
handleFilterClick: func.isRequired,
|
||||
handleActiveToggleChange: func.isRequired,
|
||||
showOnlyActiveTeachers: bool.isRequired
|
||||
}
|
||||
|
||||
export default Index
|
||||
|
||||
+7
-10
@@ -1,13 +1,13 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, arrayOf, string, number } from 'prop-types'
|
||||
import { Table, Segment, Header, Tab } from 'semantic-ui-react'
|
||||
import { Table, Tab } from 'semantic-ui-react'
|
||||
import { maxBy } from 'lodash'
|
||||
import { getActiveLanguage } from 'react-localize-redux'
|
||||
|
||||
import InfoBox from '../InfoBox'
|
||||
import infotooltips from '../../common/InfoToolTips'
|
||||
import { getNewestProgramme, getTextIn } from '../../common'
|
||||
import InfoBox from '../../InfoBox'
|
||||
import infotooltips from '../../../common/InfoToolTips'
|
||||
import { getNewestProgramme, getTextIn } from '../../../common'
|
||||
|
||||
const CreditGainTableRow = ({ statistics, code, language }) => {
|
||||
return (
|
||||
@@ -151,13 +151,10 @@ const CoursePopulationCreditGainTable = ({
|
||||
]
|
||||
|
||||
return (
|
||||
<Segment>
|
||||
<Header>
|
||||
Credit gains
|
||||
<InfoBox content={infotooltips.PopulationStatistics.CreditDistributionCoursePopulation} />
|
||||
</Header>
|
||||
<>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.CreditDistributionCoursePopulation} />
|
||||
<Tab panes={panes} />
|
||||
</Segment>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+4
-4
@@ -4,10 +4,10 @@ import { Progress } from 'semantic-ui-react'
|
||||
import { intersection, orderBy } from 'lodash'
|
||||
import { shape, func, bool, arrayOf, string, number } from 'prop-types'
|
||||
|
||||
import SearchResultTable from '../SearchResultTable'
|
||||
import { gradeFilter } from '../../populationFilters'
|
||||
import { setPopulationFilter, removePopulationFilter } from '../../redux/populationFilters'
|
||||
import { getHighestGradeOfCourseBetweenRange } from '../../common'
|
||||
import SearchResultTable from '../../SearchResultTable'
|
||||
import { gradeFilter } from '../../../populationFilters'
|
||||
import { setPopulationFilter, removePopulationFilter } from '../../../redux/populationFilters'
|
||||
import { getHighestGradeOfCourseBetweenRange } from '../../../common'
|
||||
|
||||
const CoursePopulationCreditDist = ({
|
||||
singleCourseStats,
|
||||
@@ -1,26 +1,27 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react'
|
||||
import React, { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, func, bool, arrayOf, string } from 'prop-types'
|
||||
import { Segment, Header } from 'semantic-ui-react'
|
||||
import { shape, func, bool } from 'prop-types'
|
||||
import { Segment, Header, Accordion, Popup } from 'semantic-ui-react'
|
||||
import qs from 'query-string'
|
||||
import { intersection, difference } from 'lodash'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import scrollToComponent from 'react-scroll-to-component'
|
||||
import { getCoursePopulation } from '../../redux/populations'
|
||||
import { getSingleCourseStats } from '../../redux/singleCourseStats'
|
||||
import { clearPopulationFilters } from '../../redux/populationFilters'
|
||||
import { getFaculties } from '../../redux/faculties'
|
||||
import { getSemesters } from '../../redux/semesters'
|
||||
import PopulationStudents from '../PopulationStudents'
|
||||
|
||||
import CustomPopulationFilters from '../CustomPopulationFilters'
|
||||
import CoursePopulationGradeDist from '../CoursePopulationGradeDist'
|
||||
import CoursePopulationCreditGainTable from '../CoursePopulationCreditGainTable'
|
||||
import CustomPopulationProgrammeDist from '../CustomPopulationProgrammeDist'
|
||||
import CoursePopulationGradeDist from './CoursePopulationGradeDist'
|
||||
import CoursePopulationCreditGainTable from './CoursePopulationCreditGainTable'
|
||||
import CustomPopulationProgrammeDist from '../CustomPopulation/CustomPopulationProgrammeDist'
|
||||
import ProgressBar from '../ProgressBar'
|
||||
import { getStudentToTargetCourseDateMap, getUserIsAdmin } from '../../common'
|
||||
import { useProgress, useTitle } from '../../common/hooks'
|
||||
import infotooltips from '../../common/InfoToolTips'
|
||||
import InfoBox from '../InfoBox'
|
||||
import FilterTray from '../FilterTray'
|
||||
import useFilters from '../FilterTray/useFilters'
|
||||
import { CoursePopulationFilters } from '../FilterTray/FilterSets'
|
||||
|
||||
const CoursePopulation = ({
|
||||
getCoursePopulationDispatch,
|
||||
@@ -29,12 +30,13 @@ const CoursePopulation = ({
|
||||
pending,
|
||||
history,
|
||||
courseData,
|
||||
selectedStudents,
|
||||
getSemestersDispatch,
|
||||
clearPopulationFiltersDispatch,
|
||||
semesters,
|
||||
getFacultiesDispatch
|
||||
}) => {
|
||||
const { setAllStudents, filteredStudents } = useFilters()
|
||||
const selectedStudents = filteredStudents.map(stu => stu.studentNumber)
|
||||
|
||||
const parseQueryFromUrl = () => {
|
||||
const { location } = history
|
||||
const query = qs.parse(location.search)
|
||||
@@ -44,6 +46,14 @@ const CoursePopulation = ({
|
||||
const [headerYears, setYears] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState(null)
|
||||
const [dateTo, setDateTo] = useState(null)
|
||||
const [activeIndex, setIndex] = useState([])
|
||||
const [newestIndex, setNewest] = useState(null)
|
||||
|
||||
const gradeDistRef = useRef()
|
||||
const programmeRef = useRef()
|
||||
const creditGainRef = useRef()
|
||||
const studentRef = useRef()
|
||||
const refs = [gradeDistRef, programmeRef, creditGainRef, studentRef]
|
||||
useTitle('Course population')
|
||||
|
||||
const { onProgress, progress } = useProgress(pending && !studentData.students)
|
||||
@@ -52,10 +62,21 @@ const CoursePopulation = ({
|
||||
[studentData.students, codes]
|
||||
)
|
||||
|
||||
// Pass students to filter context.
|
||||
useEffect(() => {
|
||||
setAllStudents(studentData.students || [])
|
||||
}, [studentData.students])
|
||||
|
||||
useEffect(() => {
|
||||
getSemestersDispatch()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (newestIndex) {
|
||||
scrollToComponent(refs[newestIndex].current, { align: 'bottom' })
|
||||
}
|
||||
}, [activeIndex])
|
||||
|
||||
const getFromToDates = (from, to, separate) => {
|
||||
const targetProp = separate ? 'semestercode' : 'yearcode'
|
||||
const data = separate ? semesters.semesters : semesters.years
|
||||
@@ -72,7 +93,6 @@ const CoursePopulation = ({
|
||||
if (semesters.years && semesters.semesters) {
|
||||
const { coursecodes, from, to, years, separate } = parseQueryFromUrl()
|
||||
const parsedCourseCodes = JSON.parse(coursecodes)
|
||||
clearPopulationFiltersDispatch()
|
||||
getCoursePopulationDispatch({ coursecodes, from, to, onProgress, separate })
|
||||
getSingleCourseStatsDispatch({
|
||||
fromYear: from,
|
||||
@@ -104,63 +124,211 @@ const CoursePopulation = ({
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
<Segment className="contentSegment">
|
||||
<Header className="segmentTitle" size="large" textAlign="center">
|
||||
Population of course {header}
|
||||
</Header>
|
||||
<Header className="segmentTitle" size="medium" textAlign="center">
|
||||
{subHeader}
|
||||
</Header>
|
||||
<CustomPopulationFilters
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
samples={studentData.students}
|
||||
coursecodes={codes}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
coursePopulation
|
||||
/>
|
||||
<Segment>
|
||||
<Header>
|
||||
Grade distribution
|
||||
const handleClick = index => {
|
||||
const indexes = [...activeIndex].sort()
|
||||
if (indexes.includes(index)) {
|
||||
indexes.splice(indexes.findIndex(ind => ind === index), 1)
|
||||
} else {
|
||||
indexes.push(index)
|
||||
}
|
||||
if (activeIndex.length < indexes.length) setNewest(index)
|
||||
else setNewest(null)
|
||||
setIndex(indexes)
|
||||
}
|
||||
|
||||
const panels = [
|
||||
{
|
||||
key: 0,
|
||||
title: {
|
||||
content: (
|
||||
<>
|
||||
{activeIndex.includes(0) ? (
|
||||
<>Grade distribution</>
|
||||
) : (
|
||||
<Popup
|
||||
trigger={
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Grade distribution
|
||||
</span>
|
||||
}
|
||||
position="top center"
|
||||
wide="very"
|
||||
>
|
||||
<Popup.Content>
|
||||
{' '}
|
||||
<ReactMarkdown
|
||||
source={infotooltips.PopulationStatistics.GradeDistributionCoursePopulation}
|
||||
escapeHtml={false}
|
||||
/>
|
||||
</Popup.Content>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
onTitleClick: () => handleClick(0),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={gradeDistRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.GradeDistributionCoursePopulation} />
|
||||
</Header>
|
||||
<CoursePopulationGradeDist
|
||||
selectedStudents={selectedStudents}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
samples={studentData.students}
|
||||
codes={codes}
|
||||
/>
|
||||
</Segment>
|
||||
<Segment>
|
||||
<Header>
|
||||
Programme distribution{' '}
|
||||
<CoursePopulationGradeDist
|
||||
selectedStudents={selectedStudents}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
samples={studentData.students}
|
||||
codes={codes}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
title: {
|
||||
content: (
|
||||
<>
|
||||
{activeIndex.includes(1) ? (
|
||||
<>Programme distribution</>
|
||||
) : (
|
||||
<Popup
|
||||
trigger={
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Programme distribution
|
||||
</span>
|
||||
}
|
||||
position="top center"
|
||||
wide="very"
|
||||
>
|
||||
<Popup.Content>
|
||||
{' '}
|
||||
<ReactMarkdown
|
||||
source={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation}
|
||||
escapeHtml={false}
|
||||
/>
|
||||
</Popup.Content>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
onTitleClick: () => handleClick(1),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={programmeRef}>
|
||||
<InfoBox content={infotooltips.PopulationStatistics.ProgrammeDistributionCoursePopulation} />
|
||||
<CustomPopulationProgrammeDist
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
samples={studentData.students}
|
||||
selectedStudents={selectedStudents}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
title: {
|
||||
content: (
|
||||
<>
|
||||
{activeIndex.includes(2) ? (
|
||||
<>Credit gains</>
|
||||
) : (
|
||||
<Popup
|
||||
trigger={
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Credit gains
|
||||
</span>
|
||||
}
|
||||
position="top center"
|
||||
wide="very"
|
||||
>
|
||||
<Popup.Content>
|
||||
{' '}
|
||||
<ReactMarkdown
|
||||
source={infotooltips.PopulationStatistics.CreditDistributionCoursePopulation}
|
||||
escapeHtml={false}
|
||||
/>
|
||||
</Popup.Content>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
onTitleClick: () => handleClick(2),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={creditGainRef}>
|
||||
<CoursePopulationCreditGainTable
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
selectedStudents={selectedStudents}
|
||||
samples={studentData.students}
|
||||
codes={codes}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
title: {
|
||||
content: (
|
||||
<>
|
||||
{activeIndex.includes(3) ? (
|
||||
<>Students ({selectedStudents.length})</>
|
||||
) : (
|
||||
<Popup
|
||||
trigger={
|
||||
<span style={{ paddingTop: '1vh', paddingBottom: '1vh', color: 'black', fontSize: 'large' }}>
|
||||
Students ({selectedStudents.length})
|
||||
</span>
|
||||
}
|
||||
position="top center"
|
||||
wide="very"
|
||||
>
|
||||
<Popup.Content>
|
||||
{' '}
|
||||
<ReactMarkdown
|
||||
source={infotooltips.PopulationStatistics.Students.AccordionTitle}
|
||||
escapeHtml={false}
|
||||
/>
|
||||
</Popup.Content>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
onTitleClick: () => handleClick(3),
|
||||
content: {
|
||||
content: (
|
||||
<div ref={studentRef}>
|
||||
<PopulationStudents
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
filteredStudents={studentData.students.filter(stu => selectedStudents.includes(stu.studentNumber))}
|
||||
mandatoryToggle={false}
|
||||
coursePopulation
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<FilterTray filterSet={<CoursePopulationFilters />}>
|
||||
<div className="segmentContainer">
|
||||
<Segment className="contentSegment">
|
||||
<Header className="segmentTitle" size="large" textAlign="center">
|
||||
Population of course {header}
|
||||
</Header>
|
||||
<CustomPopulationProgrammeDist
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
samples={studentData.students}
|
||||
selectedStudents={selectedStudents}
|
||||
/>
|
||||
<Header className="segmentTitle" size="medium" textAlign="center">
|
||||
{subHeader}
|
||||
</Header>
|
||||
<Accordion activeIndex={activeIndex} exclusive={false} styled fluid panels={panels} />
|
||||
</Segment>
|
||||
<CoursePopulationCreditGainTable
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
selectedStudents={selectedStudents}
|
||||
samples={studentData.students}
|
||||
codes={codes}
|
||||
from={dateFrom}
|
||||
to={dateTo}
|
||||
/>
|
||||
<PopulationStudents
|
||||
studentToTargetCourseDateMap={studentToTargetCourseDateMap}
|
||||
samples={studentData.students}
|
||||
selectedStudents={selectedStudents}
|
||||
coursePopulation
|
||||
/>
|
||||
</Segment>
|
||||
</div>
|
||||
</div>
|
||||
</FilterTray>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -168,12 +336,10 @@ CoursePopulation.propTypes = {
|
||||
getCoursePopulationDispatch: func.isRequired,
|
||||
getSingleCourseStatsDispatch: func.isRequired,
|
||||
getSemestersDispatch: func.isRequired,
|
||||
clearPopulationFiltersDispatch: func.isRequired,
|
||||
pending: bool.isRequired,
|
||||
studentData: shape({}).isRequired,
|
||||
history: shape({}).isRequired,
|
||||
courseData: shape({}).isRequired,
|
||||
selectedStudents: arrayOf(string).isRequired,
|
||||
semesters: shape({
|
||||
semesters: shape({}),
|
||||
years: shape({})
|
||||
@@ -183,35 +349,17 @@ CoursePopulation.propTypes = {
|
||||
|
||||
const mapStateToProps = ({
|
||||
singleCourseStats,
|
||||
populationFilters,
|
||||
populations,
|
||||
semesters,
|
||||
auth: {
|
||||
token: { roles }
|
||||
}
|
||||
}) => {
|
||||
const samples = populations.data.students ? populations.data.students : []
|
||||
let selectedStudents = samples.length > 0 ? samples.map(s => s.studentNumber) : []
|
||||
const { complemented } = populationFilters
|
||||
|
||||
if (samples.length > 0 && populationFilters.filters.length > 0) {
|
||||
const studentsForFilter = f => {
|
||||
return samples.filter(f.filter).map(s => s.studentNumber)
|
||||
}
|
||||
|
||||
const matchingStudents = populationFilters.filters.map(studentsForFilter)
|
||||
selectedStudents = intersection(...matchingStudents)
|
||||
|
||||
if (complemented) {
|
||||
selectedStudents = difference(samples.map(s => s.studentNumber), selectedStudents)
|
||||
}
|
||||
}
|
||||
return {
|
||||
studentData: populations.data,
|
||||
pending: populations.pending,
|
||||
query: populations.query,
|
||||
courseData: singleCourseStats.stats || {},
|
||||
selectedStudents,
|
||||
semesters: semesters.data,
|
||||
isAdmin: getUserIsAdmin(roles)
|
||||
}
|
||||
@@ -224,7 +372,6 @@ export default withRouter(
|
||||
getCoursePopulationDispatch: getCoursePopulation,
|
||||
getSingleCourseStatsDispatch: getSingleCourseStats,
|
||||
getSemestersDispatch: getSemesters,
|
||||
clearPopulationFiltersDispatch: clearPopulationFilters,
|
||||
getFacultiesDispatch: getFaculties
|
||||
}
|
||||
)(CoursePopulation)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import React from 'react'
|
||||
import { jStat } from 'jStat'
|
||||
import { sortBy } from 'lodash'
|
||||
import { func, arrayOf, object } from 'prop-types'
|
||||
|
||||
import SearchResultTable from '../SearchResultTable'
|
||||
import { getStudentTotalCredits } from '../../common'
|
||||
|
||||
const getStudentSampleInSplitQuarters = students => {
|
||||
const sortedStudents = sortBy(students, student => getStudentTotalCredits(student))
|
||||
const quarterSize = Math.floor(sortedStudents.length / 4)
|
||||
return [
|
||||
sortedStudents.slice(0, quarterSize),
|
||||
sortedStudents.slice(quarterSize, quarterSize * 2),
|
||||
sortedStudents.slice(quarterSize * 2, quarterSize * 3),
|
||||
sortedStudents.slice(quarterSize * 3, sortedStudents.length)
|
||||
]
|
||||
}
|
||||
|
||||
const getValues = students => {
|
||||
const creditsList = students.map(student => getStudentTotalCredits(student))
|
||||
|
||||
const n2z = value => (isNaN(value) ? 0 : value) // eslint-disable-line
|
||||
|
||||
return {
|
||||
n: creditsList.length,
|
||||
min: n2z(jStat.min(creditsList)),
|
||||
max: n2z(jStat.max(creditsList)),
|
||||
average: n2z(jStat.mean(creditsList)).toFixed(2),
|
||||
median: n2z(jStat.median(creditsList)),
|
||||
standardDeviation: n2z(jStat.stdev(creditsList)).toFixed(2)
|
||||
}
|
||||
}
|
||||
|
||||
const getCreditStatsForTable = (students, studentsInQuarters) => [
|
||||
getValues(students),
|
||||
...studentsInQuarters.map(s => getValues(s))
|
||||
]
|
||||
|
||||
const CourseQuarters = props => {
|
||||
const { translate, sample } = props
|
||||
|
||||
const quarters = getStudentSampleInSplitQuarters(sample)
|
||||
const stats = getCreditStatsForTable(sample, quarters)
|
||||
|
||||
const headers = [
|
||||
'',
|
||||
`all (n=${stats[0].n})`,
|
||||
`q1, bottom (n=${stats[1].n})`,
|
||||
`q2 (n=${stats[2].n})`,
|
||||
`q3 (n=${stats[3].n})`,
|
||||
`q4, top (n=${stats[3].n})`
|
||||
]
|
||||
|
||||
const rows = [
|
||||
['n', ...stats.map(stat => stat.n)],
|
||||
['min', ...stats.map(stat => stat.min)],
|
||||
['max', ...stats.map(stat => stat.max)],
|
||||
['average', ...stats.map(stat => stat.average)],
|
||||
['median', ...stats.map(stat => stat.median)],
|
||||
['standardDeviation', ...stats.map(stat => stat.standardDeviation)]
|
||||
]
|
||||
|
||||
return <SearchResultTable headers={headers} rows={rows} noResultText={translate('common.noResults')} definition />
|
||||
}
|
||||
|
||||
CourseQuarters.propTypes = {
|
||||
translate: func.isRequired,
|
||||
sample: arrayOf(object).isRequired
|
||||
}
|
||||
|
||||
export default CourseQuarters
|
||||
+33
-12
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Label } from 'semantic-ui-react'
|
||||
import { Label, Popup } from 'semantic-ui-react'
|
||||
import { string, oneOfType, number } from 'prop-types'
|
||||
|
||||
const styles = {
|
||||
@@ -16,22 +16,43 @@ const styles = {
|
||||
}
|
||||
}
|
||||
|
||||
const DropdownItem = ({ name, code, size }) => (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.text}>
|
||||
<Label content={size} icon="user" size="tiny" />
|
||||
<span style={{ paddingLeft: '1rem' }}>{name}</span>
|
||||
const DropdownItem = ({ name, code, size, description }) =>
|
||||
['ALL', 'OTHER', 'EXCLUDED'].includes(code) ? (
|
||||
<Popup
|
||||
size="small"
|
||||
content={description}
|
||||
trigger={
|
||||
<div style={styles.container}>
|
||||
<div style={styles.text}>
|
||||
<Label content={size} icon="user" size="tiny" />
|
||||
<span style={{ paddingLeft: '1rem' }}>{name}</span>
|
||||
</div>
|
||||
<div style={styles.students}>
|
||||
<Label content={code} size="tiny" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.text}>
|
||||
<Label content={size} icon="user" size="tiny" />
|
||||
<span style={{ paddingLeft: '1rem' }}>{name}</span>
|
||||
</div>
|
||||
<div style={styles.students}>
|
||||
<Label content={code} size="tiny" />
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.students}>
|
||||
<Label content={code} size="tiny" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
DropdownItem.defaultProps = {
|
||||
description: ''
|
||||
}
|
||||
DropdownItem.propTypes = {
|
||||
name: string.isRequired,
|
||||
code: string.isRequired,
|
||||
size: oneOfType([number, string]).isRequired
|
||||
size: oneOfType([number, string]).isRequired,
|
||||
description: string
|
||||
}
|
||||
|
||||
export default DropdownItem
|
||||
|
||||
+2
-2
@@ -7,9 +7,9 @@ import ClearableItem from './ClearableItem'
|
||||
|
||||
const ProgrammeDropdown = ({ options, label, name, onChange, onClear, value, ...props }) => (
|
||||
<Form.Dropdown
|
||||
options={orderBy(options, ['size'], ['desc']).map(({ key, size, value: v, text }) => ({
|
||||
options={orderBy(options, ['size'], ['desc']).map(({ key, size, value: v, text, description }) => ({
|
||||
key,
|
||||
content: <DropdownItem name={text} code={key} size={size} />,
|
||||
content: <DropdownItem name={text} code={key} size={size} description={description} />,
|
||||
text: !onClear ? text : <ClearableItem name={text} onClear={onClear} />,
|
||||
value: v
|
||||
}))}
|
||||
|
||||
+6
-5
@@ -15,7 +15,7 @@ const getSortableColumn = (key, title, getRowVal, getRowContent) => ({
|
||||
getRowContent
|
||||
})
|
||||
|
||||
const getTableData = (stats, isGradeSeries) =>
|
||||
const getTableData = (stats, isGradeSeries, isRelative) =>
|
||||
stats.map(stat => {
|
||||
const {
|
||||
name,
|
||||
@@ -24,7 +24,7 @@ const getTableData = (stats, isGradeSeries) =>
|
||||
coursecode
|
||||
} = stat
|
||||
|
||||
const spread = isGradeSeries ? getGradeSpread([grades]) : getThesisGradeSpread([grades])
|
||||
const spread = isGradeSeries ? getGradeSpread([grades], isRelative) : getThesisGradeSpread([grades], isRelative)
|
||||
|
||||
const attempts = Object.values(grades).reduce((cur, acc) => acc + cur, 0)
|
||||
return {
|
||||
@@ -49,7 +49,7 @@ const getGradeColumns = isGradeSeries =>
|
||||
]
|
||||
: THESIS_GRADE_KEYS.map(k => getSortableColumn(k, k, s => s[k]))
|
||||
|
||||
const GradesTable = ({ stats, name, alternatives, separate }) => {
|
||||
const GradesTable = ({ stats, name, alternatives, separate, isRelative }) => {
|
||||
const {
|
||||
cumulative: { grades }
|
||||
} = stats[0]
|
||||
@@ -87,7 +87,7 @@ const GradesTable = ({ stats, name, alternatives, separate }) => {
|
||||
...getGradeColumns(isGradeSeries)
|
||||
]
|
||||
|
||||
const data = getTableData(stats, isGradeSeries)
|
||||
const data = getTableData(stats, isGradeSeries, isRelative)
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -107,7 +107,8 @@ GradesTable.propTypes = {
|
||||
stats: arrayOf(shape({})).isRequired,
|
||||
name: oneOfType([number, string]).isRequired,
|
||||
alternatives: arrayOf(string).isRequired,
|
||||
separate: bool
|
||||
separate: bool,
|
||||
isRelative: bool.isRequired
|
||||
}
|
||||
|
||||
GradesTable.defaultProps = {
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ const getGradeStudSeriesFromStats = stats => {
|
||||
|
||||
const Distribution = ({ primary, comparison, viewMode, isRelative }) => {
|
||||
const isCumulativeMode = viewMode === viewModeNames.CUMULATIVE
|
||||
const primaryStats = primary.stats.filter(stat => stat.name !== 'Total')
|
||||
const primaryStats = primary.stats.filter(stat => stat.name !== 'Total' || isRelative)
|
||||
const statYears = primaryStats.map(year => year.name)
|
||||
const comparisonStats = comparison ? comparison.stats : []
|
||||
|
||||
|
||||
+12
-3
@@ -7,7 +7,7 @@ import CumulativeTable from './Tables/cumulative'
|
||||
import StudentTable from './Tables/student'
|
||||
import GradesTable from './Tables/grades'
|
||||
|
||||
const Tables = ({ primary, comparison, viewMode, alternatives, separate }) => {
|
||||
const Tables = ({ primary, comparison, viewMode, alternatives, separate, isRelative }) => {
|
||||
const getViewMode = (name, stats) => {
|
||||
switch (viewMode) {
|
||||
case viewModeNames.CUMULATIVE:
|
||||
@@ -15,7 +15,15 @@ const Tables = ({ primary, comparison, viewMode, alternatives, separate }) => {
|
||||
case viewModeNames.STUDENT:
|
||||
return <StudentTable separate={separate} name={name} stats={stats} alternatives={alternatives} />
|
||||
case viewModeNames.GRADES:
|
||||
return <GradesTable separate={separate} name={name} stats={stats} alternatives={alternatives} />
|
||||
return (
|
||||
<GradesTable
|
||||
separate={separate}
|
||||
name={name}
|
||||
stats={stats}
|
||||
alternatives={alternatives}
|
||||
isRelative={isRelative}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -41,7 +49,8 @@ Tables.propTypes = {
|
||||
comparison: dataSeriesType,
|
||||
viewMode: viewModeType.isRequired,
|
||||
alternatives: arrayOf(string).isRequired,
|
||||
separate: bool
|
||||
separate: bool,
|
||||
isRelative: bool.isRequired
|
||||
}
|
||||
|
||||
Tables.defaultProps = {
|
||||
|
||||
+26
-5
@@ -30,7 +30,7 @@ export const isThesisSeries = series => series && series.some(s => isThesisGrade
|
||||
|
||||
export const absoluteToRelative = all => (p, i) => p / all[i] || 0
|
||||
|
||||
export const getThesisGradeSpread = series => {
|
||||
export const getThesisGradeSpread = (series, isRelative) => {
|
||||
const thesisGradeAccumulator = {
|
||||
L: [],
|
||||
ECLA: [],
|
||||
@@ -41,7 +41,7 @@ export const getThesisGradeSpread = series => {
|
||||
A: [],
|
||||
I: []
|
||||
}
|
||||
return series.reduce(
|
||||
const newSeries = series.reduce(
|
||||
(acc, cur, i) => {
|
||||
const currentEntries = Object.entries(cur)
|
||||
|
||||
@@ -59,9 +59,20 @@ export const getThesisGradeSpread = series => {
|
||||
},
|
||||
{ ...thesisGradeAccumulator }
|
||||
)
|
||||
const total = Object.keys(newSeries).reduce((acc, curr) => {
|
||||
const numOfGrades = newSeries[curr][0]
|
||||
return acc + numOfGrades
|
||||
}, 0)
|
||||
|
||||
const relative = Object.keys(newSeries).reduce((acc, curr) => {
|
||||
acc[curr] = `${Math.round((newSeries[curr][0] / total) * 10000) / 100}%`
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return isRelative ? relative : newSeries
|
||||
}
|
||||
|
||||
export const getGradeSpread = series => {
|
||||
export const getGradeSpread = (series, isRelative) => {
|
||||
const failedKeys = ['Eisa', 'Hyl.', '0', 'Luop']
|
||||
|
||||
const baseAccumalator = {
|
||||
@@ -73,8 +84,7 @@ export const getGradeSpread = series => {
|
||||
5: [],
|
||||
'Hyv.': []
|
||||
}
|
||||
|
||||
return series.reduce(
|
||||
const newSeries = series.reduce(
|
||||
(acc, cur, i) => {
|
||||
const currentEntries = Object.entries(cur)
|
||||
let failed = 0
|
||||
@@ -96,4 +106,15 @@ export const getGradeSpread = series => {
|
||||
},
|
||||
{ ...baseAccumalator }
|
||||
)
|
||||
const total = Object.keys(newSeries).reduce((acc, curr) => {
|
||||
const numOfGrades = newSeries[curr][0]
|
||||
return acc + numOfGrades
|
||||
}, 0)
|
||||
|
||||
const relative = Object.keys(newSeries).reduce((acc, curr) => {
|
||||
acc[curr] = `${Math.round((newSeries[curr][0] / total) * 10000) / 100}%`
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return isRelative ? relative : newSeries
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import Tables from './Panes/tables'
|
||||
import { useTabs } from '../../../common/hooks'
|
||||
|
||||
import './resultTabs.css'
|
||||
import TSA from '../../../common/tsa'
|
||||
|
||||
const ANALYTICS_CATEGORY = 'Course Statistics'
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
|
||||
|
||||
const paneViewIndex = {
|
||||
TABLE: 0,
|
||||
@@ -23,23 +27,32 @@ const ResultTabs = props => {
|
||||
|
||||
const handleTabChange = (...params) => {
|
||||
const resetViewMode = params[1].activeIndex === paneViewIndex.TABLE && viewMode === viewModeNames.GRADES
|
||||
|
||||
const { activeIndex } = params[1]
|
||||
const currentTab = params[1].panes[activeIndex]
|
||||
sendAnalytics(`Current tab '${currentTab.menuItem.content}'`, 'Course statistics')
|
||||
setTab(...params)
|
||||
setViewMode(resetViewMode ? viewModeNames.CUMULATIVE : viewMode)
|
||||
}
|
||||
|
||||
const handleModeChange = newViewMode => {
|
||||
sendAnalytics(`Current view mode '${newViewMode}'`, 'Course statistics')
|
||||
setViewMode(newViewMode)
|
||||
}
|
||||
|
||||
const renderViewModeSelector = () => {
|
||||
const isTogglePane = tab !== 0
|
||||
|
||||
const getButtonMenu = () => (
|
||||
<Menu secondary>
|
||||
{Object.values(viewModeNames).map(name => (
|
||||
<Menu.Item key={name} name={name} active={viewMode === name} onClick={() => handleModeChange(name)} />
|
||||
))}
|
||||
{viewMode === 'Grades' && (
|
||||
<Menu.Item
|
||||
name={isRelative ? 'Set absolute' : 'Set relative'}
|
||||
active={isRelative}
|
||||
onClick={() => setIsRelative(!isRelative)}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
)
|
||||
|
||||
@@ -58,7 +71,7 @@ const ResultTabs = props => {
|
||||
{viewModeNames.STUDENT}
|
||||
</label>
|
||||
</div>
|
||||
{props.comparison && (
|
||||
{(tab === 2 || props.comparison) && (
|
||||
<div className="toggleContainer">
|
||||
<label className="toggleLabel">Absolute</label>
|
||||
<Radio toggle checked={isRelative} onChange={() => setIsRelative(!isRelative)} />
|
||||
@@ -77,7 +90,15 @@ const ResultTabs = props => {
|
||||
const paneMenuItems = [
|
||||
{
|
||||
menuItem: { key: 'Table', icon: 'table', content: 'Table' },
|
||||
renderFn: () => <Tables separate={separate} comparison={comparison} primary={primary} viewMode={viewMode} />
|
||||
renderFn: () => (
|
||||
<Tables
|
||||
separate={separate}
|
||||
comparison={comparison}
|
||||
primary={primary}
|
||||
viewMode={viewMode}
|
||||
isRelative={isRelative}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
menuItem: { key: 'pass', icon: 'balance', content: 'Pass rate chart' },
|
||||
@@ -86,19 +107,14 @@ const ResultTabs = props => {
|
||||
comparison={comparison}
|
||||
primary={primary}
|
||||
viewMode={viewMode}
|
||||
isRelative={isRelative && comparison}
|
||||
isRelative={isRelative && !!comparison}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
menuItem: { key: 'grade', icon: 'chart bar', content: 'Grade distribution chart' },
|
||||
renderFn: () => (
|
||||
<Distribution
|
||||
comparison={comparison}
|
||||
primary={primary}
|
||||
viewMode={viewMode}
|
||||
isRelative={isRelative && comparison}
|
||||
/>
|
||||
<Distribution comparison={comparison} primary={primary} viewMode={viewMode} isRelative={isRelative} />
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
+165
-202
@@ -1,4 +1,4 @@
|
||||
import React, { Component } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Segment, Header, Form, Grid, Button, Popup } from 'semantic-ui-react'
|
||||
import { shape, string, arrayOf, objectOf, oneOfType, number, func } from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
@@ -17,6 +17,10 @@ import selectors, { ALL } from '../../../selectors/courseStats'
|
||||
import YearFilter from '../SearchForm/YearFilter'
|
||||
import { getTextIn } from '../../../common'
|
||||
import { getSemesters } from '../../../redux/semesters'
|
||||
import TSA from '../../../common/tsa'
|
||||
|
||||
const ANALYTICS_CATEGORY = 'Course Statistics'
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
|
||||
|
||||
const countFilteredStudents = (stat, filter) =>
|
||||
Object.entries(stat).reduce((acc, entry) => {
|
||||
@@ -27,90 +31,77 @@ const countFilteredStudents = (stat, filter) =>
|
||||
}
|
||||
}, {})
|
||||
|
||||
class SingleCourseStats extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
const SingleCourseStats = ({
|
||||
stats,
|
||||
setSelectedCourse,
|
||||
activeLanguage,
|
||||
history,
|
||||
location,
|
||||
stats: { coursecode },
|
||||
getSemesters,
|
||||
years,
|
||||
semesters,
|
||||
programmes,
|
||||
maxYearsToCreatePopulationFrom,
|
||||
getMaxYearsToCreatePopulationFrom
|
||||
}) => {
|
||||
const [primary, setPrimary] = useState([ALL.value])
|
||||
const [comparison, setComparison] = useState([])
|
||||
const [fromYear, setFromYear] = useState(0)
|
||||
const [toYear, setToYear] = useState(0)
|
||||
const [separate, setSeparate] = useState(null)
|
||||
|
||||
const yearcodes = props.stats.statistics.map(s => s.yearcode)
|
||||
const fromYear = min(yearcodes)
|
||||
const toYear = max(yearcodes)
|
||||
|
||||
this.state = {
|
||||
primary: [ALL.value],
|
||||
comparison: [],
|
||||
fromYear,
|
||||
toYear,
|
||||
separate: null
|
||||
const parseQueryFromUrl = () => {
|
||||
const { separate } = qs.parse(location.search)
|
||||
return {
|
||||
separate: JSON.parse(separate)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillMount = () => {
|
||||
const {
|
||||
setSelectedCourse,
|
||||
location,
|
||||
stats: { coursecode },
|
||||
getSemesters,
|
||||
years,
|
||||
semesters
|
||||
} = this.props
|
||||
useEffect(() => {
|
||||
if (years.length === 0 || semesters.length === 0) getSemesters()
|
||||
if (location.search) {
|
||||
const params = this.parseQueryFromUrl()
|
||||
this.setState(params)
|
||||
const { separate } = parseQueryFromUrl()
|
||||
setSeparate(separate)
|
||||
}
|
||||
setSelectedCourse(coursecode)
|
||||
}
|
||||
|
||||
componentDidUpdate = prevProps => {
|
||||
const { primary } = this.state
|
||||
if (this.props.programmes.length !== prevProps.programmes.length) {
|
||||
if (primary.every(c => !this.props.programmes.map(p => p.key).includes(c))) {
|
||||
// eslint-disable-next-line react/no-did-update-set-state
|
||||
this.setState({
|
||||
primary: [ALL.value]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
const { clearSelectedCourse } = this.props
|
||||
clearSelectedCourse()
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
this.props.getMaxYearsToCreatePopulationFrom({
|
||||
courseCodes: JSON.stringify(this.props.stats.alternatives)
|
||||
getMaxYearsToCreatePopulationFrom({
|
||||
courseCodes: JSON.stringify(stats.alternatives)
|
||||
})
|
||||
}
|
||||
setSelectedCourse(coursecode)
|
||||
|
||||
getProgrammeName = progcode => {
|
||||
const yearcodes = stats.statistics.map(s => s.yearcode)
|
||||
const initFromYear = min(yearcodes)
|
||||
const initToYear = max(yearcodes)
|
||||
setFromYear(initFromYear)
|
||||
setToYear(initToYear)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (primary.every(c => !programmes.map(p => p.key).includes(c))) {
|
||||
setPrimary([ALL.value])
|
||||
}
|
||||
}, [programmes])
|
||||
|
||||
const getProgrammeName = progcode => {
|
||||
if (progcode === ALL.value) {
|
||||
return 'All'
|
||||
}
|
||||
if (progcode === 'EXCLUDED') {
|
||||
return 'Excluded'
|
||||
}
|
||||
const { activeLanguage } = this.props
|
||||
const { name } = this.props.stats.programmes[progcode]
|
||||
const { name } = stats.programmes[progcode]
|
||||
return getTextIn(name, activeLanguage)
|
||||
}
|
||||
|
||||
setExcludedToComparison = () =>
|
||||
this.setState({
|
||||
// eslint-disable-next-line react/no-access-state-in-setstate
|
||||
comparison: this.state.primary.includes(ALL.value) ? [] : ['EXCLUDED']
|
||||
})
|
||||
const setExcludedToComparison = () => setComparison(primary.includes(ALL.value) ? [] : ['EXCLUDED'])
|
||||
|
||||
getExcluded = () =>
|
||||
this.state.primary.includes(ALL.value)
|
||||
? []
|
||||
: difference(this.props.programmes.map(p => p.value).filter(v => v !== ALL.value), this.state.primary)
|
||||
const getExcluded = () =>
|
||||
primary.includes(ALL.value) ? [] : difference(programmes.map(p => p.value).filter(v => v !== ALL.value), primary)
|
||||
|
||||
belongsToAtLeastOneProgramme = codes => {
|
||||
const belongsToAtLeastOneProgramme = codes => {
|
||||
if (codes.includes(ALL.value)) return () => true
|
||||
|
||||
const { programmes } = this.props.stats
|
||||
const { programmes } = stats
|
||||
const studentNumbers = []
|
||||
codes.forEach(code => {
|
||||
if (programmes[code]) {
|
||||
@@ -122,13 +113,12 @@ class SingleCourseStats extends Component {
|
||||
return studentnumber => numberset.has(studentnumber)
|
||||
}
|
||||
|
||||
validProgCode = code => {
|
||||
const { programmes } = this.props.stats
|
||||
const validProgCode = code => {
|
||||
const { programmes } = stats
|
||||
return programmes[code] || (code === ALL.value || code === 'EXCLUDED')
|
||||
}
|
||||
|
||||
filteredYearsAndSemesters = () => {
|
||||
const { years, semesters, stats } = this.props
|
||||
const filteredYearsAndSemesters = () => {
|
||||
const yearcodes = stats.statistics.map(s => s.yearcode)
|
||||
const from = min(yearcodes)
|
||||
const to = max(yearcodes)
|
||||
@@ -145,9 +135,7 @@ class SingleCourseStats extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
isStatInYearRange = ({ name }) => {
|
||||
const { separate, fromYear, toYear } = this.state
|
||||
const { years, semesters } = this.props
|
||||
const isStatInYearRange = ({ name }) => {
|
||||
const timeFilter = ({ value }) => value >= fromYear && value <= toYear
|
||||
const filteredSemesters = semesters.filter(timeFilter)
|
||||
const filteredYears = years.filter(timeFilter)
|
||||
@@ -156,11 +144,11 @@ class SingleCourseStats extends Component {
|
||||
: filteredYears.find(year => year.text === name)
|
||||
}
|
||||
|
||||
statsForProgrammes = (progCodes, name) => {
|
||||
const { statistics } = this.props.stats
|
||||
const filter = this.belongsToAtLeastOneProgramme(progCodes)
|
||||
const statsForProgrammes = (progCodes, name) => {
|
||||
const { statistics } = stats
|
||||
const filter = belongsToAtLeastOneProgramme(progCodes)
|
||||
const progStats = statistics
|
||||
.filter(this.isStatInYearRange)
|
||||
.filter(isStatInYearRange)
|
||||
.map(({ code, name, students: allstudents, attempts, coursecode }) => {
|
||||
const cumulative = {
|
||||
grades: countFilteredStudents(attempts.grades, filter),
|
||||
@@ -233,56 +221,53 @@ class SingleCourseStats extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleSelect = (e, { name, value }) => {
|
||||
const handleSelect = (e, { name, value }) => {
|
||||
let selected = [...value].filter(v => v !== ALL.value)
|
||||
|
||||
if (name === 'primary') {
|
||||
this.setState({
|
||||
// eslint-disable-next-line react/no-access-state-in-setstate
|
||||
comparison: this.state.comparison.filter(p => p !== 'EXCLUDED')
|
||||
})
|
||||
setComparison(comparison.filter(p => p !== 'EXCLUDED'))
|
||||
}
|
||||
|
||||
if (
|
||||
(!this.state[name].includes(ALL.value) && value.includes(ALL.value)) ||
|
||||
(name === 'primary' && value.length === 0)
|
||||
) {
|
||||
if ((!primary.includes(ALL.value) && value.includes(ALL.value)) || (name === 'primary' && value.length === 0)) {
|
||||
selected = [ALL.value]
|
||||
}
|
||||
if (name === 'primary') {
|
||||
if (primary.length > selected.length) sendAnalytics('Primary group removed', 'Course stats')
|
||||
else sendAnalytics('Primary group set', 'Course stats')
|
||||
setPrimary(selected)
|
||||
}
|
||||
|
||||
this.setState({ [name]: selected })
|
||||
if (name === 'comparison') {
|
||||
if (comparison.length > selected.length) sendAnalytics('Comparison group removed', 'Course stats')
|
||||
else sendAnalytics('Comparison group set', 'Course stats')
|
||||
setComparison(selected)
|
||||
}
|
||||
}
|
||||
|
||||
handleChange = (e, { name, value }) => {
|
||||
this.setState({ [name]: value })
|
||||
const handleYearChange = (e, { name, value }) => {
|
||||
if (name === 'fromYear' && value <= toYear) setFromYear(value)
|
||||
else if (name === 'toYear' && value >= fromYear) setToYear(value)
|
||||
sendAnalytics('Changed time frame', 'Course stats')
|
||||
}
|
||||
|
||||
handleYearChange = (e, { name, value }) => {
|
||||
const { fromYear, toYear } = this.state
|
||||
if (name === 'fromYear' && value <= toYear) this.setState({ fromYear: value })
|
||||
else if (name === 'toYear' && value >= fromYear) this.setState({ toYear: value })
|
||||
}
|
||||
const filteredProgrammeStatistics = () => {
|
||||
const filter = p => validProgCode(p)
|
||||
|
||||
filteredProgrammeStatistics = () => {
|
||||
const { primary, comparison } = this.state
|
||||
const filter = p => this.validProgCode(p)
|
||||
|
||||
const excludedProgrammes = this.getExcluded()
|
||||
const excludedProgrammes = getExcluded()
|
||||
|
||||
const primaryProgrammes = primary
|
||||
const comparisonProgrammes = comparison.filter(filter)
|
||||
if (comparison.includes('EXCLUDED')) comparisonProgrammes.push(...excludedProgrammes)
|
||||
|
||||
const pstats = primaryProgrammes.length
|
||||
? this.statsForProgrammes(
|
||||
? statsForProgrammes(
|
||||
primaryProgrammes,
|
||||
primaryProgrammes.length === 1 ? this.getProgrammeName(primaryProgrammes[0]) : 'Primary'
|
||||
primaryProgrammes.length === 1 ? getProgrammeName(primaryProgrammes[0]) : 'Primary'
|
||||
)
|
||||
: undefined
|
||||
const cstats = comparisonProgrammes.length
|
||||
? this.statsForProgrammes(
|
||||
? statsForProgrammes(
|
||||
comparisonProgrammes,
|
||||
comparisonProgrammes.length === 1 ? this.getProgrammeName(comparisonProgrammes[0]) : 'Comparison'
|
||||
comparisonProgrammes.length === 1 ? getProgrammeName(comparisonProgrammes[0]) : 'Comparison'
|
||||
)
|
||||
: undefined
|
||||
return {
|
||||
@@ -291,20 +276,11 @@ class SingleCourseStats extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
parseQueryFromUrl = () => {
|
||||
const { location } = this.props
|
||||
const { separate } = qs.parse(location.search)
|
||||
return {
|
||||
separate: JSON.parse(separate)
|
||||
}
|
||||
}
|
||||
const clearComparison = () => setComparison([])
|
||||
|
||||
clearComparison = () => this.setState({ comparison: [] })
|
||||
|
||||
comparisonProgrammes = programmes => {
|
||||
const { primary, comparison } = this.state
|
||||
const comparisonProgrammes = programmes => {
|
||||
const result = programmes.filter(({ key }) => key !== 'EXCLUDED')
|
||||
const excludedProgrammes = this.getExcluded()
|
||||
const excludedProgrammes = getExcluded()
|
||||
|
||||
if (!primary.includes(ALL.value)) {
|
||||
const excludedStudents = result
|
||||
@@ -315,6 +291,7 @@ class SingleCourseStats extends Component {
|
||||
key: 'EXCLUDED',
|
||||
size: uniqueExcludedStudents.length,
|
||||
students: uniqueExcludedStudents,
|
||||
description: 'All students that are not in primary group selection',
|
||||
text: 'Excluded',
|
||||
value: 'EXCLUDED'
|
||||
})
|
||||
@@ -322,106 +299,93 @@ class SingleCourseStats extends Component {
|
||||
return result.filter(({ key }) => !comparison.includes('EXCLUDED') || !excludedProgrammes.includes(key))
|
||||
}
|
||||
|
||||
showPopulation = () => {
|
||||
const { fromYear: from, toYear: to } = this.state
|
||||
const {
|
||||
stats: { alternatives },
|
||||
history,
|
||||
years: yearCodes
|
||||
} = this.props
|
||||
const years = `${yearCodes.find(s => s.value === from).text.split('-')[0]}-${
|
||||
yearCodes.find(s => s.value === to).text.split('-')[1]
|
||||
const showPopulation = () => {
|
||||
const from = fromYear
|
||||
const to = toYear
|
||||
const years2 = `${years.find(s => s.value === from).text.split('-')[0]}-${
|
||||
years.find(s => s.value === to).text.split('-')[1]
|
||||
}`
|
||||
const queryObject = { from, to, coursecodes: JSON.stringify(alternatives), years, separate: false }
|
||||
const queryObject = { from, to, coursecodes: JSON.stringify(stats), years2, separate: false }
|
||||
const searchString = qs.stringify(queryObject)
|
||||
history.push(`/coursepopulation?${searchString}`)
|
||||
}
|
||||
|
||||
renderShowPopulation(disabled = false) {
|
||||
return <Button disabled={disabled} onClick={this.showPopulation} content="Show population" />
|
||||
const renderShowPopulation = (disabled = false) => {
|
||||
return <Button disabled={disabled} onClick={showPopulation} content="Show population" />
|
||||
}
|
||||
|
||||
render() {
|
||||
const { programmes, maxYearsToCreatePopulationFrom, stats } = this.props
|
||||
const { primary, comparison, fromYear, toYear } = this.state
|
||||
const statistics = this.filteredProgrammeStatistics()
|
||||
const { filteredYears } = this.filteredYearsAndSemesters()
|
||||
const statistics = filteredProgrammeStatistics()
|
||||
const { filteredYears } = filteredYearsAndSemesters()
|
||||
|
||||
const timeFilter = (_, value) => value >= fromYear && value <= toYear
|
||||
const filteredProgrammes = programmes
|
||||
.map(e => {
|
||||
const students = new Set(flatten(Object.values(pickBy(e.students, timeFilter))))
|
||||
return { ...e, students: [...students], size: students.size }
|
||||
})
|
||||
.filter(e => e.size > 0)
|
||||
if (stats.statistics.length < 1) return <Segment>No data for selected course</Segment>
|
||||
const timeFilter = (_, value) => value >= fromYear && value <= toYear
|
||||
const filteredProgrammes = programmes
|
||||
.map(e => {
|
||||
const students = new Set(flatten(Object.values(pickBy(e.students, timeFilter))))
|
||||
return { ...e, students: [...students], size: students.size }
|
||||
})
|
||||
.filter(e => e.size > 0)
|
||||
if (stats.statistics.length < 1) return <Segment>No data for selected course</Segment>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Segment>
|
||||
<Form>
|
||||
<Header content="Statistics by time range" as="h4" />
|
||||
<YearFilter
|
||||
years={filteredYears}
|
||||
fromYear={fromYear}
|
||||
toYear={toYear}
|
||||
handleChange={this.handleYearChange}
|
||||
return (
|
||||
<div>
|
||||
<Segment>
|
||||
<Form>
|
||||
<Header content="Statistics by time range" as="h4" />
|
||||
<YearFilter years={filteredYears} fromYear={fromYear} toYear={toYear} handleChange={handleYearChange} />
|
||||
{maxYearsToCreatePopulationFrom < toYear - fromYear + 1 ? (
|
||||
<Popup
|
||||
content={`Max years to create a population from for this course is ${Math.max(
|
||||
0,
|
||||
maxYearsToCreatePopulationFrom
|
||||
)}`}
|
||||
trigger={<span>{renderShowPopulation(true)}</span>}
|
||||
/>
|
||||
{maxYearsToCreatePopulationFrom < toYear - fromYear + 1 ? (
|
||||
<Popup
|
||||
content={`Max years to create a population from for this course is ${Math.max(
|
||||
0,
|
||||
maxYearsToCreatePopulationFrom
|
||||
)}`}
|
||||
trigger={<span>{this.renderShowPopulation(true)}</span>}
|
||||
) : (
|
||||
renderShowPopulation()
|
||||
)}
|
||||
</Form>
|
||||
</Segment>
|
||||
<Segment>
|
||||
<Form>
|
||||
<Header content="Filter statistics by study programmes" as="h4" />
|
||||
<Grid>
|
||||
<Grid.Column width={8}>
|
||||
<ProgrammeDropdown
|
||||
name="primary"
|
||||
options={filteredProgrammes}
|
||||
label="Primary group"
|
||||
placeholder="Select study programmes"
|
||||
value={primary}
|
||||
onChange={handleSelect}
|
||||
/>
|
||||
) : (
|
||||
this.renderShowPopulation()
|
||||
)}
|
||||
</Form>
|
||||
</Segment>
|
||||
<Segment>
|
||||
<Form>
|
||||
<Header content="Filter statistics by study programmes" as="h4" />
|
||||
<Grid>
|
||||
<Grid.Column width={8}>
|
||||
<ProgrammeDropdown
|
||||
name="primary"
|
||||
options={filteredProgrammes}
|
||||
label="Primary group"
|
||||
placeholder="Select study programmes"
|
||||
value={primary}
|
||||
onChange={this.handleSelect}
|
||||
</Grid.Column>
|
||||
<Grid.Column width={8}>
|
||||
<ProgrammeDropdown
|
||||
name="comparison"
|
||||
options={comparisonProgrammes(filteredProgrammes)}
|
||||
label="Comparison group"
|
||||
placeholder="Optional"
|
||||
value={comparison}
|
||||
onChange={handleSelect}
|
||||
/>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={8} />
|
||||
<Grid.Column width={8}>
|
||||
<Form.Group>
|
||||
<Form.Button
|
||||
content="Select excluded study programmes"
|
||||
onClick={setExcludedToComparison}
|
||||
disabled={primary.length === 1 && primary[0] === ALL.value}
|
||||
/>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={8}>
|
||||
<ProgrammeDropdown
|
||||
name="comparison"
|
||||
options={this.comparisonProgrammes(filteredProgrammes)}
|
||||
label="Comparison group"
|
||||
placeholder="Optional"
|
||||
value={comparison}
|
||||
onChange={this.handleSelect}
|
||||
/>
|
||||
</Grid.Column>
|
||||
<Grid.Column width={8} />
|
||||
<Grid.Column width={8}>
|
||||
<Form.Group>
|
||||
<Form.Button
|
||||
content="Select excluded study programmes"
|
||||
onClick={this.setExcludedToComparison}
|
||||
disabled={primary.length === 1 && primary[0] === ALL.value}
|
||||
/>
|
||||
<Form.Button content="Clear" onClick={this.clearComparison} />
|
||||
</Form.Group>
|
||||
</Grid.Column>
|
||||
</Grid>
|
||||
</Form>
|
||||
</Segment>
|
||||
<ResultTabs separate={this.state.separate} primary={statistics.primary} comparison={statistics.comparison} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<Form.Button content="Clear" onClick={clearComparison} />
|
||||
</Form.Group>
|
||||
</Grid.Column>
|
||||
</Grid>
|
||||
</Form>
|
||||
</Segment>
|
||||
<ResultTabs separate={separate} primary={statistics.primary} comparison={statistics.comparison} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
SingleCourseStats.propTypes = {
|
||||
@@ -454,7 +418,6 @@ SingleCourseStats.propTypes = {
|
||||
location: shape({}).isRequired,
|
||||
activeLanguage: string.isRequired,
|
||||
setSelectedCourse: func.isRequired,
|
||||
clearSelectedCourse: func.isRequired,
|
||||
getSemesters: func.isRequired,
|
||||
history: shape({
|
||||
push: func
|
||||
|
||||
+32
-47
@@ -1,57 +1,42 @@
|
||||
import React, { Component } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Segment, Label, Header, Divider, Form } from 'semantic-ui-react'
|
||||
import { shape, arrayOf, oneOfType, number, string } from 'prop-types'
|
||||
import SingleCourseStats from '../SingleCourseStats'
|
||||
import selectors from '../../../selectors/courseStats'
|
||||
|
||||
class SingleCourseTab extends Component {
|
||||
state = {
|
||||
selected: this.props.selected
|
||||
}
|
||||
const SingleCourseTab = ({ selected, stats, courses }) => {
|
||||
const [selection, setSelection] = useState(selected)
|
||||
|
||||
getStats = () => {
|
||||
const { stats } = this.props
|
||||
const { selected } = this.state
|
||||
return {
|
||||
selected,
|
||||
selectedStatistic: stats[selected]
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { selected, selectedStatistic } = this.getStats()
|
||||
const { courses } = this.props
|
||||
if (!selectedStatistic) return null
|
||||
return (
|
||||
<div>
|
||||
<Segment>
|
||||
<Form>
|
||||
{courses ? (
|
||||
courses.text
|
||||
) : (
|
||||
<Form.Dropdown
|
||||
name="selected"
|
||||
options={courses}
|
||||
onChange={(e, { value }) => this.setState({ selected: value })}
|
||||
value={selected || courses.value}
|
||||
selectOnBlur={false}
|
||||
selectOnNavigation={false}
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
<Label.Group>
|
||||
<Label
|
||||
key={selectedStatistic.coursecode}
|
||||
content={`${selectedStatistic.alternatives.map(code => ` ${code}`)} ${selectedStatistic.name} `}
|
||||
/>
|
||||
</Label.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
{selected && <SingleCourseStats stats={selectedStatistic} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!stats[selection]) return null
|
||||
return (
|
||||
<div>
|
||||
<Segment>
|
||||
<Form>
|
||||
{courses ? (
|
||||
courses.text
|
||||
) : (
|
||||
<Form.Dropdown
|
||||
name="selected"
|
||||
options={courses}
|
||||
onChange={(e, { value }) => setSelection(value)}
|
||||
value={selection || courses.value}
|
||||
selectOnBlur={false}
|
||||
selectOnNavigation={false}
|
||||
/>
|
||||
)}
|
||||
<Divider />
|
||||
<Label.Group>
|
||||
<Label
|
||||
key={stats[selection].coursecode}
|
||||
content={`${stats[selection].alternatives.map(code => ` ${code}`)} ${stats[selection].name} `}
|
||||
/>
|
||||
</Label.Group>
|
||||
</Form>
|
||||
</Segment>
|
||||
{selection && <SingleCourseStats stats={stats[selection]} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
SingleCourseTab.propTypes = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Component } from 'react'
|
||||
import React from 'react'
|
||||
import { Form, Label, Segment, Header } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { shape, arrayOf, func, oneOfType, number, string } from 'prop-types'
|
||||
@@ -8,58 +8,55 @@ import { fields, setValue } from '../../../redux/coursesSummaryForm'
|
||||
import CumulativeTable from '../CumulativeTable'
|
||||
import ProgrammeDropdown from '../ProgrammeDropdown'
|
||||
|
||||
class SummaryTab extends Component {
|
||||
handleChange = (e, { name, value }) => {
|
||||
const SummaryTab = ({ form, setValue, statistics, programmes, queryInfo, onClickCourse }) => {
|
||||
const handleChange = (e, { name, value }) => {
|
||||
let selected = [...value].filter(v => v !== ALL.value)
|
||||
if ((!this.props.form[fields.programmes].includes(ALL.value) && value.includes(ALL.value)) || value.length === 0) {
|
||||
if ((!form[fields.programmes].includes(ALL.value) && value.includes(ALL.value)) || value.length === 0) {
|
||||
selected = [ALL.value]
|
||||
}
|
||||
this.props.setValue(name, selected)
|
||||
setValue(name, selected)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { statistics, programmes, queryInfo } = this.props
|
||||
const data = statistics.map(stat => {
|
||||
const { coursecode, name, realisations, summary } = stat
|
||||
const { passed, failed, passrate } = summary
|
||||
return {
|
||||
id: coursecode,
|
||||
category: name,
|
||||
passed,
|
||||
failed,
|
||||
passrate,
|
||||
realisations
|
||||
}
|
||||
})
|
||||
const data = statistics.map(stat => {
|
||||
const { coursecode, name, realisations, summary } = stat
|
||||
const { passed, failed, passrate } = summary
|
||||
return {
|
||||
id: coursecode,
|
||||
category: name,
|
||||
passed,
|
||||
failed,
|
||||
passrate,
|
||||
realisations
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Segment>
|
||||
<Form>
|
||||
<Header content="Filter statistics by study programmes" as="h4" />
|
||||
<ProgrammeDropdown
|
||||
options={programmes
|
||||
.map(e => ({ ...e, size: new Set(flatten(Object.values(e.students))).size }))
|
||||
.filter(e => e.size > 0)}
|
||||
label="Study programmes:"
|
||||
name={fields.programmes}
|
||||
onChange={this.handleChange}
|
||||
value={this.props.form[fields.programmes]}
|
||||
/>
|
||||
<Form.Field>
|
||||
<label>Timeframe:</label>
|
||||
<Label.Group>
|
||||
{queryInfo.timeframe.map(({ code, name }) => (
|
||||
<Label key={code} content={name} />
|
||||
))}
|
||||
</Label.Group>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Segment>
|
||||
{<CumulativeTable categoryName="Course" onClickCourse={this.props.onClickCourse} data={data} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<Segment>
|
||||
<Form>
|
||||
<Header content="Filter statistics by study programmes" as="h4" />
|
||||
<ProgrammeDropdown
|
||||
options={programmes
|
||||
.map(e => ({ ...e, size: new Set(flatten(Object.values(e.students))).size }))
|
||||
.filter(e => e.size > 0)}
|
||||
label="Study programmes:"
|
||||
name={fields.programmes}
|
||||
onChange={handleChange}
|
||||
value={form[fields.programmes]}
|
||||
/>
|
||||
<Form.Field>
|
||||
<label>Timeframe:</label>
|
||||
<Label.Group>
|
||||
{queryInfo.timeframe.map(({ code, name }) => (
|
||||
<Label key={code} content={name} />
|
||||
))}
|
||||
</Label.Group>
|
||||
</Form.Field>
|
||||
</Form>
|
||||
</Segment>
|
||||
{<CumulativeTable categoryName="Course" onClickCourse={onClickCourse} data={data} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
SummaryTab.propTypes = {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Header, Segment, Tab } from 'semantic-ui-react'
|
||||
import { Header, Segment, Tab, Message } from 'semantic-ui-react'
|
||||
import { connect } from 'react-redux'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import { bool, shape, func, string } from 'prop-types'
|
||||
import { bool, shape, func, string, arrayOf } from 'prop-types'
|
||||
import './courseStatistics.css'
|
||||
import SearchForm from './SearchForm'
|
||||
import SingleCourseTab from './SingleCourseTab'
|
||||
@@ -11,6 +11,11 @@ import SummaryTab from './SummaryTab'
|
||||
import ProgressBar from '../ProgressBar'
|
||||
import { useProgress, useTitle } from '../../common/hooks'
|
||||
import { clearCourseStats } from '../../redux/coursestats'
|
||||
import { getUserRoles, checkUserAccess } from '../../common'
|
||||
import TSA from '../../common/tsa'
|
||||
|
||||
const ANALYTICS_CATEGORY = 'Course Statistics'
|
||||
const sendAnalytics = (action, name, value) => TSA.Matomo.sendEvent(ANALYTICS_CATEGORY, action, name, value)
|
||||
|
||||
const MENU = {
|
||||
SUM: 'Summary',
|
||||
@@ -20,7 +25,16 @@ const MENU = {
|
||||
}
|
||||
|
||||
const CourseStatistics = props => {
|
||||
const { singleCourseStats, clearCourseStats, history, statsIsEmpty, loading, initCourseCode } = props
|
||||
const {
|
||||
singleCourseStats,
|
||||
clearCourseStats,
|
||||
history,
|
||||
statsIsEmpty,
|
||||
loading,
|
||||
initCourseCode,
|
||||
userRoles,
|
||||
rights
|
||||
} = props
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [selected, setSelected] = useState(initCourseCode)
|
||||
@@ -60,6 +74,7 @@ const CourseStatistics = props => {
|
||||
icon: 'search',
|
||||
position: 'right',
|
||||
onClick: () => {
|
||||
sendAnalytics('Clicked new query', 'Course stats')
|
||||
history.push('/coursestatistics')
|
||||
clearCourseStats()
|
||||
}
|
||||
@@ -75,6 +90,16 @@ const CourseStatistics = props => {
|
||||
setActiveIndex(activeIndex)
|
||||
}
|
||||
}
|
||||
if (!checkUserAccess(['courseStatistics', 'admin'], userRoles) && rights.length < 1)
|
||||
return (
|
||||
<div className="segmentContainer">
|
||||
<Message
|
||||
error
|
||||
color="red"
|
||||
header="You have no rights to access any data. If you should have access please contact grp-toska@helsinki.fi"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const panes = getPanes()
|
||||
return (
|
||||
@@ -105,12 +130,21 @@ CourseStatistics.propTypes = {
|
||||
history: shape({}).isRequired,
|
||||
clearCourseStats: func.isRequired,
|
||||
loading: bool.isRequired,
|
||||
initCourseCode: string.isRequired
|
||||
initCourseCode: string.isRequired,
|
||||
userRoles: arrayOf(string).isRequired,
|
||||
rights: arrayOf(string).isRequired
|
||||
}
|
||||
|
||||
const mapStateToProps = ({ courseStats }) => {
|
||||
const mapStateToProps = ({
|
||||
courseStats,
|
||||
auth: {
|
||||
token: { roles, rights }
|
||||
}
|
||||
}) => {
|
||||
const courses = Object.keys(courseStats.data)
|
||||
return {
|
||||
userRoles: getUserRoles(roles),
|
||||
rights,
|
||||
pending: courseStats.pending,
|
||||
error: courseStats.error,
|
||||
statsIsEmpty: courses.length === 0,
|
||||
|
||||
+4
-4
@@ -4,10 +4,10 @@ import { func, shape, arrayOf, string, bool } from 'prop-types'
|
||||
import { Segment, Header, Popup } from 'semantic-ui-react'
|
||||
import { getTranslate } from 'react-localize-redux'
|
||||
|
||||
import SegmentDimmer from '../SegmentDimmer'
|
||||
import PopulationCourseStats from '../PopulationCourseStats'
|
||||
import { refreshFilters } from '../../redux/populationFilters'
|
||||
import { getCustomPopulationCoursesByStudentnumbers } from '../../redux/populationCourses'
|
||||
import SegmentDimmer from '../../SegmentDimmer'
|
||||
import PopulationCourseStats from '../../PopulationCourseStats'
|
||||
import { refreshFilters } from '../../../redux/populationFilters'
|
||||
import { getCustomPopulationCoursesByStudentnumbers } from '../../../redux/populationCourses'
|
||||
|
||||
const CustomPopulationCourses = ({
|
||||
refreshNeeded,
|
||||
+3
-3
@@ -3,9 +3,9 @@ import { connect } from 'react-redux'
|
||||
import { Segment, Icon, Button, Form, Input, Popup } from 'semantic-ui-react'
|
||||
import { func, shape, string, arrayOf } from 'prop-types'
|
||||
|
||||
import infoTooltips from '../../common/InfoToolTips'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
import { courseCreditFilter } from '../../populationFilters'
|
||||
import infoTooltips from '../../../common/InfoToolTips'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../../redux/populationFilters'
|
||||
import { courseCreditFilter } from '../../../populationFilters'
|
||||
|
||||
const CourseCreditFilter = ({
|
||||
coursecodes,
|
||||
+3
-3
@@ -3,9 +3,9 @@ import { connect } from 'react-redux'
|
||||
import { Segment, Icon, Button, Form, Dropdown, Popup } from 'semantic-ui-react'
|
||||
import { func, shape, string, arrayOf, number } from 'prop-types'
|
||||
|
||||
import infoTooltips from '../../common/InfoToolTips'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../redux/populationFilters'
|
||||
import { gradeFilter } from '../../populationFilters'
|
||||
import infoTooltips from '../../../common/InfoToolTips'
|
||||
import { removePopulationFilter, setPopulationFilter } from '../../../redux/populationFilters'
|
||||
import { gradeFilter } from '../../../populationFilters'
|
||||
|
||||
const GradeFilter = ({
|
||||
coursecodes,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user