diff --git a/edupage_api/__init__.py b/edupage_api/__init__.py index 6edf99f..6f5c7b0 100644 --- a/edupage_api/__init__.py +++ b/edupage_api/__init__.py @@ -28,6 +28,12 @@ from edupage_api.substitution import Substitution, TimetableChange from edupage_api.timeline import TimelineEvent, TimelineEvents from edupage_api.timetables import Timetable, Timetables +from edupage_api.attendance import ( + Attendance, + AttendanceStatistic, + AttendenceStatDetail, + Arrival, +) class Edupage(EdupageModule): @@ -136,6 +142,54 @@ def get_subjects(self) -> Optional[list[Subject]]: return Subjects(self).get_subjects() + def get_attendance_statistics( + self, user_id: str, date: date + ) -> AttendanceStatistic: + """Get a count of excused/unexcused/absent/late (and more) lessons. + + Note: To get your own attendance, pass `edupage.get_user_id()` as `user_id` + + Args: + user_id (str): This user's attendance data will be in the result. + date (datetime.date): Date from which you want attendance statistics. + + Returns: + AttendanceStatistic: The fetched statistics + """ + + return Attendance(self).get_attendance_statistics(user_id, date) + + def get_days_with_available_attendance(self, user_id: str) -> list[date]: + """Get all dates where attendance data is available. + + Note: To get your data for your own attendance, pass `edupage.get_user_id()` as `user_id` + + Args: + user_id (str): This user's available attendence dates will be in the result. + + Result: + list[datetime.date]: A list of all dates where attendence data is available. + """ + + return Attendance(self).get_days_with_available_attendance(user_id) + + def get_arrivals(self, user_id: str) -> dict[str, Arrival]: + """Get arrival and departure times for all days returned by `Edupage.get_days_with_available_attendance`. + + Note: To get your own arrivals, pass `edupage.get_user_id()` as `user_id` + Note: Only returns data when available. There may not be an entry for each day returned by `Edupage.get_days_with_available_attendance`. + + Args: + user_id (str): This user's arrivals dates will be in the result. + + Result: + dict[str, Arrival]: The keys of the dict are dates of the arrival in "YYYY-mm-dd" format (%Y-%m-%d). Contains all available arrival and departure data. + + Note: You don't have to rely on the keys of the dictionary, there is also a `Arrival.date` property. + """ + + return Attendance(self).get_arrivals(user_id) + def send_message( self, recipients: Union[list[EduAccount], EduAccount], body: str ) -> int: diff --git a/edupage_api/attendance.py b/edupage_api/attendance.py new file mode 100644 index 0000000..812668d --- /dev/null +++ b/edupage_api/attendance.py @@ -0,0 +1,248 @@ +import json + +from dataclasses import dataclass +from datetime import date, datetime +from typing import Optional + +from edupage_api.module import Module, ModuleHelper +from edupage_api.exceptions import ( + MissingDataException, + InsufficientPermissionsException, +) + + +@dataclass +class AttendenceStatDetail: + count: float + excused: float + unexcused: float + + +@dataclass +class AttendanceStatistic: + total_lessons_absent: AttendenceStatDetail + total_late_minutes: AttendenceStatDetail + total_early_minutes: AttendenceStatDetail + + theory_lessons_total: AttendenceStatDetail + theory_lessons_absent: AttendenceStatDetail + theory_late_minutes: AttendenceStatDetail + theory_early_minutes: AttendenceStatDetail + + training_lessons_total: AttendenceStatDetail + training_lessons_absent: AttendenceStatDetail + training_late_minutes: AttendenceStatDetail + training_early_minutes: AttendenceStatDetail + + late_count: float + early_count: float + present_lessons_count: float + distant_lessons_count: float + + # TODO (in the future): fields: "todo", "error", "sa_dontcount" - what do they mean? + + +@dataclass +class Arrival: + date: date + arrival: Optional[datetime] + departure: Optional[datetime] + + +class Attendance(Module): + def __get_attendance_data(self, user_id: str): + request_url = f"https://{self.edupage.subdomain}.edupage.org/dashboard/eb.php" + params = { + "ebuid": user_id, + "mode": "attendance", + } + + response = self.edupage.session.get(request_url, params=params) + response_html = response.text + + user_id_number = Attendance.__get_user_id_number(user_id) + try: + data = response_html.split( + 'ASC.requireAsync("/dashboard/dochadzka.js#initZiak").then' + )[1][37:].split(f",[{user_id_number}],true);")[0] + + return json.loads(data) + except IndexError: + raise MissingDataException( + 'Unexpected response from attendance endpoint! (expected string `ASC.requireAsync("/dashboard/dochadzka.js#initZiak").then` to be in the response)' + ) + + @staticmethod + def __get_user_id_number(user_id: str): + return user_id.replace("Student", "").replace("Ucitel", "").replace("-", "") + + def get_days_with_available_attendance(self, user_id: str) -> list[date]: + user_id_number = Attendance.__get_user_id_number( + self.edupage.get_user_id() # pyright: ignore[reportAttributeAccessIssue] + ) + + target_user_id_number = Attendance.__get_user_id_number(user_id) + attendance_data = self.__get_attendance_data(user_id_number) + + stats = attendance_data["dateStats"] + if target_user_id_number not in stats: + raise InsufficientPermissionsException( + "The requested user's data is not available in the response." + ) + + return [ + datetime.strptime("%Y-%m-%d", d).date() + for d in stats[target_user_id_number].keys() + ] + + @ModuleHelper.logged_in + def get_arrivals(self, user_id: str) -> dict[str, Arrival]: + user_id_number = Attendance.__get_user_id_number( + self.edupage.get_user_id() # pyright: ignore[reportAttributeAccessIssue] + ) + + target_user_id_number = Attendance.__get_user_id_number(user_id) + + attendance_data = self.__get_attendance_data(user_id_number) + detailed_stats = attendance_data["students"].get(target_user_id_number) + if detailed_stats is None: + raise InsufficientPermissionsException( + "The requested user's data is not available in the response." + ) + + arrivals = {} + for raw_date, arrival_data in detailed_stats.items(): + if "prichod" not in arrival_data and "odchod" not in arrival_data: + continue + + parsed_date = datetime.strptime(raw_date, "%Y-%m-%d").date() + + arrival_time = ( + datetime.strptime( + arrival_data.get("prichod"), + "%H:%M:%S", + ).replace( + year=parsed_date.year, month=parsed_date.month, day=parsed_date.day + ) + if arrival_data.get("prichod") is not None + else None + ) + + departure_time = ( + datetime.strptime( + arrival_data.get("odchod"), + "%H:%M:%S", + ).replace( + year=parsed_date.year, month=parsed_date.month, day=parsed_date.day + ) + if arrival_data.get("odchod") is not None + else None + ) + + arrivals[raw_date] = Arrival(parsed_date, arrival_time, departure_time) + + return arrivals + + @ModuleHelper.logged_in + def get_attendance_statistics(self, user_id: str, date: date): + user_id_number = Attendance.__get_user_id_number( + self.edupage.get_user_id() # pyright: ignore[reportAttributeAccessIssue] + ) + + target_user_id_number = Attendance.__get_user_id_number(user_id) + + attendance_data = self.__get_attendance_data(user_id_number) + + all_stats = attendance_data["dateStats"] + + user_stats = all_stats.get(target_user_id_number) + if user_stats is None: + raise InsufficientPermissionsException( + "The requested user's data is not available in the response." + ) + + stats = user_stats[date.strftime("%Y-%m-%d")] + + total_lessons_absent = AttendenceStatDetail( + count=stats.get("absent"), + excused=stats.get("excused"), + unexcused=stats.get("unexcused"), + ) + + total_late_minutes = AttendenceStatDetail( + count=stats.get("late_minutes"), + excused=stats.get("late_excused_auto"), + unexcused=stats.get("late_unexcused_auto"), + ) + + total_early_minutes = AttendenceStatDetail( + count=stats.get("early_minutes"), + excused=stats.get("early_excused_auto"), + unexcused=stats.get("early_unexcused_auto"), + ) + + theory_lessons_total = AttendenceStatDetail( + count=stats.get("teoria_absent"), + excused=stats.get("teoria_excused"), + unexcused=stats.get("teoria_unexcused"), + ) + + theory_lessons_absent = AttendenceStatDetail( + count=stats.get("teoria_absent_only"), + excused=stats.get("teoria_absent_excused"), + unexcused=stats.get("teoria_absent_unexcused"), + ) + + theory_early_minutes = AttendenceStatDetail( + count=stats.get("teoria_early"), + excused=stats.get("teoria_early_excused"), + unexcused=stats.get("teoria_early_unexcused"), + ) + + theory_late_minutes = AttendenceStatDetail( + count=stats.get("teoria_late"), + excused=stats.get("teoria_late_excused"), + unexcused=stats.get("teoria_late_unexcused"), + ) + + training_lessons_total = AttendenceStatDetail( + count=stats.get("prax_absent"), + excused=stats.get("prax_excused"), + unexcused=stats.get("prax_unexcused"), + ) + + training_lessons_absent = AttendenceStatDetail( + count=stats.get("prax_absent_only"), + excused=stats.get("prax_absent_excused"), + unexcused=stats.get("prax_absent_unexcused"), + ) + + training_early_minutes = AttendenceStatDetail( + count=stats.get("prax_early"), + excused=stats.get("prax_early_excused"), + unexcused=stats.get("prax_early_unexcused"), + ) + + training_late_minutes = AttendenceStatDetail( + count=stats.get("prax_late"), + excused=stats.get("prax_late_excused"), + unexcused=stats.get("prax_late_unexcused"), + ) + + return AttendanceStatistic( + total_lessons_absent, + total_late_minutes, + total_early_minutes, + theory_lessons_total, + theory_lessons_absent, + theory_late_minutes, + theory_early_minutes, + training_lessons_total, + training_lessons_absent, + training_late_minutes, + training_early_minutes, + late_count=stats.get("late"), + early_count=stats.get("early"), + present_lessons_count=stats.get("present"), + distant_lessons_count=stats.get("distant"), + )