librus_apix.grades
This module defines functions and data classes for retrieving and managing grade-related data from the Librus API.
Classes: - Gpa: Represents the semestral grade for a specific semester and subject. - Grade: Represents a single grade entry with detailed information. - GradeDescriptive: Represents a descriptive grade entry with detailed information.
Functions: - get_grades: Fetches and returns the grades, semestral averages, and descriptive grades from Librus.
Usage:
from librus_apix.grades import get_grades
try:
# Fetch grades data
numeric_grades, average_grades, descriptive_grades = get_grades(client, sort_by="all")
# Process the grades data as required
...
except ArgumentError as e:
# Handle invalid argument error
...
except ParseError as e:
# Handle parse error
...
1""" 2This module defines functions and data classes for retrieving and managing grade-related data from the Librus API. 3 4Classes: 5 - Gpa: Represents the semestral grade for a specific semester and subject. 6 - Grade: Represents a single grade entry with detailed information. 7 - GradeDescriptive: Represents a descriptive grade entry with detailed information. 8 9Functions: 10 - get_grades: Fetches and returns the grades, semestral averages, and descriptive grades from Librus. 11 12Usage: 13```python 14from librus_apix.grades import get_grades 15 16try: 17 # Fetch grades data 18 numeric_grades, average_grades, descriptive_grades = get_grades(client, sort_by="all") 19 # Process the grades data as required 20 ... 21except ArgumentError as e: 22 # Handle invalid argument error 23 ... 24except ParseError as e: 25 # Handle parse error 26 ... 27``` 28""" 29 30import re 31from collections import defaultdict 32from dataclasses import dataclass 33from typing import DefaultDict, Dict, List, Tuple, Union 34 35from bs4 import BeautifulSoup, Tag 36 37import librus_apix.urls as urls 38from librus_apix.client import Client 39from librus_apix.exceptions import ArgumentError, ParseError 40from librus_apix.helpers import no_access_check 41 42 43@dataclass 44class Gpa: 45 """ 46 Represents the Semestral Grade for a specific semester and subject. 47 48 Attributes: 49 semester (int): The semester number (e.g., 1 for first semester, 2 for second semester). 50 gpa (float | str): The GPA value, which can be a float or a "-" string meaning it's empty. 51 subject (str): The subject for which the GPA is calculated. 52 """ 53 54 semester: int 55 gpa: float | str 56 subject: str 57 58 59@dataclass 60class Grade: 61 """ 62 Represents a single grade entry with detailed information. 63 64 Attributes: 65 title (str): The title of the grade. 66 grade (str): The grade string value (e.g., '2', '4+', etc.). 67 value (float | str): Property function. Returns calculated float of grade. (e.g., '4.5 for 4+', '2.75 for 3-'), 68 or the raw grade string if it isn't numeric (e.g., '85%', '85/100'). 69 counts (bool): Indicates whether the grade counts towards the GPA. 70 date (str): The date when the grade was given. 71 href (str): A URL suffix associated with the grade. 72 desc (str): A detailed description of the grade. 73 semester (int): The semester number (e.g., 1 for first semester, 2 for second semester). 74 category (str): The category of the grade (e.g., 'Homework', 'Exam'). 75 teacher (str): The name of the teacher who gave the grade. 76 weight (int): The weight of the grade in calculating the final score. 77 """ 78 79 title: str 80 grade: str 81 counts: bool 82 date: str 83 href: str 84 desc: str 85 semester: int 86 category: str 87 teacher: str 88 weight: int 89 90 @property 91 def value(self) -> Union[float, str]: 92 """ 93 Calculates and returns the numeric value of the grade based on its string representation. 94 95 Returns: 96 Union[float, str]: The numeric value of the grade, a string indicating it doesn't count, 97 or the raw grade string if it can't be converted (ex. '85%', '85/100', 'A+'). 98 """ 99 if self.counts is False: 100 return "Does not count" 101 modified = re.fullmatch(r"(\d)([+-])", self.grade) 102 if modified is not None: 103 base, modifier = modified.groups() 104 return float(base) + (0.5 if modifier == "+" else -0.25) 105 try: 106 return float(self.grade.replace(",", ".")) 107 except ValueError: 108 return self.grade 109 110 111@dataclass 112class GradeDescriptive: 113 title: str 114 grade: str 115 date: str 116 href: str 117 desc: str 118 semester: int 119 teacher: str 120 121 122def get_grades(client: Client, sort_by: str = "all") -> Tuple[ 123 List[DefaultDict[str, List[Grade]]], 124 DefaultDict[str, List[Gpa]], 125 List[DefaultDict[str, List[GradeDescriptive]]], 126]: 127 """ 128 Fetches and returns the grades, semestral averages and descriptive grades from librus. 129 130 Args: 131 client (Client): The client object used to interact with the server. 132 sort_by (str): The criteria to sort grades. Can be 'all', 'week', or 'last_login'. 133 134 Returns: 135 Tuple: A tuple containing lists of numeric and descriptive grades, and GPA information. 136 137 Raises: 138 ArgumentError: If an invalid sort_by value is provided. 139 ParseError: If there is an error in parsing the grades. 140 """ 141 SORT = { 142 "all": "zmiany_logowanie_wszystkie", 143 "week": "zmiany_logowanie_tydzien", 144 "last_login": "zmiany_logowanie", 145 } 146 if sort_by not in SORT.keys(): 147 raise ArgumentError( 148 "Wrong value for sort_by it can be either all, week or last_login" 149 ) 150 151 soup = no_access_check( 152 BeautifulSoup( 153 client.post(client.GRADES_URL, data={SORT[sort_by]: "1"}).text, 154 "lxml", 155 ) 156 ) 157 tr = soup.find_all("tr", attrs={"class": ["line0", "line1"], "id": None}) 158 if len(tr) < 1: 159 raise ParseError("Error in parsing grades") 160 161 sem_grades, avg_grades = _extract_grades_numeric(tr) 162 sem_grades_desc = _extract_grades_descriptive(tr) 163 164 # New descriptive grading schema: the page contains only an empty table, 165 # grades are loaded by JavaScript from the gateway API. 166 if soup.select_one("table.newSchemaGradesByStudentTable") is not None: 167 try: 168 api_grades_desc = _extract_grades_descriptive_from_api( 169 client, soup.select("table.newSchemaGradesByStudentTable tr.studentRow") 170 ) 171 except Exception: 172 # optional data - never break get_grades because of the API 173 api_grades_desc = [] 174 for sem_index, semester in enumerate(api_grades_desc): 175 for subject, grades in semester.items(): 176 if not sem_grades_desc[sem_index][subject]: 177 sem_grades_desc[sem_index][subject] = grades 178 179 return sem_grades, avg_grades, sem_grades_desc 180 181 182def _handle_subject(semester_grades) -> str: 183 return semester_grades[0].text.replace("\n", "").strip() 184 185 186def _get_desc_and_counts(a: Tag, grade: str, subject: str) -> Tuple[str, bool]: 187 desc = f"Ocena: {grade}\nPrzedmiot: {subject}\n" 188 desc += re.sub( 189 r"<br*>", 190 "\n", 191 a.attrs.get("title", "").replace("<br/>", "").replace("<br />", "\n"), 192 ) 193 gpacount = re.search("Licz do średniej: [a-zA-Z]{3}", desc) 194 counts = False 195 if gpacount is not None: 196 pair = gpacount[0].split(": ") 197 if len(pair) >= 2 and pair[1] == "tak": 198 counts = True 199 return desc, counts 200 201 202def _extract_grade_info( 203 a: Tag, subject: str 204) -> Tuple[str, str, str, str, bool, str, str, int]: 205 date = re.search("Data:.{11}", a.attrs.get("title", "")) 206 if date is None: 207 raise ParseError("Error in getting grade's date.") 208 209 attr_dict = {} 210 for attr in a.attrs["title"].replace("<br/>", "<br>").split("<br>"): 211 if len(attr.strip()) >= 2: 212 key, value = attr.split(": ", 1) 213 attr_dict[key] = value 214 category: str = attr_dict.get("Kategoria", "") 215 teacher: str = attr_dict.get("Nauczyciel", "") 216 weight: int = int(attr_dict.get("Waga", 0)) 217 218 grade = a.text.replace("\xa0", "").replace("\n", "") 219 desc, counts = _get_desc_and_counts(a, grade, subject) 220 date = date.group().split(" ") 221 date = date[1] if len(date) >= 2 else " ".join(date) 222 return ( 223 grade, 224 date, 225 a.attrs.get("href", ""), 226 desc, 227 counts, 228 category, 229 teacher, 230 weight, 231 ) 232 233 234def _extract_grades_numeric( 235 table_rows: List[Tag], 236) -> Tuple[List[DefaultDict[str, List[Grade]]], DefaultDict[str, List[Gpa]]]: 237 # list containing two dicts (for each semester) 238 # key of each semester dict is subject, in each subject there is list of grades 239 sem_grades: List[DefaultDict[str, List[Grade]]] = [ 240 defaultdict(list) for _ in range(2) 241 ] # 2 semesters 242 avg_grades: DefaultDict[str, List[Gpa]] = defaultdict(list) 243 244 for box in table_rows: 245 if box.select_one("td[class='center micro screen-only']") is None: 246 # row without grade data - skip 247 continue 248 semester_grades = box.select('td:not([class="center micro screen-only"])') 249 if len(semester_grades) < 9: 250 continue 251 average_grades = list(map(lambda x: x.text, box.select("td.right"))) 252 semesters = [semester_grades[1:4], semester_grades[4:7]] 253 subject = _handle_subject(semester_grades) 254 for semester_number, semester in enumerate(semesters): 255 if subject not in sem_grades[semester_number]: 256 sem_grades[semester_number][subject] = [] 257 for sg in semester: 258 grade_a_improved = sg.select( 259 "td:not([class='center']) > span > span.grade-box > a" 260 ) 261 grade_a = ( 262 sg.select("td:not([class='center']) > span.grade-box > a") 263 + grade_a_improved 264 ) 265 for a in grade_a: 266 ( 267 _grade, 268 date, 269 _href, 270 desc, 271 counts, 272 category, 273 teacher, 274 weight, 275 ) = _extract_grade_info(a, subject) 276 g = Grade( 277 subject, 278 _grade, 279 counts, 280 date, 281 a.attrs.get("href", ""), 282 desc, 283 semester_number + 1, 284 category, 285 teacher, 286 weight, 287 ) 288 sem_grades[semester_number][subject].append(g) 289 avg_gr = ( 290 average_grades[semester_number] 291 if len(average_grades) > semester_number 292 else 0.0 293 ) # might happen that the list is empty 294 gpa = Gpa(semester_number + 1, avg_gr, subject) 295 avg_grades[subject].append(gpa) 296 avg_gr = ( 297 average_grades[-1] if len(average_grades) > 0 else 0.0 298 ) # might happen that the list is empty 299 avg_grades[subject].append(Gpa(0, avg_gr, subject)) 300 301 return sem_grades, avg_grades 302 303 304def _extract_grades_descriptive( 305 table_rows: List[Tag], 306) -> List[DefaultDict[str, List[GradeDescriptive]]]: 307 # list containing two dicts (for each semester) 308 # key of each semester dict is subject, in each subject there is list of grades 309 sem_grades_desc: List[DefaultDict[str, List[GradeDescriptive]]] = [ 310 defaultdict(list) for _ in range(2) 311 ] # 2 semesters 312 313 for box in table_rows: 314 if box.select_one("td[class='micro center screen-only']") is None: 315 # row without descriptive grade data - skip 316 continue 317 semester_grades = box.select('td:not([class="micro center screen-only"])') 318 if len(semester_grades) < 3: 319 continue 320 semesters = [semester_grades[1], semester_grades[2]] 321 subject = semester_grades[0].text.replace("\n", "").strip() 322 for sem_index, sg in enumerate(semesters): 323 grade_elements = sg.select( 324 "td:not([class='center']) > span.grade-box > a, " 325 "span.grade-box > span.ocena" 326 ) 327 for element in grade_elements: 328 grade_tag = ( 329 element.parent 330 if element.name == "span" and "ocena" in element.get("class", []) 331 else element 332 ) 333 ( 334 _grade, 335 date, 336 href, 337 desc, 338 _, 339 _category, 340 teacher, 341 _weight, 342 ) = _extract_grade_info(grade_tag, subject) 343 if "javascript" in href: 344 # javascript content is not standard href - clear it 345 href = "" 346 g = GradeDescriptive( 347 subject, _grade, date, href, desc, sem_index + 1, teacher 348 ) 349 sem_grades_desc[sem_index][subject].append(g) 350 351 # get semester descriptive grade 352 found_grade = False 353 summary_title = "" 354 summary_desc = "" 355 summary_date = "" 356 summary_teacher = "" 357 358 parse_next_row = False 359 for box in table_rows: 360 if parse_next_row: 361 parse_next_row = False 362 paragraphs = box.find_all("p") 363 text_list = [par.text.strip() for par in paragraphs] 364 summary_desc = "\n".join(text_list).strip() 365 found_grade = True 366 # description found - break 367 # There is no more grades for now (for first semester). Maybe there will be grade for 368 # second semester, but the format (structure) of web page is unknown for the moment. 369 # #TODO: implement the case for second semester (in future) 370 break 371 372 header = box.select_one("th") 373 if header and header.select_one("strong") is not None: 374 # header row found - next row will contain the description 375 parse_next_row = True 376 title_tag = header.select_one("strong") 377 if title_tag is None: 378 continue 379 info = title_tag.next_sibling 380 if info is None: 381 continue 382 summary_title = title_tag.text.strip() 383 summary_date = re.findall(r"opublikowano: (.+?) ", info.text)[ 384 0 385 ] # get date only 386 summary_teacher = re.findall(r"nauczyciel: (.+?)\)", info.text)[0] 387 388 if found_grade: 389 sem_index = 0 390 semester_summary = GradeDescriptive( 391 summary_title, 392 "", 393 summary_date, 394 "", 395 summary_desc, 396 sem_index + 1, 397 summary_teacher, 398 ) 399 if summary_title not in sem_grades_desc[sem_index]: 400 sem_grades_desc[sem_index][summary_title] = [] 401 sem_grades_desc[sem_index][summary_title].append(semester_summary) 402 403 return sem_grades_desc 404 405 406def _extract_grades_descriptive_from_api( 407 client: Client, subject_rows: List[Tag] 408) -> List[DefaultDict[str, List[GradeDescriptive]]]: 409 """ 410 Fetches descriptive grades of the new schema the same way Librus' JavaScript 411 (gradesByStudent.js) does: resolve the student identifier, then 412 POST /gateway/api/2.0/Auth/DescriptiveGradingSystem/PartialGrades/Student/{id}. 413 """ 414 sem_grades_desc: List[DefaultDict[str, List[GradeDescriptive]]] = [ 415 defaultdict(list) for _ in range(2) 416 ] 417 client.refresh_oauth() # sets the oauth_token cookie required by the gateway API 418 419 user_id = client.get(urls.GATEWAY_API_TOKEN_INFO).json()["UserIdentifier"] 420 user_info = client.get(urls.GATEWAY_API_USER_INFO + user_id).json() 421 student_id = user_info.get("IdentifierOfStudentAssignedWithUser") or user_id 422 423 response = client.post_json(urls.GATEWAY_API_DESCRIPTIVE_GRADES + student_id) 424 response.raise_for_status() 425 items = response.json().get("data") or [] 426 if not items: 427 return sem_grades_desc 428 429 # grade -> subject: identifier -> numericIdentifier (data-subject_id on the page) 430 subjects: Dict[str, Dict] = { 431 s["identifier"]: s for s in client.get(urls.GATEWAY_API_SUBJECTS).json()["data"] 432 } 433 teachers: Dict[str, str] = { 434 u["AccountId"]: f"{u['LastName']} {u['FirstName'] or ''}".strip() 435 for u in client.get(urls.GATEWAY_API_USERS).json()["Users"] 436 } 437 subject_names = { 438 row["data-subject_id"]: row.find_all("td")[1].text.strip() for row in subject_rows 439 } 440 441 for item in items: 442 semester = int(item["semester"]) 443 if semester not in (1, 2): 444 continue 445 subject = subjects.get(item["subjectId"], {}) 446 title = ( 447 subject_names.get(str(subject.get("numericIdentifier"))) 448 or subject.get("name") 449 or item["subjectId"] 450 ) 451 grade = (item.get("scaleValue") or {}).get("value") or "OO" 452 teacher = teachers.get(item.get("teacherId"), "") 453 date = item.get("date", "")[:10] 454 desc = ( 455 f"Ocena: {grade}\n" 456 f"Przedmiot: {title}\n" 457 f"Obszar: {(item.get('area') or {}).get('name', '')}\n" 458 f"Data: {date}\n" 459 f"Nauczyciel: {teacher}\n" 460 f"Treść oceny: {item.get('content') or ''}\n" 461 f"Dodał: {teachers.get(item.get('addedBy'), '')}\n" 462 f"Komentarz: {item.get('comments') or ''}\n" 463 ) 464 sem_grades_desc[semester - 1][title].append( 465 GradeDescriptive(title, grade, date, "", desc, semester, teacher) 466 ) 467 return sem_grades_desc
44@dataclass 45class Gpa: 46 """ 47 Represents the Semestral Grade for a specific semester and subject. 48 49 Attributes: 50 semester (int): The semester number (e.g., 1 for first semester, 2 for second semester). 51 gpa (float | str): The GPA value, which can be a float or a "-" string meaning it's empty. 52 subject (str): The subject for which the GPA is calculated. 53 """ 54 55 semester: int 56 gpa: float | str 57 subject: str
Represents the Semestral Grade for a specific semester and subject.
Attributes: semester (int): The semester number (e.g., 1 for first semester, 2 for second semester). gpa (float | str): The GPA value, which can be a float or a "-" string meaning it's empty. subject (str): The subject for which the GPA is calculated.
60@dataclass 61class Grade: 62 """ 63 Represents a single grade entry with detailed information. 64 65 Attributes: 66 title (str): The title of the grade. 67 grade (str): The grade string value (e.g., '2', '4+', etc.). 68 value (float | str): Property function. Returns calculated float of grade. (e.g., '4.5 for 4+', '2.75 for 3-'), 69 or the raw grade string if it isn't numeric (e.g., '85%', '85/100'). 70 counts (bool): Indicates whether the grade counts towards the GPA. 71 date (str): The date when the grade was given. 72 href (str): A URL suffix associated with the grade. 73 desc (str): A detailed description of the grade. 74 semester (int): The semester number (e.g., 1 for first semester, 2 for second semester). 75 category (str): The category of the grade (e.g., 'Homework', 'Exam'). 76 teacher (str): The name of the teacher who gave the grade. 77 weight (int): The weight of the grade in calculating the final score. 78 """ 79 80 title: str 81 grade: str 82 counts: bool 83 date: str 84 href: str 85 desc: str 86 semester: int 87 category: str 88 teacher: str 89 weight: int 90 91 @property 92 def value(self) -> Union[float, str]: 93 """ 94 Calculates and returns the numeric value of the grade based on its string representation. 95 96 Returns: 97 Union[float, str]: The numeric value of the grade, a string indicating it doesn't count, 98 or the raw grade string if it can't be converted (ex. '85%', '85/100', 'A+'). 99 """ 100 if self.counts is False: 101 return "Does not count" 102 modified = re.fullmatch(r"(\d)([+-])", self.grade) 103 if modified is not None: 104 base, modifier = modified.groups() 105 return float(base) + (0.5 if modifier == "+" else -0.25) 106 try: 107 return float(self.grade.replace(",", ".")) 108 except ValueError: 109 return self.grade
Represents a single grade entry with detailed information.
Attributes: title (str): The title of the grade. grade (str): The grade string value (e.g., '2', '4+', etc.). value (float | str): Property function. Returns calculated float of grade. (e.g., '4.5 for 4+', '2.75 for 3-'), or the raw grade string if it isn't numeric (e.g., '85%', '85/100'). counts (bool): Indicates whether the grade counts towards the GPA. date (str): The date when the grade was given. href (str): A URL suffix associated with the grade. desc (str): A detailed description of the grade. semester (int): The semester number (e.g., 1 for first semester, 2 for second semester). category (str): The category of the grade (e.g., 'Homework', 'Exam'). teacher (str): The name of the teacher who gave the grade. weight (int): The weight of the grade in calculating the final score.
91 @property 92 def value(self) -> Union[float, str]: 93 """ 94 Calculates and returns the numeric value of the grade based on its string representation. 95 96 Returns: 97 Union[float, str]: The numeric value of the grade, a string indicating it doesn't count, 98 or the raw grade string if it can't be converted (ex. '85%', '85/100', 'A+'). 99 """ 100 if self.counts is False: 101 return "Does not count" 102 modified = re.fullmatch(r"(\d)([+-])", self.grade) 103 if modified is not None: 104 base, modifier = modified.groups() 105 return float(base) + (0.5 if modifier == "+" else -0.25) 106 try: 107 return float(self.grade.replace(",", ".")) 108 except ValueError: 109 return self.grade
Calculates and returns the numeric value of the grade based on its string representation.
Returns: Union[float, str]: The numeric value of the grade, a string indicating it doesn't count, or the raw grade string if it can't be converted (ex. '85%', '85/100', 'A+').
112@dataclass 113class GradeDescriptive: 114 title: str 115 grade: str 116 date: str 117 href: str 118 desc: str 119 semester: int 120 teacher: str
123def get_grades(client: Client, sort_by: str = "all") -> Tuple[ 124 List[DefaultDict[str, List[Grade]]], 125 DefaultDict[str, List[Gpa]], 126 List[DefaultDict[str, List[GradeDescriptive]]], 127]: 128 """ 129 Fetches and returns the grades, semestral averages and descriptive grades from librus. 130 131 Args: 132 client (Client): The client object used to interact with the server. 133 sort_by (str): The criteria to sort grades. Can be 'all', 'week', or 'last_login'. 134 135 Returns: 136 Tuple: A tuple containing lists of numeric and descriptive grades, and GPA information. 137 138 Raises: 139 ArgumentError: If an invalid sort_by value is provided. 140 ParseError: If there is an error in parsing the grades. 141 """ 142 SORT = { 143 "all": "zmiany_logowanie_wszystkie", 144 "week": "zmiany_logowanie_tydzien", 145 "last_login": "zmiany_logowanie", 146 } 147 if sort_by not in SORT.keys(): 148 raise ArgumentError( 149 "Wrong value for sort_by it can be either all, week or last_login" 150 ) 151 152 soup = no_access_check( 153 BeautifulSoup( 154 client.post(client.GRADES_URL, data={SORT[sort_by]: "1"}).text, 155 "lxml", 156 ) 157 ) 158 tr = soup.find_all("tr", attrs={"class": ["line0", "line1"], "id": None}) 159 if len(tr) < 1: 160 raise ParseError("Error in parsing grades") 161 162 sem_grades, avg_grades = _extract_grades_numeric(tr) 163 sem_grades_desc = _extract_grades_descriptive(tr) 164 165 # New descriptive grading schema: the page contains only an empty table, 166 # grades are loaded by JavaScript from the gateway API. 167 if soup.select_one("table.newSchemaGradesByStudentTable") is not None: 168 try: 169 api_grades_desc = _extract_grades_descriptive_from_api( 170 client, soup.select("table.newSchemaGradesByStudentTable tr.studentRow") 171 ) 172 except Exception: 173 # optional data - never break get_grades because of the API 174 api_grades_desc = [] 175 for sem_index, semester in enumerate(api_grades_desc): 176 for subject, grades in semester.items(): 177 if not sem_grades_desc[sem_index][subject]: 178 sem_grades_desc[sem_index][subject] = grades 179 180 return sem_grades, avg_grades, sem_grades_desc
Fetches and returns the grades, semestral averages and descriptive grades from librus.
Args: client (Client): The client object used to interact with the server. sort_by (str): The criteria to sort grades. Can be 'all', 'week', or 'last_login'.
Returns: Tuple: A tuple containing lists of numeric and descriptive grades, and GPA information.
Raises: ArgumentError: If an invalid sort_by value is provided. ParseError: If there is an error in parsing the grades.