ladok3 4.10__py3-none-any.whl → 5.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- doc/ltxobj/ladok3.pdf +0 -0
- ladok3/Makefile +6 -0
- ladok3/__init__.py +1511 -3386
- ladok3/api.nw +1653 -225
- ladok3/cli.nw +118 -53
- ladok3/cli.py +323 -252
- ladok3/data.nw +92 -15
- ladok3/data.py +79 -3
- ladok3/ladok.bash +35 -17
- ladok3/ladok3.nw +288 -16
- ladok3/report.nw +183 -117
- ladok3/report.py +135 -63
- ladok3/scripts.nw +244 -0
- ladok3/student.nw +69 -4
- ladok3/student.py +98 -42
- ladok3/undoc.nw +62 -3119
- {ladok3-4.10.dist-info → ladok3-5.4.dist-info}/LICENSE +1 -1
- {ladok3-4.10.dist-info → ladok3-5.4.dist-info}/METADATA +39 -17
- ladok3-5.4.dist-info/RECORD +21 -0
- {ladok3-4.10.dist-info → ladok3-5.4.dist-info}/WHEEL +1 -1
- ladok3/.gitignore +0 -10
- ladok3-4.10.dist-info/RECORD +0 -21
- {ladok3-4.10.dist-info → ladok3-5.4.dist-info}/entry_points.txt +0 -0
ladok3/ladok3.nw
CHANGED
|
@@ -16,6 +16,8 @@ import json
|
|
|
16
16
|
import operator
|
|
17
17
|
import re
|
|
18
18
|
import requests
|
|
19
|
+
from requests.adapters import HTTPAdapter
|
|
20
|
+
from urllib3.util.retry import Retry
|
|
19
21
|
import urllib.parse
|
|
20
22
|
import weblogin.ladok
|
|
21
23
|
|
|
@@ -57,6 +59,17 @@ ladok = ladok3.LadokSession("KTH Royal Institute of Technology",
|
|
|
57
59
|
test_environment=True) # for experiments
|
|
58
60
|
\end{minted}
|
|
59
61
|
|
|
62
|
+
Yet another way is to use the [[ladok]] command's own credential store.
|
|
63
|
+
\begin{minted}[linenos]{python}
|
|
64
|
+
import ladok3
|
|
65
|
+
import ladok3.cli
|
|
66
|
+
|
|
67
|
+
ladok = ladok3.LadokSession(*ladok3.cli.load_credentials(),
|
|
68
|
+
test_environment=True) # for experiments
|
|
69
|
+
\end{minted}
|
|
70
|
+
Just note that unpacking [[*]] to unpack the tuple that [[load_credentials]]
|
|
71
|
+
returns.
|
|
72
|
+
|
|
60
73
|
This chapter covers how the [[LadokSession]] class work.
|
|
61
74
|
The remaining chapters cover what the [[ladok]] object can be used for.
|
|
62
75
|
\Cref{StudentClasses} covers how we can work with student data.
|
|
@@ -169,9 +182,76 @@ autologin_handlers.insert(0,
|
|
|
169
182
|
test_environment=test_environment))
|
|
170
183
|
|
|
171
184
|
self.__session = weblogin.AutologinSession(autologin_handlers)
|
|
185
|
+
<<configure retry policy for the session>>
|
|
172
186
|
@
|
|
173
187
|
|
|
174
188
|
|
|
189
|
+
\subsection{Retry policy for network resilience}
|
|
190
|
+
|
|
191
|
+
Network operations can fail transiently due to temporary connectivity issues,
|
|
192
|
+
server overload, or other intermittent problems. To make the LADOK API wrapper
|
|
193
|
+
more robust, we implement an automatic retry mechanism with exponential backoff.
|
|
194
|
+
|
|
195
|
+
The retry policy addresses the "why" behind network failures: they are often
|
|
196
|
+
temporary. Rather than immediately failing when encountering a network error or
|
|
197
|
+
server overload (5xx errors), we wait and try again. The exponential backoff
|
|
198
|
+
prevents overwhelming an already struggling server while still providing
|
|
199
|
+
reasonable response times for transient failures.
|
|
200
|
+
|
|
201
|
+
We use the [[urllib3.util.retry.Retry]] class, which provides sophisticated
|
|
202
|
+
retry logic, combined with [[requests.adapters.HTTPAdapter]] to integrate it
|
|
203
|
+
into our session. This approach is preferable to manual retry loops because it:
|
|
204
|
+
\begin{itemize}
|
|
205
|
+
\item Handles backoff timing automatically with exponential growth
|
|
206
|
+
\item Respects HTTP standards (like [[Retry-After]] headers)
|
|
207
|
+
\item Integrates seamlessly with the [[requests]] session infrastructure
|
|
208
|
+
\item Applies to all HTTP methods uniformly
|
|
209
|
+
\end{itemize}
|
|
210
|
+
|
|
211
|
+
We configure the retry policy as follows:
|
|
212
|
+
\begin{description}
|
|
213
|
+
\item[{Total retries}] We set a maximum of 10 retry attempts. With exponential
|
|
214
|
+
backoff starting at 1 second, this allows for approximately 5 minutes of total
|
|
215
|
+
retry time (1, 2, 4, 8, 16, 32, 64, 128, 256, 300 seconds = \~{}5 minutes when
|
|
216
|
+
capped at 300 seconds).
|
|
217
|
+
|
|
218
|
+
\item[{Backoff factor}] We use a backoff factor of 1, meaning the wait time
|
|
219
|
+
between retries is $\{\text{backoff factor}\} \times 2^{(\text{retry number} -
|
|
220
|
+
1)}$ seconds. The first retry waits 1 second, the second waits 2 seconds, the
|
|
221
|
+
third waits 4 seconds, and so on.
|
|
222
|
+
|
|
223
|
+
\item[{Maximum backoff}] We cap the backoff at 300 seconds (5 minutes) to
|
|
224
|
+
prevent excessively long waits on later retries while still allowing the full
|
|
225
|
+
retry sequence to span approximately 5 minutes total.
|
|
226
|
+
|
|
227
|
+
\item[{Status forcelist}] We retry on HTTP status codes 500 (Internal Server
|
|
228
|
+
Error), 502 (Bad Gateway), 503 (Service Unavailable), and 504 (Gateway Timeout).
|
|
229
|
+
These indicate temporary server-side issues that often resolve quickly.
|
|
230
|
+
|
|
231
|
+
\item[{Allowed methods}] We allow retries on all HTTP methods including GET,
|
|
232
|
+
POST, PUT, and DELETE. While POST/PUT/DELETE are not idempotent by HTTP
|
|
233
|
+
standards, LADOK's API design makes these operations safe to retry (results are
|
|
234
|
+
either already recorded or will be recorded on retry).
|
|
235
|
+
\end{description}
|
|
236
|
+
|
|
237
|
+
<<configure retry policy for the session>>=
|
|
238
|
+
retry_strategy = Retry(
|
|
239
|
+
total=10,
|
|
240
|
+
backoff_factor=1,
|
|
241
|
+
backoff_max=300,
|
|
242
|
+
status_forcelist=[500, 502, 503, 504],
|
|
243
|
+
allowed_methods=["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE",
|
|
244
|
+
"POST"]
|
|
245
|
+
)
|
|
246
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
247
|
+
self.__session.mount("http://", adapter)
|
|
248
|
+
self.__session.mount("https://", adapter)
|
|
249
|
+
@
|
|
250
|
+
|
|
251
|
+
The [[mount]] method attaches the retry-enabled adapter to both HTTP and HTTPS
|
|
252
|
+
URLs, ensuring all requests through this session benefit from automatic retries.
|
|
253
|
+
|
|
254
|
+
|
|
175
255
|
\section{The [[LadokSession]] data methods}\label{LadokSession-data-methods}
|
|
176
256
|
|
|
177
257
|
The data methods are essentially factories for various objects mapping to data
|
|
@@ -252,7 +332,20 @@ For completeness, we also provide [[LadokDataEncoder]], a subclass of
|
|
|
252
332
|
[[JSONEncoder]].
|
|
253
333
|
<<classes>>=
|
|
254
334
|
class LadokDataEncoder(json.JSONEncoder):
|
|
335
|
+
"""JSON encoder for LadokData objects.
|
|
336
|
+
|
|
337
|
+
Extends JSONEncoder to properly serialize LadokData objects using their
|
|
338
|
+
json property for consistent JSON output.
|
|
339
|
+
"""
|
|
255
340
|
def default(self, object):
|
|
341
|
+
"""Convert LadokData objects to JSON-serializable format.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
object: The object to encode.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
dict: JSON representation for LadokData objects, or default handling.
|
|
348
|
+
"""
|
|
256
349
|
if isinstance(object, LadokData):
|
|
257
350
|
return object.json
|
|
258
351
|
return super().default(object)
|
|
@@ -844,6 +937,8 @@ def alive(self):
|
|
|
844
937
|
except:
|
|
845
938
|
self.__get_personal_attributes()
|
|
846
939
|
return self.__alive
|
|
940
|
+
|
|
941
|
+
<<student contact attribute methods>>
|
|
847
942
|
@
|
|
848
943
|
|
|
849
944
|
Then we can call that method to update all attributes.
|
|
@@ -872,6 +967,89 @@ self.__alive = not record['Avliden']
|
|
|
872
967
|
@
|
|
873
968
|
|
|
874
969
|
|
|
970
|
+
\section{Students' contact attributes}
|
|
971
|
+
|
|
972
|
+
The contact attributes include email, phone number, and postal address information.
|
|
973
|
+
We use the [[__get_contact_attributes]] helper method to populate these attributes
|
|
974
|
+
from a separate LADOK API call.
|
|
975
|
+
|
|
976
|
+
<<student contact attribute methods>>=
|
|
977
|
+
@property
|
|
978
|
+
def email(self):
|
|
979
|
+
"""Return the student's email address"""
|
|
980
|
+
try:
|
|
981
|
+
return self.__email
|
|
982
|
+
except:
|
|
983
|
+
self.__get_contact_attributes()
|
|
984
|
+
return self.__email
|
|
985
|
+
|
|
986
|
+
@property
|
|
987
|
+
def phone(self):
|
|
988
|
+
"""Return the student's phone number"""
|
|
989
|
+
try:
|
|
990
|
+
return self.__phone
|
|
991
|
+
except:
|
|
992
|
+
self.__get_contact_attributes()
|
|
993
|
+
return self.__phone
|
|
994
|
+
|
|
995
|
+
@property
|
|
996
|
+
def address(self):
|
|
997
|
+
"""Return the student's postal address as a list of lines"""
|
|
998
|
+
try:
|
|
999
|
+
return self.__address
|
|
1000
|
+
except:
|
|
1001
|
+
self.__get_contact_attributes()
|
|
1002
|
+
return self.__address
|
|
1003
|
+
|
|
1004
|
+
def __get_contact_attributes(self):
|
|
1005
|
+
"""Helper method that fetches contact attributes"""
|
|
1006
|
+
<<pull student contact attributes from LADOK>>
|
|
1007
|
+
@
|
|
1008
|
+
<<pull student contact attributes from LADOK>>=
|
|
1009
|
+
try:
|
|
1010
|
+
contact_data = self.ladok.get_student_contact_data_JSON(self.ladok_id)
|
|
1011
|
+
|
|
1012
|
+
# Extract email address
|
|
1013
|
+
self.__email = None
|
|
1014
|
+
if 'Epost' in contact_data and contact_data['Epost']:
|
|
1015
|
+
for email in contact_data['Epost']:
|
|
1016
|
+
if 'Adress' in email:
|
|
1017
|
+
self.__email = email['Adress']
|
|
1018
|
+
break
|
|
1019
|
+
|
|
1020
|
+
# Extract phone number
|
|
1021
|
+
self.__phone = None
|
|
1022
|
+
if 'Telefon' in contact_data and contact_data['Telefon']:
|
|
1023
|
+
for phone in contact_data['Telefon']:
|
|
1024
|
+
if 'Nummer' in phone:
|
|
1025
|
+
self.__phone = phone['Nummer']
|
|
1026
|
+
break
|
|
1027
|
+
|
|
1028
|
+
# Extract postal address
|
|
1029
|
+
self.__address = []
|
|
1030
|
+
if 'Postadress' in contact_data and contact_data['Postadress']:
|
|
1031
|
+
addr = contact_data['Postadress']
|
|
1032
|
+
if isinstance(addr, list) and len(addr) > 0:
|
|
1033
|
+
addr = addr[0]
|
|
1034
|
+
if isinstance(addr, dict):
|
|
1035
|
+
for field in ['Adressrad1', 'Adressrad2', 'Adressrad3']:
|
|
1036
|
+
if field in addr and addr[field]:
|
|
1037
|
+
self.__address.append(addr[field])
|
|
1038
|
+
if 'Postnummer' in addr and addr['Postnummer'] and 'Postort' in addr and addr['Postort']:
|
|
1039
|
+
self.__address.append(f"{addr['Postnummer']} {addr['Postort']}")
|
|
1040
|
+
|
|
1041
|
+
except Exception:
|
|
1042
|
+
# If contact data can't be retrieved, set default empty values
|
|
1043
|
+
self.__email = None
|
|
1044
|
+
self.__phone = None
|
|
1045
|
+
self.__address = []
|
|
1046
|
+
@
|
|
1047
|
+
|
|
1048
|
+
<<student attribute methods>>=
|
|
1049
|
+
<<student contact attribute methods>>
|
|
1050
|
+
@
|
|
1051
|
+
|
|
1052
|
+
|
|
875
1053
|
\section{Students' study-related attributes}
|
|
876
1054
|
|
|
877
1055
|
The study related attributes are courses and course results.
|
|
@@ -923,9 +1101,62 @@ for course in self.ladok.registrations_JSON(self.ladok_id):
|
|
|
923
1101
|
|
|
924
1102
|
We also want to know if a student is suspended or not.
|
|
925
1103
|
We can get a list of suspensions for a student.
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1104
|
+
This list looks like this:
|
|
1105
|
+
\begin{minted}{json}
|
|
1106
|
+
[
|
|
1107
|
+
{"Anteckning": "VJ-2024-...",
|
|
1108
|
+
"Giltighetsperiod": {"Slutdatum": "2024-12-03",
|
|
1109
|
+
"Startdatum": "2024-09-25", "link": []},
|
|
1110
|
+
"LarosateID": 29,
|
|
1111
|
+
"OrsakID": 101450,
|
|
1112
|
+
"SenastAndradAv": "someone@kth.se",
|
|
1113
|
+
"SenastSparad": "2024-09-24T16:45:56.941",
|
|
1114
|
+
"StudentUID": "...",
|
|
1115
|
+
"Uid": "...",
|
|
1116
|
+
"link": [{"method": "GET",
|
|
1117
|
+
"uri": "https://api.ladok.se:443/studentinformation/internal/avstangning/...",
|
|
1118
|
+
"mediaType": "application/vnd.ladok+xml",
|
|
1119
|
+
"rel": "self"}]
|
|
1120
|
+
}
|
|
1121
|
+
]
|
|
1122
|
+
\end{minted}
|
|
1123
|
+
Let's make a class for this.
|
|
1124
|
+
<<classes>>=
|
|
1125
|
+
class Suspension(LadokData):
|
|
1126
|
+
"""A suspension"""
|
|
1127
|
+
def __init__(self, /, **kwargs):
|
|
1128
|
+
super().__init__(**kwargs)
|
|
1129
|
+
self.__note = kwargs.pop("Anteckning")
|
|
1130
|
+
self.__validity = (
|
|
1131
|
+
datetime.date.fromisoformat(kwargs["Giltighetsperiod"]["Startdatum"]),
|
|
1132
|
+
datetime.date.fromisoformat(kwargs["Giltighetsperiod"]["Slutdatum"])
|
|
1133
|
+
)
|
|
1134
|
+
|
|
1135
|
+
@property
|
|
1136
|
+
def note(self):
|
|
1137
|
+
"""
|
|
1138
|
+
The note of the suspension. This is usually a case number.
|
|
1139
|
+
"""
|
|
1140
|
+
return self.__note
|
|
1141
|
+
|
|
1142
|
+
@property
|
|
1143
|
+
def validity(self):
|
|
1144
|
+
"""
|
|
1145
|
+
A tuple (start, end) of the validity period of the suspension.
|
|
1146
|
+
"""
|
|
1147
|
+
return self.__validity
|
|
1148
|
+
|
|
1149
|
+
@property
|
|
1150
|
+
def is_current(self):
|
|
1151
|
+
"""
|
|
1152
|
+
Is True if the suspension is currently valid.
|
|
1153
|
+
"""
|
|
1154
|
+
return self.validity[0] <= datetime.date.today() <= self.validity[1]
|
|
1155
|
+
|
|
1156
|
+
def __str__(self):
|
|
1157
|
+
return f"{self.validity[0]}--{self.validity[1]}"
|
|
1158
|
+
@
|
|
1159
|
+
|
|
929
1160
|
We never cache this result since we always want the most up-to-date version.
|
|
930
1161
|
<<student attribute methods>>=
|
|
931
1162
|
@property
|
|
@@ -933,7 +1164,9 @@ def suspensions(self):
|
|
|
933
1164
|
"""
|
|
934
1165
|
The list of the students' suspensions.
|
|
935
1166
|
"""
|
|
936
|
-
|
|
1167
|
+
suspensions = self.ladok.get_student_suspensions_JSON(
|
|
1168
|
+
self.ladok_id)["Avstangning"]
|
|
1169
|
+
return [Suspension(**suspension) for suspension in suspensions]
|
|
937
1170
|
@
|
|
938
1171
|
|
|
939
1172
|
|
|
@@ -971,9 +1204,9 @@ ladok = ladok3.LadokSession(
|
|
|
971
1204
|
test_environment=True)
|
|
972
1205
|
|
|
973
1206
|
print(r"\begin{minted}{JSON}")
|
|
974
|
-
|
|
975
|
-
ladok3.clean_data(
|
|
976
|
-
print(json.dumps(
|
|
1207
|
+
course_round = ladok.search_course_rounds_JSON(code="DD1317")[0]
|
|
1208
|
+
ladok3.clean_data(course_round)
|
|
1209
|
+
print(json.dumps(course_round, indent=2))
|
|
977
1210
|
print(r"\end{minted}")
|
|
978
1211
|
\end{pycode}
|
|
979
1212
|
|
|
@@ -1122,12 +1355,8 @@ By trial-and-error, it seems like the faux courses has none of the attributes
|
|
|
1122
1355
|
that the real courses have.
|
|
1123
1356
|
However, we try our best.
|
|
1124
1357
|
<<assign common CourseInstance data to private attributes>>=
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
ladok=self.ladok, course=self,
|
|
1128
|
-
**component) for component in data["IngaendeMoment"]]
|
|
1129
|
-
else:
|
|
1130
|
-
self.__components = []
|
|
1358
|
+
self.__components = []
|
|
1359
|
+
<<add course components to [[self.__components]]>>
|
|
1131
1360
|
<<assign CourseInstance data to private attributes>>=
|
|
1132
1361
|
<<assign common CourseInstance data to private attributes>>
|
|
1133
1362
|
|
|
@@ -1203,6 +1432,33 @@ def components(self, /, **kwargs):
|
|
|
1203
1432
|
@
|
|
1204
1433
|
|
|
1205
1434
|
|
|
1435
|
+
\subsection{Course components}
|
|
1436
|
+
|
|
1437
|
+
We need to add the courses components to the course instance.
|
|
1438
|
+
That is things like LAB1 or EXA1 that make up the course in LADOK.
|
|
1439
|
+
<<add course components to [[self.__components]]>>=
|
|
1440
|
+
if "IngaendeMoment" in data:
|
|
1441
|
+
self.__components += [CourseComponent(
|
|
1442
|
+
ladok=self.ladok, course=self,
|
|
1443
|
+
**component) for component in data["IngaendeMoment"]]
|
|
1444
|
+
@
|
|
1445
|
+
|
|
1446
|
+
We also add the course itself as a component.
|
|
1447
|
+
This is to handle the grade on the course itself.
|
|
1448
|
+
Once all the components have a grade, the course itself will get a grade.
|
|
1449
|
+
This component represents that grade.
|
|
1450
|
+
It will get the course code as its code.
|
|
1451
|
+
<<add course components to [[self.__components]]>>=
|
|
1452
|
+
try:
|
|
1453
|
+
course_component_data = data.copy()
|
|
1454
|
+
course_component_data["ladok"] = self.ladok
|
|
1455
|
+
self.__components.append(CourseComponent(course=self,
|
|
1456
|
+
**course_component_data))
|
|
1457
|
+
except KeyError:
|
|
1458
|
+
pass
|
|
1459
|
+
@
|
|
1460
|
+
|
|
1461
|
+
|
|
1206
1462
|
\section{Course components}
|
|
1207
1463
|
|
|
1208
1464
|
The [[CourseComponent]] class will make the attributes available.
|
|
@@ -1415,13 +1671,15 @@ We can pull these from LADOK as well.
|
|
|
1415
1671
|
We construct [[CourseResult]] objects using a [[CourseRegistration]] object.
|
|
1416
1672
|
From the response we get from LADOK, we construct [[CourseResult]] objects that
|
|
1417
1673
|
deal with the details.
|
|
1674
|
+
(It's a bit unclear why [[Kursversioner]] is a list, so far it has always
|
|
1675
|
+
worked to take the first.)
|
|
1418
1676
|
<<pull existing CourseResult objects from LADOK>>=
|
|
1419
1677
|
response = self.ladok.student_results_JSON(
|
|
1420
1678
|
self.__student.ladok_id, self.education_id
|
|
1421
1679
|
)["Kursversioner"][0]
|
|
1422
1680
|
|
|
1423
|
-
#self.__results_id = response["Uid"]
|
|
1424
1681
|
self.__results = []
|
|
1682
|
+
|
|
1425
1683
|
for result in response["VersionensModuler"]:
|
|
1426
1684
|
try:
|
|
1427
1685
|
self.__results.append(CourseResult(ladok=self.ladok,
|
|
@@ -1432,6 +1690,20 @@ for result in response["VersionensModuler"]:
|
|
|
1432
1690
|
pass
|
|
1433
1691
|
@
|
|
1434
1692
|
|
|
1693
|
+
As with the components, the course grade itself is a special case.
|
|
1694
|
+
That data is not in [[VersionensModuler]], but in [[VersionensKurs]].
|
|
1695
|
+
But we can do the same with it, just as we could with the components.
|
|
1696
|
+
(And this works since it's a component in [[self.components()]].)
|
|
1697
|
+
<<pull existing CourseResult objects from LADOK>>=
|
|
1698
|
+
try:
|
|
1699
|
+
self.__results.append(CourseResult(ladok=self.ladok,
|
|
1700
|
+
components=self.components(),
|
|
1701
|
+
student=self.__student,
|
|
1702
|
+
**response["VersionensKurs"]))
|
|
1703
|
+
except TypeError:
|
|
1704
|
+
pass
|
|
1705
|
+
@
|
|
1706
|
+
|
|
1435
1707
|
Now we can see which components from [[self.components]] are missing from
|
|
1436
1708
|
[[self.__results]] and just add empty results for those.
|
|
1437
1709
|
<<add new CourseResult objects for missing components>>=
|
|
@@ -1629,7 +1901,7 @@ try:
|
|
|
1629
1901
|
self.__uid, self.grade.id, self.date.isoformat(), self.__last_modified
|
|
1630
1902
|
)
|
|
1631
1903
|
except Exception as err:
|
|
1632
|
-
raise
|
|
1904
|
+
raise LadokError(
|
|
1633
1905
|
f"couldn't update {self.component.code} to {self.grade} ({self.date})"
|
|
1634
1906
|
f" to LADOK: {err}"
|
|
1635
1907
|
)
|
|
@@ -1652,7 +1924,7 @@ try:
|
|
|
1652
1924
|
self.grade.id, self.date.isoformat()
|
|
1653
1925
|
)
|
|
1654
1926
|
except Exception as err:
|
|
1655
|
-
raise
|
|
1927
|
+
raise LadokError("Couldn't register "
|
|
1656
1928
|
f"{self.component} {self.grade} {self.date}: {err}")
|
|
1657
1929
|
|
|
1658
1930
|
self.__populate_attributes(**response)
|