From b06d08455fb70489db70542d9601f3e9a4495d68 Mon Sep 17 00:00:00 2001 From: Antoni-Czaplicki <56671347+Antoni-Czaplicki@users.noreply.github.com> Date: Wed, 10 Jul 2024 20:48:24 +0200 Subject: [PATCH] Implement fetching grades, terms, courses and api documentation --- docs/full-docs.rst | 6 ++ run.py | 45 ++++++++++++ usos_api/client.py | 11 +++ usos_api/connection.py | 1 - usos_api/helper.py | 54 ++++++++++++++ usos_api/models/__init__.py | 27 ++++++- usos_api/models/api_documentation.py | 68 ++++++++++++++++++ usos_api/models/consumer.py | 5 +- usos_api/models/course.py | 45 +++++++----- usos_api/models/grade.py | 32 ++++++--- usos_api/models/group.py | 6 +- usos_api/models/term.py | 7 ++ usos_api/services/__init__.py | 14 +++- usos_api/services/api_documentation.py | 69 ++++++++++++++++++ usos_api/services/api_server.py | 4 +- usos_api/services/courses.py | 52 ++++++++++++++ usos_api/services/grades.py | 99 ++++++++++++++++++++++++++ usos_api/services/groups.py | 8 +-- usos_api/services/terms.py | 39 ++++++++++ 19 files changed, 546 insertions(+), 46 deletions(-) create mode 100644 usos_api/helper.py create mode 100644 usos_api/models/api_documentation.py create mode 100644 usos_api/services/api_documentation.py create mode 100644 usos_api/services/courses.py create mode 100644 usos_api/services/grades.py create mode 100644 usos_api/services/terms.py diff --git a/docs/full-docs.rst b/docs/full-docs.rst index 79fbd71..150a859 100644 --- a/docs/full-docs.rst +++ b/docs/full-docs.rst @@ -39,3 +39,9 @@ Services :members: +Helper +^^^^^^ + +.. autoclass:: usos_api.helper.APIHelper + :members: + :show-inheritance: \ No newline at end of file diff --git a/run.py b/run.py index 3e047bf..567b153 100644 --- a/run.py +++ b/run.py @@ -1,6 +1,7 @@ """ Example of fetching user data from the USOS API. """ import asyncio +import json import os from dotenv import load_dotenv @@ -40,6 +41,7 @@ async def fetch_data(api_base_address: str, consumer_key: str, consumer_secret: ongoing_terms_only=True ) print(groups) + print(await client.helper.get_user_end_grades_with_weights()) except USOSAPIException as e: print(f"Error fetching data: {e}") finally: @@ -47,6 +49,47 @@ async def fetch_data(api_base_address: str, consumer_key: str, consumer_secret: await client.close() +async def fetch_documentation( + api_base_address: str, consumer_key: str, consumer_secret: str +): + async with USOSClient(api_base_address, consumer_key, consumer_secret) as client: + await client.load_access_token_from_file() + documentation_service = client.api_documentation_service + + method_index = await documentation_service.get_method_index() + modules = set() + + for method in method_index: + module = method.name.split("/")[1] + modules.add(module) + + modules_info = [] + for module in modules: + print(f"Fetching documentation for module {module}") + module_info = await documentation_service.get_module_info(module) + module_info = module_info.dict() + module_info["methods_info"] = {} + for method in module_info["methods"]: + print(f"Fetching documentation for method {method}") + method_info = await documentation_service.get_method_info(method) + module_info["methods_info"][method] = method_info.dict() + + modules_info.append(module_info) + + # Combine fetched data + documentation = { + "method_index": [method.dict() for method in method_index], + "modules": [module for module in modules_info], + } + + return documentation + + +async def save_documentation_to_file(file_path, documentation): + with open(file_path, "w") as file: + json.dump(documentation, file, indent=4) + + if __name__ == "__main__": api_base_address = os.environ.get("USOS_API_BASE_ADDRESS", DEFAULT_API_BASE_ADDRESS) consumer_key = os.environ.get("USOS_CONSUMER_KEY") @@ -58,3 +101,5 @@ async def fetch_data(api_base_address: str, consumer_key: str, consumer_secret: ) asyncio.run(fetch_data(api_base_address, consumer_key, consumer_secret)) + # documentation = asyncio.run(fetch_documentation(api_base_address, consumer_key, consumer_secret)) + # asyncio.run(save_documentation_to_file("usos_api_documentation.json", documentation)) diff --git a/usos_api/client.py b/usos_api/client.py index 7c9a2df..2bbc837 100644 --- a/usos_api/client.py +++ b/usos_api/client.py @@ -1,10 +1,15 @@ import json from .connection import USOSAPIConnection +from .helper import APIHelper from .logger import get_logger from .services import UserService +from .services.api_documentation import APIDocumentationService from .services.api_server import APIServerService +from .services.courses import CourseService +from .services.grades import GradeService from .services.groups import GroupService +from .services.terms import TermService _LOGGER = get_logger("USOSClient") @@ -35,7 +40,13 @@ def __init__( ) self.user_service = UserService(self.connection) self.group_service = GroupService(self.connection) + self.course_service = CourseService(self.connection) + self.term_service = TermService(self.connection) + self.grade_service = GradeService(self.connection) self.api_server_service = APIServerService(self.connection) + self.api_documentation_service = APIDocumentationService(self.connection) + + self.helper = APIHelper(self) async def __aenter__(self) -> "USOSClient": """ diff --git a/usos_api/connection.py b/usos_api/connection.py index 2ff4507..97cac7c 100644 --- a/usos_api/connection.py +++ b/usos_api/connection.py @@ -92,7 +92,6 @@ async def get(self, service: str, **kwargs) -> dict: url, headers, body = self.auth_manager.sign_request( "".join(url_parts), headers=headers ) - print(url, headers, body) async with self._session.get(url, params=kwargs, headers=headers) as response: await self._handle_response_errors(response) return await response.json() diff --git a/usos_api/helper.py b/usos_api/helper.py new file mode 100644 index 0000000..0d47839 --- /dev/null +++ b/usos_api/helper.py @@ -0,0 +1,54 @@ +from typing import TYPE_CHECKING, List + +from usos_api.models import CourseEdition, Grade + +if TYPE_CHECKING: + from usos_api import USOSClient + + +class APIHelper: + def __init__(self, client: "USOSClient"): + self.client = client + + async def get_user_end_grades_with_weights( + self, current_term_only: bool = False + ) -> List[Grade]: + """ + Get user end grades with weights. + + :param current_term_only: If True, only consider the current term. + :return: A list of user end grades with weights. + """ + ects_by_term = await self.client.course_service.get_user_courses_ects() + terms = await self.client.term_service.get_terms(list(ects_by_term.keys())) + term_ids = [ + term.id for term in terms if not current_term_only or term.is_current + ] + course_ids = [ + course_id + for term_id in term_ids + for course_id in ects_by_term[term_id].keys() + ] + + courses = { + course.id: course + for course in await self.client.course_service.get_courses(course_ids) + } + grades_by_term = await self.client.grade_service.get_grades_by_terms(term_ids) + + user_grades = [] + for term in terms: + if term.id not in term_ids: + continue + for course_id, ects in ects_by_term[term.id].items(): + grades = ( + grades_by_term[term.id].get(course_id, {}).get("course_grades", []) + ) + for grade in grades: + grade.weight = ects + grade.course_edition = CourseEdition( + course=courses[course_id], term=term + ) + user_grades.append(grade) + + return user_grades diff --git a/usos_api/models/__init__.py b/usos_api/models/__init__.py index 74e3510..ef6f148 100644 --- a/usos_api/models/__init__.py +++ b/usos_api/models/__init__.py @@ -1,5 +1,21 @@ +from .api_documentation import ( + APIMethodIndexItem, + APIMethodInfo, + APIModuleInfo, + Argument, + AuthOptions, + DeprecatedInfo, + ResultField, + ScopeInfo, +) from .consumer import Consumer -from .course import Course, CourseAttribute, CourseEdition, CourseEditionConducted +from .course import ( + Course, + CourseAttribute, + CourseEdition, + CourseEditionConducted, + CourseUnit, +) from .grade import Grade from .group import Group from .lang_dict import LangDict @@ -50,4 +66,13 @@ "CourseEditionConducted", "Term", "Consumer", + "CourseUnit", + "AuthOptions", + "Argument", + "ResultField", + "DeprecatedInfo", + "APIMethodInfo", + "APIMethodIndexItem", + "APIModuleInfo", + "ScopeInfo", ] diff --git a/usos_api/models/api_documentation.py b/usos_api/models/api_documentation.py new file mode 100644 index 0000000..e9a65a7 --- /dev/null +++ b/usos_api/models/api_documentation.py @@ -0,0 +1,68 @@ +from typing import Any + +from pydantic import BaseModel + + +class AuthOptions(BaseModel): + consumer: str | None = None + token: str | None = None + administrative_only: bool | None = None + ssl_required: bool | None = None + + +class Argument(BaseModel): + name: str | None = None + is_required: bool | None = None + is_deprecated: bool | None = None + default_value: Any | None = None + description: str | None = None + + +class ResultField(BaseModel): + name: str | None = None + description: str | None = None + is_primary: bool | None = None + is_secondary: bool | None = None + + +class DeprecatedInfo(BaseModel): + deprecated_by: str | None = None + present_until: str | None = None + + +class APIMethodInfo(BaseModel): + name: str | None = None + short_name: str | None = None + description: str | None = None + brief_description: str | None = None + ref_url: str | None = None + auth_options: AuthOptions | None = None + scopes: list[str] | None = None + arguments: list[Argument] | None = None + returns: str | None = None + errors: str | None = None + result_fields: list[ResultField] | None = None + beta: bool | None = None + deprecated: DeprecatedInfo | None = None + admin_access: bool | None = None + is_internal: bool | None = None + + +class APIMethodIndexItem(BaseModel): + name: str | None = None + brief_description: str | None = None + + +class APIModuleInfo(BaseModel): + name: str | None = None + title: str | None = None + brief_description: str | None = None + description: str | None = None + submodules: list[str] | None = None + methods: list[str] | None = None + beta: bool | None = None + + +class ScopeInfo(BaseModel): + key: str | None = None + developers_description: str | None = None diff --git a/usos_api/models/consumer.py b/usos_api/models/consumer.py index 0d8b731..d7a7cf1 100644 --- a/usos_api/models/consumer.py +++ b/usos_api/models/consumer.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import List from pydantic import BaseModel @@ -13,5 +12,5 @@ class Consumer(BaseModel): url: str | None = None email: str | None = None date_registered: datetime | None = None - administrative_methods: List[str] | None = None - token_scopes: List[str] | None = None + administrative_methods: list[str] | None = None + token_scopes: list[str] | None = None diff --git a/usos_api/models/course.py b/usos_api/models/course.py index 9ee671a..9ef1c2c 100644 --- a/usos_api/models/course.py +++ b/usos_api/models/course.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, Optional from pydantic import BaseModel @@ -6,9 +6,8 @@ from .term import Term if TYPE_CHECKING: - from .user import User + pass -from .grade import Grade from .lang_dict import LangDict @@ -18,7 +17,7 @@ class CourseAttribute(BaseModel): """ name: LangDict | None = None - values: List[LangDict] | None = None + values: list[LangDict] | None = None class Course(BaseModel): @@ -31,7 +30,7 @@ class Course(BaseModel): homepage_url: str | None = None profile_url: str | None = None is_currently_conducted: bool | None = None - terms: List[Term] | None = None + terms: list[Term] | None = None fac_id: str | None = None lang_id: str | None = None ects_credits_simplified: float | None = None @@ -40,8 +39,25 @@ class Course(BaseModel): learning_outcomes: LangDict | None = None assessment_criteria: LangDict | None = None practical_placement: LangDict | None = None - attributes: List[CourseAttribute] | None = None - attributes2: List[CourseAttribute] | None = None + attributes: list[CourseAttribute] | None = None + attributes2: list[CourseAttribute] | None = None + + +class CourseUnit(BaseModel): + """ + Class representing a course unit. + """ + + id: str + homepage_url: str | None = None + profile_url: str | None = None + learning_outcomes: LangDict | None = None + assessment_criteria: LangDict | None = None + topics: LangDict | None = None + teaching_methods: LangDict | None = None + bibliography: LangDict | None = None + course_edition: Optional["CourseEdition"] = None + class_groups: list[Group] | None = None class CourseEdition(BaseModel): @@ -49,22 +65,13 @@ class CourseEdition(BaseModel): Class representing a course edition. """ - course_id: str | None = None - course_name: LangDict | None = None - term_id: str | None = None + course: Course | None = None + term: Term | None = None homepage_url: str | None = None - profile_url: str | None = None - coordinators: List["User"] | None = None - lecturers: List["User"] | None = None - passing_status: str | None = None - user_groups: List[Group] | None = None description: LangDict | None = None bibliography: LangDict | None = None notes: LangDict | None = None - course_units_ids: List[str] | None = None - participants: List["User"] | None = None - grades: List[Grade] | None = None - attributes: List[CourseAttribute] | None = None + course_units: list[CourseUnit] | None = None class CourseEditionConducted(BaseModel): diff --git a/usos_api/models/grade.py b/usos_api/models/grade.py index f637514..5b5397c 100644 --- a/usos_api/models/grade.py +++ b/usos_api/models/grade.py @@ -1,7 +1,11 @@ -from typing import TYPE_CHECKING, Any, Dict, Optional +from datetime import datetime +from typing import TYPE_CHECKING, Any, Optional from pydantic import BaseModel +from . import CourseEdition +from .course import CourseUnit + if TYPE_CHECKING: from .user import User @@ -9,19 +13,31 @@ class Grade(BaseModel): + value: float | None = None value_symbol: str | None = None passes: bool | None = None value_description: LangDict | None = None - exam_id: str | None = None - exam_session_number: str | None = None + exam_id: int | None = None + exam_session_number: int | None = None counts_into_average: bool | None = None comment: str | None = None private_comment: str | None = None grade_type_id: str | None = None - date_modified: str | None = None - date_acquisition: str | None = None + date_modified: datetime | None = None + date_acquisition: datetime | None = None modification_author: str | None = None - course_edition: Dict[str, Any] | None = None - unit: Dict[str, Any] | None = None - exam_report: Dict[str, Any] | None = None + course_edition: CourseEdition | None = None + unit: CourseUnit | None = None + exam_report: dict[str, Any] | None = None user: Optional["User"] = None + weight: float | None = ( + None # Not returned by USOS API but is here to make it easier to work with grades + ) + + def __init__(self, **data: Any): + super().__init__(**data) + if self.value_symbol: + try: + self.value = float(self.value_symbol.replace(",", ".")) + except ValueError: + pass # Invalid value, ignore it diff --git a/usos_api/models/group.py b/usos_api/models/group.py index b3fdbd2..ed1902b 100644 --- a/usos_api/models/group.py +++ b/usos_api/models/group.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING from pydantic import BaseModel @@ -22,8 +22,8 @@ class Group(BaseModel): course_fac_id: str | None = None course_lang_id: str | None = None term_id: str | None = None - lecturers: List["User"] | None = None - participants: List["User"] | None = None + lecturers: list["User"] | None = None + participants: list["User"] | None = None group_description: LangDict | None = None group_literature: LangDict | None = None course_learning_outcomes: LangDict | None = None diff --git a/usos_api/models/term.py b/usos_api/models/term.py index cc6baeb..40018d0 100644 --- a/usos_api/models/term.py +++ b/usos_api/models/term.py @@ -12,3 +12,10 @@ class Term(BaseModel): end_date: date | None = None finish_date: date | None = None is_active: bool | None = None + + @property + def is_current(self) -> bool: + """ + Check if the term is currently active. + """ + return self.is_active and self.start_date <= date.today() <= self.finish_date diff --git a/usos_api/services/__init__.py b/usos_api/services/__init__.py index 313882c..7dafdf0 100644 --- a/usos_api/services/__init__.py +++ b/usos_api/services/__init__.py @@ -1,5 +1,17 @@ +from .api_documentation import APIDocumentationService from .api_server import APIServerService +from .courses import CourseService +from .grades import GradeService from .groups import GroupService +from .terms import TermService from .users import UserService -__all__ = ["UserService", "GroupService", "APIServerService"] +__all__ = [ + "APIDocumentationService", + "APIServerService", + "CourseService", + "GradeService", + "GroupService", + "TermService", + "UserService", +] diff --git a/usos_api/services/api_documentation.py b/usos_api/services/api_documentation.py new file mode 100644 index 0000000..f5d2b51 --- /dev/null +++ b/usos_api/services/api_documentation.py @@ -0,0 +1,69 @@ +from usos_api.models.api_documentation import ( + APIMethodIndexItem, + APIMethodInfo, + APIModuleInfo, + ScopeInfo, +) + + +class APIDocumentationService: + """ + A service for API documentation fetching. + """ + + def __init__(self, connection): + """ + Initialize the API documentation service. + + :param connection: The connection to use. + """ + self.connection = connection + + async def get_method_info( + self, method: str, fields: list[str] | None = None + ) -> APIMethodInfo: + """ + Get information about a specific method. + + :param method: The method to get information about. + :param fields: The fields to include in the response. + :return: Object representing the method. + """ + + if not method.startswith("services/"): + method = f"services/{method}" + + response = await self.connection.post( + "services/apiref/method", name=method, fields=fields + ) + return APIMethodInfo(**response) + + async def get_method_index(self) -> list[APIMethodIndexItem]: + """ + Get a list of API methods with brief descriptions. + :return: List of objects representing the methods. + """ + response = await self.connection.post("services/apiref/method_index") + return [APIMethodIndexItem(**item) for item in response] + + async def get_module_info(self, module_name: str) -> APIModuleInfo: + """ + Get information about a specific module. + + :param module_name: The name of the module. + :return: Object representing the module. + """ + if not module_name.startswith("services/"): + module_name = f"services/{module_name}" + response = await self.connection.post( + "services/apiref/module", name=module_name + ) + return APIModuleInfo(**response) + + async def get_scopes(self) -> list[ScopeInfo]: + """ + Get a list of all scopes available in the USOS API installation. + :return: List of scope information objects. + """ + response = await self.connection.post("services/apiref/scopes") + return [ScopeInfo(**scope) for scope in response] diff --git a/usos_api/services/api_server.py b/usos_api/services/api_server.py index cbba8fa..bb01c53 100644 --- a/usos_api/services/api_server.py +++ b/usos_api/services/api_server.py @@ -1,5 +1,3 @@ -from typing import List - from usos_api.models.consumer import Consumer @@ -16,7 +14,7 @@ def __init__(self, connection): """ self.connection = connection - async def get_consumer_info(self, fields: List[str] | None = None) -> Consumer: + async def get_consumer_info(self, fields: list[str] | None = None) -> Consumer: """ Get information on the Consumer. diff --git a/usos_api/services/courses.py b/usos_api/services/courses.py new file mode 100644 index 0000000..f55325c --- /dev/null +++ b/usos_api/services/courses.py @@ -0,0 +1,52 @@ +from typing import Optional + +from ..connection import USOSAPIConnection +from ..models import Course + + +class CourseService: + """ + A service for course-related operations. + """ + + def __init__(self, connection: USOSAPIConnection): + """ + Initialize the course service. + + :param connection: The connection to use. + """ + self.connection = connection + + async def get_user_courses_ects(self) -> dict[str, dict[str, float]]: + """ + Get user courses ECTS. + + :return: The user courses ECTS. + """ + response = await self.connection.post("services/courses/user_ects_points") + return { + term: {course: float(points) for course, points in courses.items()} + for term, courses in response.items() + } + + async def get_courses( + self, course_ids: list[str], fields: Optional[list[str]] = None + ) -> list[Course]: + """ + Get courses by their IDs. + + :param course_ids: The IDs of the courses to get. + :param fields: The fields to include in the response. + :return: A list of courses. + """ + if not course_ids: + return [] + + course_ids_str = "|".join(course_ids) + fields_str = "|".join(fields) if fields else "id|name" + + response = await self.connection.post( + "services/courses/courses", course_ids=course_ids_str, fields=fields_str + ) + + return [Course(**course_data) for course_data in response.values()] diff --git a/usos_api/services/grades.py b/usos_api/services/grades.py new file mode 100644 index 0000000..bdf4115 --- /dev/null +++ b/usos_api/services/grades.py @@ -0,0 +1,99 @@ +from ..connection import USOSAPIConnection +from ..models import Grade + + +class GradeService: + """ + A service for grade-related operations. + """ + + def __init__(self, connection: USOSAPIConnection): + """ + Initialize the grade service. + + :param connection: The connection to use. + """ + self.connection = connection + + async def get_grades_by_terms( + self, term_ids: list[str] | str, fields: list[str] = None + ) -> dict[str, dict[str, dict[str, dict[str, Grade] | list[Grade]]]]: + """ + Get grades by terms. + + :param term_ids: The IDs of the terms to get grades for, or a single term ID. + :param fields: The fields to include in the response. + :return: The grades. + """ + term_ids = [term_ids] if isinstance(term_ids, str) else term_ids + fields = fields or [ + "value_symbol", + "passes", + "value_description", + "exam_id", + "exam_session_number", + "counts_into_average", + ] + + response = await self.connection.post( + "services/grades/terms2", + term_ids="|".join(term_ids), + fields="|".join(fields), + ) + + new_response = {} + term_id: str + for term_id, courses in response.items(): + new_response[term_id] = self._process_courses(courses) + + return new_response + + def _process_courses( + self, courses: dict + ) -> dict[str, dict[str, dict[str, Grade] | list[Grade]]]: + """ + Process courses to extract grades. + + :param courses: The courses to process. + :return: The processed courses with grades. + """ + processed_courses = {} + for course_id, grades in courses.items(): + course_units_grades = self._process_course_units_grades( + grades["course_units_grades"] + ) + course_grades = self._process_course_grades(grades["course_grades"]) + processed_courses[course_id] = { + "course_units_grades": course_units_grades, + "course_grades": course_grades, + } + return processed_courses + + def _process_course_units_grades( + self, course_units_grades: dict + ) -> dict[str, dict[str, Grade]]: + """ + Process course units grades. + + :param course_units_grades: The course units grades to process. + :return: The processed course units grades. + """ + processed_units_grades = {} + for unit_id, units in course_units_grades.items(): + processed_units_grades[unit_id] = { + exam_session_number: Grade(**grade) + for unit in units + for exam_session_number, grade in unit.items() + } + return processed_units_grades + + def _process_course_grades(self, course_grades: list) -> list[Grade]: + """ + Process course grades. + + :param course_grades: The course grades to process. + :return: The processed course grades. + """ + return [ + Grade(**grade) for session in course_grades for grade in session.values() + ] diff --git a/usos_api/services/groups.py b/usos_api/services/groups.py index 5de98d1..af70e98 100644 --- a/usos_api/services/groups.py +++ b/usos_api/services/groups.py @@ -1,5 +1,3 @@ -from datetime import datetime - from ..connection import USOSAPIConnection from ..logger import get_logger from ..models import Group, Term @@ -12,11 +10,7 @@ def _filter_ongoing_terms(terms: list[Term]) -> list[Term]: :param terms: The terms to filter. :return: The ongoing terms. """ - return [ - term - for term in terms - if term.start_date <= datetime.today().date() <= term.finish_date - ] + return [term for term in terms if term.is_current] def _deserialize_term(data: dict) -> Term: diff --git a/usos_api/services/terms.py b/usos_api/services/terms.py new file mode 100644 index 0000000..7f4bb59 --- /dev/null +++ b/usos_api/services/terms.py @@ -0,0 +1,39 @@ +from ..connection import USOSAPIConnection +from ..models import Term + + +class TermService: + """ + A service for term-related operations. + """ + + def __init__(self, connection: USOSAPIConnection): + """ + Initialize the term service. + + :param connection: The connection to use. + """ + self.connection = connection + + async def get_term(self, term_id: str) -> Term: + """ + Get a term by its ID. + + :param term_id: The ID of the term to get. + :return: The term. + """ + response = await self.connection.post("services/terms/term", term_id=term_id) + return Term(**response) + + async def get_terms(self, term_ids: list[str]) -> list[Term]: + """ + Get terms by their IDs. + + :param term_ids: The IDs of the terms to get, or a single term ID. + :return: The terms. + """ + + term_ids = "|".join(term_ids) + + response = await self.connection.post("services/terms/terms", term_ids=term_ids) + return [Term(**term) for term in response.values()]