diff --git a/examples/example_Student.py b/examples/example_Student.py index 6e89877..731e2b3 100644 --- a/examples/example_Student.py +++ b/examples/example_Student.py @@ -16,7 +16,7 @@ for course in me.courses(): print(f"{course.code} {course.name}") -course = me.courses(code="DD2395")[0] +course = me.courses(code="DD2395")[-1] print(f"{course.code} results:") for result in course.results(): @@ -30,7 +30,13 @@ print() student = ladok.get_student("1234561234") -prgi = student.courses(code="DD1315")[0] +prgis = student.courses(code="DD1315") +print(f"{student} registered {len(prgis)} times") + +for reg in prgis: + print(reg) + +prgi = student.courses(code="DD1315")[-1] print(f"{student.personnummer} {student.first_name} {student.last_name}") diff --git a/src/ladok3/api.nw b/src/ladok3/api.nw index 1f90d0f..0307fbd 100644 --- a/src/ladok3/api.nw +++ b/src/ladok3/api.nw @@ -2,6 +2,9 @@ We will now document some possible API calls to LADOK. + +\section{HTTP queries to LADOK} + To make things easier, we will add three methods: [[get_query]], [[put_query]] and [[post_query]], which are shortcuts to make GET, PUT and POST queries to LADOK. @@ -42,6 +45,9 @@ def post_query(self, path, post_data, headers=headers) @ + +\section{Cleaning data for printing} + We sometimes want to print the data, for instance, example output in this document. For this reason we introduce some cleaning functions. @@ -87,6 +93,9 @@ def pseudonymize(json_obj): pseudonymize(item) @ + +\section{Test code for the API} + We will use the following to test the following API methods. \begin{pyblock}[apitest][numbers=left] import json @@ -128,17 +137,49 @@ The output looks like this. \section{[[registrations_JSON]]} +This methods returns \emph{all} registrations for a student, \ie registrations +on courses and programmes. +<>= +def registrations_JSON(self, student_id): + """Return all registrations for student with ID student_id.""" + response = self.get_query( + '/studiedeltagande/tillfallesdeltagande/kurstillfallesdeltagande/student/'+ + student_id, + "application/vnd.ladok-studiedeltagande+json") + + if response.status_code == 200: + return response.json()["Tillfallesdeltaganden"] + return None +@ + +This method is used as follows. +\begin{pyblock}[apitest][numbers=left,firstnumber=last] +me = ladok.get_student("de709f81-a867-11e7-8dbf-78e86dc2470c") + +results = ladok.registrations_JSON(me.ladok_id) + +ladok3.clean_data(results) +print(json.dumps(results, indent=2)) +\end{pyblock} +The output looks like this. +\stdoutpythontex[verbatim] + + + +\section{[[registrations_on_course_JSON]]} + This method returns all registrations on a particular course for a particular student. This way we can check if a student has been registered several times on a course. <>= -def registrations_JSON(self, course_education_id, student_id): +def registrations_on_course_JSON(self, + course_education_id, student_id): """Return a list of registrations on course with education_id for student with student_id. JSON format.""" response = self.get_query( - "/studiedeltagande/tillfallesdeltagande/" - f"utbildning/{course_education_id}/student/{student_id}", + "/studiedeltagande/tillfallesdeltagande" + f"/utbildning/{course_education_id}/student/{student_id}", "application/vnd.ladok-studiedeltagande+json") if response.status_code == 200: @@ -151,7 +192,8 @@ This method is used as follows. me = ladok.get_student("de709f81-a867-11e7-8dbf-78e86dc2470c") dasak = me.courses(code="DD2395")[0] -results = ladok.registrations_JSON(dasak.education_id, me.ladok_id) +results = ladok.registrations_on_course_JSON(dasak.education_id, + me.ladok_id) ladok3.clean_data(results) print(json.dumps(results, indent=2)) @@ -498,7 +540,7 @@ response from the [[create_result_JSON]] method. This method is used as follows. \begin{pyblock}[apitest][numbers=left,firstnumber=last] attestants = ladok.result_attestants_JSON( - "a1ff1fda-881e-11eb-b9f5-10126f8746d1") + "d05c1e97-4c1e-11eb-8e41-bc743cd4482b") print(json.dumps(attestants[0], indent=2)) \end{pyblock} diff --git a/src/ladok3/data.nw b/src/ladok3/data.nw index f20ee2d..3e2558a 100644 --- a/src/ladok3/data.nw +++ b/src/ladok3/data.nw @@ -98,6 +98,7 @@ This yields \cref{GradeStatsAvg}. \stdoutpythontex + \section{The [[data]] subcommand}\label{DataCommand} This is a subcommand run as part of the [[ladok3.cli]] module. @@ -128,7 +129,10 @@ We add a subparser. We set it up to use the function [[command]]. <>= data_parser = parser.add_parser("data", - help="Returns course results data in CSV form") + help="Returns course results data in CSV form", + description=""" +Returns the results in CSV form for all first-time registered students. +""".strip()) data_parser.set_defaults(func=command) @ @@ -153,7 +157,6 @@ data_writer.writerow([ ]) for course_round in course_rounds: data = extract_data_for_round(ladok, course_round) - data = clean_data(data) for student, component, grade, time in data: data_writer.writerow( @@ -175,18 +178,27 @@ def extract_data_for_round(ladok, course_round): <> <> - for result in results: - student = result["Student"]["Uid"] + for student in course_round.participants(): + student_results = filter_student_results(student, results) - for component_result in result["ResultatPaUtbildningar"]: - if component_result["HarTillgodoraknande"]: - continue + <> - <> - <> - <> + if len(student_results) < 1: + for component in course_round.components(): + yield student, component, "-", None + continue - yield (student, component_code, grade, normalized_date) + for component in course_round.components(): + result_data = filter_component_result( + component, student_results[0]["ResultatPaUtbildningar"]) + + if result_data: + <> + else: + grade = "-" + normalized_date = None + + yield student, component, grade, normalized_date @ We need the start of the course and the length to be able to normalize the @@ -206,6 +218,31 @@ results = ladok.search_reported_results_JSON( course_round.round_id, component.instance_id) @ +Now, we don't iterate over these results. +We iterate over the students and the components of a course round. +LADOK doesn't report \enquote{none results}. +But we want to have a result showing that a student hasn't done anything, that +should affect the statistics. +Then we must search for a student's result in the batch of results we received +from LADOK. +<>= +def filter_student_results(student, results): + return list(filter( + lambda x: x["Student"]["Uid"] == student.ladok_id, + results)) +@ + +Similarly, we want to find the result for a particular component. +<>= +def filter_component_result(component, results): + for component_result in results: + <> + <> + return result_data + + return None +@ + Depending on whether the data is attested or not, we can get the actual grade and date from two different substructures: \enquote{Arbetsunderlag} are results in LADOK that have been entered, but not @@ -227,12 +264,9 @@ The [[course_round]] object allows us to do exactly that with the [[components]] method. We note that we can ignore the grade on the whole course, since that one is determined by the other components. -<>= -matching_component = course_round.components( - instance_id=result_data["UtbildningsinstansUID"]) -if len(matching_component) < 1: +<>= +if component.instance_id != result_data["UtbildningsinstansUID"]: continue -component_code = matching_component[0].code @ Finally, if there is a grade, we can extract the grade and compute the @@ -249,34 +283,57 @@ else: normalized_date = None @ +However, we don't want to include all students. +We check if a student should be included or not, the criteria are discussed in +\cref{WhoToInclude}. +<>= +if not should_include(ladok, student, course_round, student_results): + continue +@ + -\section{Clean the data} +\section{Which students to exclude}\label{WhoToInclude} We want to filter out some values from the data. -We only want to keep students who are registered on the course the first time. +We only want to keep students who are registered on the course the first time +and who doesn't have any credit transfer on the course. +<>= +def should_include(ladok, student, course_round, result): + """Returns True if student should be included, False if to be excluded""" + if is_reregistered(ladok, student.ladok_id, course_round): + return False + + if has_credit_transfer(result): + return False + + return True +@ + +A student should be counted on the first round they were registered on. +We check if a student is reregistered by checking if the course round is the +first round the student was registered on. <>= -def clean_data(data): - data = list(data) - students_to_remove = reregistered_students(data) - return remove_students(students_to_remove, data) +def is_reregistered(ladok, student_id, course): + """Check if the student is reregistered on the course round course.""" + registrations = ladok.registrations_on_course_JSON( + course.education_id, student_id) + registrations.sort( + key=lambda x: x["Utbildningsinformation"]["Studieperiod"]["Startdatum"]) + first_reg = registrations[0] + return first_reg["Utbildningsinformation"]["Utbildningstillfalleskod"] != \ + course.round_code @ -We approximate first time registrations with grades reported before the course -started. -Thus we remove any student who has a result before the course. -It would be more exact to remove students who are in fact reregistered in -LADOK, but we leave that for a future version. +If the student has a credit transfer for any part of the course, we should +exclude the student. <>= -def reregistered_students(data): - students = set() - for student, _, _, time in data: - if time and time < 0: - students.add(student) - return students - -def remove_students(students, data): - for row in data: - if row[0] not in students: - yield row +def has_credit_transfer(results): + """Returns True if there exists a credit tranfer among the results.""" + for result in results: + for component_result in result["ResultatPaUtbildningar"]: + if component_result["HarTillgodoraknande"]: + return True + + return False @ diff --git a/src/ladok3/ladok3.nw b/src/ladok3/ladok3.nw index dd30d73..78b2274 100644 --- a/src/ladok3/ladok3.nw +++ b/src/ladok3/ladok3.nw @@ -938,17 +938,27 @@ populate all study-related attributes that LADOK returns in one request. def __get_study_attributes(self): """Helper method to fetch study related attributes""" <> +@ +The [[courses]] method returns all registrations on courses. +This means that the same course can occur multiple times if the student has +reregistered. +We return the list sorted on the starting date, the newest course last. +(LADOK already seems to return them in sorted order, but we sort them anyway to +ensure that this is not just a coincidence.) +<>= def courses(self, /, **kwargs): """Returns a list of courses that the student is registered on. - Filtered based on keywords.""" + Filtered based on keywords. Sorted on start-date, most recent last.""" try: courses = self.__courses except: self.__get_study_attributes() courses = self.__courses - return filter_on_keys(courses, **kwargs) + courses = filter_on_keys(courses, **kwargs) + courses.sort(key=operator.attrgetter("start")) + return courses @ Then we can call the last method when we want to pull all attributes too (using @@ -956,21 +966,14 @@ the [[pull]] method). <>= self.__get_study_attributes() @ - <>= -# detta är egentligen kurstillfällen, inte kurser (ID-numret är alltså ett -# ID-nummer för ett kurstillfälle) -response = self.ladok.session.get( - url=self.ladok.base_gui_proxy_url+ - '/studiedeltagande/tillfallesdeltagande/kurstillfallesdeltagande/student/'+ - self.ladok_id, - headers=self.ladok.headers).json() +registrations = self.ladok.registrations_JSON(self.ladok_id) self.__courses = [] -for course in response['Tillfallesdeltaganden']: - if not course['Nuvarande'] or \ - 'Utbildningskod' not in course['Utbildningsinformation']: +for course in registrations: + # Only get courses, not programmes + if 'Utbildningskod' not in course['Utbildningsinformation']: continue self.__courses.append(CourseRegistration( @@ -1463,16 +1466,45 @@ possibly filter the results. def results(self, /, **kwargs): """Returns all students' results on the course""" try: - return filter_on_keys(self.__results, **kwargs) + self.__results except: self.__fetch_results() + return filter_on_keys(self.__results, **kwargs) @ To fetch the results from LADOK, we must do the following query. <>= def __fetch_results(self): - pass + raise NotImplementedError( + f"{type(self).__name__}.__fetch_results not implemented") +@ + + +\section{Participants for a course round} + +We want to get a list of participants for a course round, \ie a list of +[[Student]] objects. +<>= +def participants(self, /, **kwargs): + """Returns a Student object for each participant in the course.""" + try: + self.__participants + except: + self.__fetch_participants() + + return filter_on_keys(self.__participants, **kwargs) +@ + +When we fetch the participants, we don't create new [[Student]] objects. +We use the [[get_student]] method of the LADOK session object to fetch objects +from the cache if they already exist. +<>= +def __fetch_participants(self): + self.__participants = [] + for student in self.ladok.participants_JSON(self.round_id): + self.__participants.append( + self.ladok.get_student(student["Student"]["Uid"])) @ @@ -1493,6 +1525,7 @@ class CourseRegistration(CourseInstance): # ett Ladok-ID för kursomgången self.__round_id = kwargs.pop("UtbildningstillfalleUID") + self.__round_code = kwargs.pop("Utbildningstillfalleskod") dates = kwargs.pop("Studieperiod") self.__start = datetime.date.fromisoformat(dates["Startdatum"]) @@ -1503,6 +1536,11 @@ class CourseRegistration(CourseInstance): """Returns LADOK ID for the course round (kursomgång)""" return self.__round_id + @property + def round_code(self): + """Returns the human-readable round code (tillfälleskod)""" + return self.__round_code + @property def start(self): return self.__start @@ -1511,6 +1549,12 @@ class CourseRegistration(CourseInstance): def end(self): return self.__end + def __str__(self): + return f"{self.code} {self.round_code} ({self.start}--{self.end})" + + def __repr__(self): + return f"{self.code}:{self.round_code}:{self.start}--{self.end}" + def results(self, /, **kwargs): """Returns the student's results on the course, filtered on keywords""" try: