ladok3 4.13__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 +1443 -3350
- ladok3/api.nw +1649 -224
- ladok3/cli.nw +102 -53
- ladok3/cli.py +84 -35
- ladok3/data.nw +92 -15
- ladok3/data.py +79 -3
- ladok3/ladok.bash +35 -17
- ladok3/ladok3.nw +242 -9
- 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.13.dist-info → ladok3-5.4.dist-info}/LICENSE +1 -1
- {ladok3-4.13.dist-info → ladok3-5.4.dist-info}/METADATA +39 -17
- ladok3-5.4.dist-info/RECORD +21 -0
- {ladok3-4.13.dist-info → ladok3-5.4.dist-info}/WHEEL +1 -1
- ladok3/.gitignore +0 -10
- ladok3-4.13.dist-info/RECORD +0 -21
- {ladok3-4.13.dist-info → ladok3-5.4.dist-info}/entry_points.txt +0 -0
ladok3/data.py
CHANGED
|
@@ -13,6 +13,19 @@ def filter_rounds(all_rounds, desired_rounds):
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def extract_data_for_round(ladok, course_round, args):
|
|
16
|
+
"""Extract student result data for a specific course round.
|
|
17
|
+
|
|
18
|
+
Processes a course round to extract student results data in CSV format,
|
|
19
|
+
filtering by students and components as specified in args.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
ladok (LadokSession): The LADOK session for data access.
|
|
23
|
+
course_round: The course round object to extract data from.
|
|
24
|
+
args: Command line arguments containing filter options.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
list: List of result data dictionaries for CSV output.
|
|
28
|
+
"""
|
|
16
29
|
course_start = course_round.start
|
|
17
30
|
course_length = course_round.end - course_start
|
|
18
31
|
component = course_round.components()[0]
|
|
@@ -60,14 +73,38 @@ def extract_data_for_round(ladok, course_round, args):
|
|
|
60
73
|
grade = "-"
|
|
61
74
|
normalized_date = None
|
|
62
75
|
|
|
63
|
-
yield student, component, grade,
|
|
76
|
+
yield student.ladok_id if args.ladok_id else student, component, grade, (
|
|
77
|
+
normalized_date
|
|
78
|
+
if args.normalize_date
|
|
79
|
+
else result_data["Examinationsdatum"] if result_data else None
|
|
80
|
+
)
|
|
64
81
|
|
|
65
82
|
|
|
66
83
|
def filter_student_results(student, results):
|
|
84
|
+
"""Filter results for a specific student.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
student: Student object with ladok_id attribute.
|
|
88
|
+
results (list): List of result dictionaries from LADOK.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
list: Filtered list containing only results for the specified student.
|
|
92
|
+
"""
|
|
67
93
|
return list(filter(lambda x: x["Student"]["Uid"] == student.ladok_id, results))
|
|
68
94
|
|
|
69
95
|
|
|
70
96
|
def filter_component_result(component, results):
|
|
97
|
+
"""Find the result data for a specific course component.
|
|
98
|
+
|
|
99
|
+
Searches through results to find the entry matching the given component.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
component: Course component object to search for.
|
|
103
|
+
results (list): List of result dictionaries to search through.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
dict or None: Result data for the component, or None if not found.
|
|
107
|
+
"""
|
|
71
108
|
for component_result in results:
|
|
72
109
|
if "Arbetsunderlag" in component_result:
|
|
73
110
|
result_data = component_result["Arbetsunderlag"]
|
|
@@ -131,8 +168,16 @@ def has_credit_transfer(results):
|
|
|
131
168
|
|
|
132
169
|
|
|
133
170
|
def add_command_options(parser):
|
|
171
|
+
"""Add the 'course' subcommand options to the argument parser.
|
|
172
|
+
|
|
173
|
+
Creates a subparser for the course data extraction command with all
|
|
174
|
+
necessary arguments for filtering and output formatting.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
parser (ArgumentParser): The parent parser to add the subcommand to.
|
|
178
|
+
"""
|
|
134
179
|
data_parser = parser.add_parser(
|
|
135
|
-
"
|
|
180
|
+
"course",
|
|
136
181
|
help="Returns course results data in CSV form",
|
|
137
182
|
description="""
|
|
138
183
|
Returns the results in CSV form for all first-time registered students.
|
|
@@ -151,6 +196,13 @@ def add_command_options(parser):
|
|
|
151
196
|
"default is a tab character to be compatible with POSIX commands, "
|
|
152
197
|
"use `-d,` or `-d ,` to get comma-separated values.",
|
|
153
198
|
)
|
|
199
|
+
|
|
200
|
+
data_parser.add_argument(
|
|
201
|
+
"-H",
|
|
202
|
+
"--header",
|
|
203
|
+
action="store_true",
|
|
204
|
+
help="Print a header line with the column names.",
|
|
205
|
+
)
|
|
154
206
|
data_parser.add_argument(
|
|
155
207
|
"-r",
|
|
156
208
|
"--rounds",
|
|
@@ -158,6 +210,21 @@ def add_command_options(parser):
|
|
|
158
210
|
help="The round codes for the rounds to include, "
|
|
159
211
|
"otherwise all rounds will be included.",
|
|
160
212
|
)
|
|
213
|
+
data_parser.add_argument(
|
|
214
|
+
"-l",
|
|
215
|
+
"--ladok-id",
|
|
216
|
+
action="store_true",
|
|
217
|
+
help="Use the LADOK ID for the student, "
|
|
218
|
+
"otherwise the student name and personnummer "
|
|
219
|
+
"will be used.",
|
|
220
|
+
)
|
|
221
|
+
data_parser.add_argument(
|
|
222
|
+
"-n",
|
|
223
|
+
"--normalize-date",
|
|
224
|
+
action="store_true",
|
|
225
|
+
help="Normalize the date to the start of the course, "
|
|
226
|
+
"otherwise the date is printed as is.",
|
|
227
|
+
)
|
|
161
228
|
data_parser.add_argument(
|
|
162
229
|
"-t",
|
|
163
230
|
"--time-limit",
|
|
@@ -183,12 +250,21 @@ def add_command_options(parser):
|
|
|
183
250
|
|
|
184
251
|
|
|
185
252
|
def command(ladok, args):
|
|
253
|
+
"""Execute the course data extraction command.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
ladok (LadokSession): The LADOK session for data access.
|
|
257
|
+
args: Parsed command line arguments containing course and filter options.
|
|
258
|
+
"""
|
|
186
259
|
data_writer = csv.writer(sys.stdout, delimiter=args.delimiter)
|
|
187
260
|
course_rounds = filter_rounds(
|
|
188
261
|
ladok.search_course_rounds(code=args.course_code), args.rounds
|
|
189
262
|
)
|
|
190
263
|
|
|
191
|
-
|
|
264
|
+
if args.header:
|
|
265
|
+
data_writer.writerow(
|
|
266
|
+
["Course", "Round", "Component", "Student", "Grade", "Time"]
|
|
267
|
+
)
|
|
192
268
|
for course_round in course_rounds:
|
|
193
269
|
data = extract_data_for_round(ladok, course_round, args)
|
|
194
270
|
|
ladok3/ladok.bash
CHANGED
|
@@ -25,22 +25,40 @@ __python_argcomplete_run_inner() {
|
|
|
25
25
|
|
|
26
26
|
_python_argcomplete() {
|
|
27
27
|
local IFS=$'\013'
|
|
28
|
-
local
|
|
29
|
-
if
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
28
|
+
local script=""
|
|
29
|
+
if [[ -n "${ZSH_VERSION-}" ]]; then
|
|
30
|
+
local completions
|
|
31
|
+
completions=($(IFS="$IFS" \
|
|
32
|
+
COMP_LINE="$BUFFER" \
|
|
33
|
+
COMP_POINT="$CURSOR" \
|
|
34
|
+
_ARGCOMPLETE=1 \
|
|
35
|
+
_ARGCOMPLETE_SHELL="zsh" \
|
|
36
|
+
_ARGCOMPLETE_SUPPRESS_SPACE=1 \
|
|
37
|
+
__python_argcomplete_run ${script:-${words[1]}}))
|
|
38
|
+
_describe "${words[1]}" completions -o nosort
|
|
39
|
+
else
|
|
40
|
+
local SUPPRESS_SPACE=0
|
|
41
|
+
if compopt +o nospace 2> /dev/null; then
|
|
42
|
+
SUPPRESS_SPACE=1
|
|
43
|
+
fi
|
|
44
|
+
COMPREPLY=($(IFS="$IFS" \
|
|
45
|
+
COMP_LINE="$COMP_LINE" \
|
|
46
|
+
COMP_POINT="$COMP_POINT" \
|
|
47
|
+
COMP_TYPE="$COMP_TYPE" \
|
|
48
|
+
_ARGCOMPLETE_COMP_WORDBREAKS="$COMP_WORDBREAKS" \
|
|
49
|
+
_ARGCOMPLETE=1 \
|
|
50
|
+
_ARGCOMPLETE_SHELL="bash" \
|
|
51
|
+
_ARGCOMPLETE_SUPPRESS_SPACE=$SUPPRESS_SPACE \
|
|
52
|
+
__python_argcomplete_run ${script:-$1}))
|
|
53
|
+
if [[ $? != 0 ]]; then
|
|
54
|
+
unset COMPREPLY
|
|
55
|
+
elif [[ $SUPPRESS_SPACE == 1 ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then
|
|
56
|
+
compopt -o nospace
|
|
57
|
+
fi
|
|
44
58
|
fi
|
|
45
59
|
}
|
|
46
|
-
|
|
60
|
+
if [[ -z "${ZSH_VERSION-}" ]]; then
|
|
61
|
+
complete -o nospace -o default -o bashdefault -F _python_argcomplete ladok
|
|
62
|
+
else
|
|
63
|
+
compdef _python_argcomplete ladok
|
|
64
|
+
fi
|
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,8 +182,75 @@ 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>>
|
|
186
|
+
@
|
|
187
|
+
|
|
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)
|
|
172
249
|
@
|
|
173
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
|
+
|
|
174
254
|
|
|
175
255
|
\section{The [[LadokSession]] data methods}\label{LadokSession-data-methods}
|
|
176
256
|
|
|
@@ -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
|
|
|
@@ -1668,7 +1901,7 @@ try:
|
|
|
1668
1901
|
self.__uid, self.grade.id, self.date.isoformat(), self.__last_modified
|
|
1669
1902
|
)
|
|
1670
1903
|
except Exception as err:
|
|
1671
|
-
raise
|
|
1904
|
+
raise LadokError(
|
|
1672
1905
|
f"couldn't update {self.component.code} to {self.grade} ({self.date})"
|
|
1673
1906
|
f" to LADOK: {err}"
|
|
1674
1907
|
)
|
|
@@ -1691,7 +1924,7 @@ try:
|
|
|
1691
1924
|
self.grade.id, self.date.isoformat()
|
|
1692
1925
|
)
|
|
1693
1926
|
except Exception as err:
|
|
1694
|
-
raise
|
|
1927
|
+
raise LadokError("Couldn't register "
|
|
1695
1928
|
f"{self.component} {self.grade} {self.date}: {err}")
|
|
1696
1929
|
|
|
1697
1930
|
self.__populate_attributes(**response)
|