483 lines
19 KiB
Python
483 lines
19 KiB
Python
import logging
|
|
from datetime import timedelta
|
|
|
|
import requests
|
|
|
|
from librus_tricks import cache as cache_lib
|
|
from librus_tricks import exceptions, tools
|
|
from librus_tricks.classes import *
|
|
from librus_tricks.messages import MessageReader
|
|
|
|
|
|
class SynergiaClient:
|
|
"""Sesja z API Synergii"""
|
|
|
|
def __init__(self, user, api_url='https://api.librus.pl/2.0', user_agent='LibrusMobileApp',
|
|
cache=cache_lib.AlchemyCache()):
|
|
"""
|
|
Tworzy sesję z API Synergii.
|
|
|
|
:param librus_tricks.auth.SynergiaUser user: Użytkownik sesji
|
|
:param str api_url: Bazowy url api, zmieniaj jeżeli chcesz używać proxy typu beeceptor
|
|
:param str user_agent: User-agent klienta http, domyślnie się podszywa pod aplikację
|
|
:param librus_tricks.cache.CacheBase cache: Obiekt, który zarządza cache
|
|
"""
|
|
self.user = user
|
|
self.session = requests.session()
|
|
|
|
self.session.headers.update({'User-Agent': user_agent})
|
|
self.__auth_headers = {'Authorization': f'Bearer {user.token}'}
|
|
self.__api_url = api_url
|
|
|
|
if cache_lib.CacheBase in cache.__class__.__bases__:
|
|
self.cache = cache
|
|
self.li_session = self
|
|
else:
|
|
raise exceptions.InvalidCacheManager(f'{cache} can not be a cache object!')
|
|
|
|
self.__message_reader = None
|
|
|
|
@property
|
|
def message_reader(self):
|
|
if self.__message_reader is None:
|
|
self.__message_reader = MessageReader(self)
|
|
return self.__message_reader
|
|
|
|
def __repr__(self):
|
|
return f'<Synergia session for {self.user}>'
|
|
|
|
def __update_auth_header(self):
|
|
self.__auth_headers = {'Authorization': f'Bearer {self.user.token}'}
|
|
logging.debug('Updating headers to %s', self.__auth_headers)
|
|
|
|
@staticmethod
|
|
def assembly_path(*elements, prefix='', suffix='', sep='/'):
|
|
"""
|
|
Składa str w jednego str, przydatne przy tworzeniu url.
|
|
|
|
:param str elements: Elementy do stworzenia str
|
|
:param str prefix: Początek str
|
|
:param str suffix: Koniec str
|
|
:param str sep: str wstawiany pomiędzy elementy
|
|
:return: Złożony str
|
|
:rtype: str
|
|
"""
|
|
for element in elements:
|
|
prefix += sep + str(element)
|
|
return prefix + suffix
|
|
|
|
# HTTP part
|
|
|
|
def dispatch_http_code(self, response: requests.Response, callback=None, callback_args=tuple(),
|
|
callback_kwargs=None):
|
|
"""
|
|
Sprawdza czy serwer zgłasza błąd poprzez podanie kodu http, w przypadku błędu, rzuca wyjątkiem.
|
|
|
|
:param requests.Response response:
|
|
:raises librus_tricks.exceptions.SynergiaNotFound: 404
|
|
:raises librus_tricks.exceptions.SynergiaForbidden: 403
|
|
:raises librus_tricks.exceptions.SynergiaAccessDenied: 401
|
|
:raises librus_tricks.exceptions.SynergiaInvalidRequest: 401
|
|
:rtype: requests.Response
|
|
:return: sprawdzona odpowiedź http
|
|
"""
|
|
if callback_kwargs is None:
|
|
callback_kwargs = dict()
|
|
|
|
logging.debug('Dispatching response status')
|
|
if response.json().get('Code') == 'TokenIsExpired':
|
|
logging.info('Server returned error code "TokenIsExpired", trying to obtain new token')
|
|
self.user.revalidate_user()
|
|
self.__update_auth_header()
|
|
logging.debug('Repeating failed response')
|
|
return callback(*callback_args, **callback_kwargs)
|
|
|
|
logging.debug('Dispatching http status code')
|
|
if response.status_code >= 400:
|
|
try:
|
|
raise {
|
|
503: exceptions.SynergiaMaintenanceError(response.url, response.json()),
|
|
500: exceptions.SynergiaServerError(response.url, response.json()),
|
|
404: exceptions.SynergiaAPIEndpointNotFound(response.url),
|
|
403: exceptions.SynergiaForbidden(response.url, response.json()),
|
|
401: exceptions.SynergiaAccessDenied(response.url, response.json()),
|
|
400: exceptions.SynergiaAPIInvalidRequest(response.url, response.json()),
|
|
}[response.status_code]
|
|
except KeyError:
|
|
raise exceptions.OtherHTTPResponse('Not excepted HTTP error code!', response.status_code)
|
|
|
|
return response.json()
|
|
|
|
def get(self, *path, request_params=None):
|
|
"""
|
|
Wykonuje odpowiednio spreparowane zapytanie http GET.
|
|
|
|
:param path: Ścieżka zawierająca węzeł API
|
|
:type path: str
|
|
:param request_params: dict zawierający kwargs dla zapytania http
|
|
:type request_params: dict
|
|
:return: json przekonwertowany na dict'a
|
|
:rtype: dict
|
|
"""
|
|
if request_params is None:
|
|
request_params = dict()
|
|
path_str = self.assembly_path(*path, prefix=self.__api_url)
|
|
response = self.session.get(
|
|
path_str, headers=self.__auth_headers, params=request_params
|
|
)
|
|
|
|
response = self.dispatch_http_code(response, callback=self.get, callback_args=path,
|
|
callback_kwargs=request_params)
|
|
|
|
return response
|
|
|
|
def post(self, *path, request_params=None):
|
|
"""
|
|
Pozwala na dokonanie zapytania http POST.
|
|
|
|
:param path: Ścieżka zawierająca węzeł API
|
|
:type path: str
|
|
:param request_params: dict zawierający kwargs dla zapytania http
|
|
:type request_params: dict
|
|
:return: json przekonwertowany na dict'a
|
|
:rtype: dict
|
|
"""
|
|
if request_params is None:
|
|
request_params = dict()
|
|
path_str = self.assembly_path(*path, prefix=self.__api_url)
|
|
response = self.session.post(
|
|
path_str, headers=self.__auth_headers, params=request_params
|
|
)
|
|
|
|
response = self.dispatch_http_code(response, callback=self.post, callback_args=path,
|
|
callback_kwargs=request_params)
|
|
|
|
return response
|
|
|
|
# Cache
|
|
|
|
def get_cached_response(self, *path, http_params=None, max_lifetime=timedelta(hours=1)):
|
|
"""
|
|
Wykonuje zapytanie http GET z poprzednim sprawdzeniem cache.
|
|
|
|
:param path: Niezłożona ścieżka do węzła API
|
|
:param http_params: dict zawierający kwargs dla zapytania http
|
|
:type http_params: dict
|
|
:param timedelta max_lifetime: Maksymalny czas ważności cache dla tego zapytania http
|
|
:return: dict zawierający odpowiedź zapytania
|
|
:rtype: dict
|
|
"""
|
|
uri = self.assembly_path(*path, prefix=self.__api_url)
|
|
response_cached = self.cache.get_query(uri, self.user.uid)
|
|
|
|
if response_cached is None:
|
|
logging.debug('Response is not present in cache!')
|
|
http_response = self.get(*path, request_params=http_params)
|
|
self.cache.add_query(uri, http_response, self.user.uid)
|
|
return http_response
|
|
|
|
try:
|
|
age = datetime.now() - response_cached.last_load
|
|
except TypeError:
|
|
age = datetime.now() - response_cached.last_load.replace(tzinfo=None)
|
|
|
|
if age > max_lifetime:
|
|
logging.debug('Response is too old! Trying to get latest response from api')
|
|
http_response = self.get(*path, request_params=http_params)
|
|
self.cache.del_query(uri, self.user.uid)
|
|
self.cache.add_query(uri, http_response, self.user.uid)
|
|
return http_response
|
|
return response_cached.response
|
|
|
|
def get_cached_object(self, uid, cls, max_lifetime=timedelta(hours=1)):
|
|
"""
|
|
Pobiera dany obiekt z poprzednim sprawdzeniem cache. Nie używane domyślnie w bibliotece.
|
|
|
|
:param str uid: Id żądanego obiektu
|
|
:param cls: Klasa żądanego obiektu
|
|
:param timedelta max_lifetime: Maksymalny czas ważności cache dla tego obiektu
|
|
:return: Żądany obiekt
|
|
"""
|
|
requested_object = self.cache.get_object(uid, cls)
|
|
|
|
if requested_object is None:
|
|
logging.debug('Obejct is not present in cache!')
|
|
requested_object = cls.create(uid=uid, session=self)
|
|
self.cache.add_object(uid, cls, requested_object.export_resource())
|
|
return requested_object
|
|
|
|
try:
|
|
age = datetime.now() - requested_object.last_load
|
|
except TypeError:
|
|
age = datetime.now() - requested_object.last_load.replace(tzinfo=None)
|
|
|
|
if age > max_lifetime:
|
|
logging.debug('Object is too old! Trying to get latest object from api')
|
|
requested_object = cls.create(uid=uid, session=self)
|
|
self.cache.del_object(uid)
|
|
self.cache.add_object(uid, cls, requested_object.export_resource())
|
|
|
|
return requested_object
|
|
|
|
# API query part
|
|
|
|
def return_objects(self, *path, cls, extraction_key=None, lifetime=timedelta(seconds=10), bypass_cache=False):
|
|
"""
|
|
Zwraca listę obiektów lub obiekt, wygenerowaną z danych danej ścieżki API.
|
|
|
|
:param str path: Niezłożona ścieżka do węzła API
|
|
:param cls: Klasa żądanych obiektów
|
|
:param str extraction_key: Klucz do wyjęcia danych, pozostawienie tego parametru na None powoduje
|
|
automatyczną próbę zczytania danych
|
|
:param timedelta lifetime: Maksymalny czas ważności cache dla tego zapytania http
|
|
:param bool bypass_cache: Ustawienie tego parametru na True powoduje ignorowanie mechanizmu cache
|
|
:return: Lista żądanych obiektów
|
|
:rtype: list[SynergiaGenericClass]
|
|
"""
|
|
if bypass_cache:
|
|
raw = self.get(*path)
|
|
else:
|
|
raw = self.get_cached_response(*path, max_lifetime=lifetime)
|
|
|
|
if extraction_key is None:
|
|
extraction_key = SynergiaGenericClass.auto_extract(raw)
|
|
|
|
raw = raw[extraction_key]
|
|
|
|
if isinstance(raw, list):
|
|
stack = []
|
|
for stored_payload in raw:
|
|
stack.append(cls.assembly(stored_payload, self))
|
|
return tuple(stack)
|
|
if isinstance(raw, dict):
|
|
return cls.assembly(raw, self)
|
|
|
|
return None
|
|
|
|
def grades(self, *grades):
|
|
"""
|
|
:param int grades: Id ocen
|
|
:rtype: tuple[librus_tricks.classes.SynergiaGrade]
|
|
:return: krotka z wszystkimi/wybranymi ocenami
|
|
"""
|
|
if grades.__len__() == 0:
|
|
return self.return_objects('Grades', cls=SynergiaGrade, extraction_key='Grades')
|
|
ids_computed = self.assembly_path(*grades, sep=',', suffix=',')[1:]
|
|
return self.return_objects('Grades', ids_computed, cls=SynergiaGrade, extraction_key='Grades')
|
|
|
|
@property
|
|
def grades_categorized(self):
|
|
grades_categorized = {}
|
|
for subject in self.subjects():
|
|
grades_categorized[subject.name] = []
|
|
|
|
for grade in self.grades():
|
|
grades_categorized[grade.subject.name].append(
|
|
grade
|
|
)
|
|
|
|
for subjects in grades_categorized.copy().keys():
|
|
if grades_categorized[subjects].__len__() == 0:
|
|
del (grades_categorized[subjects])
|
|
|
|
return grades_categorized
|
|
|
|
def attendances(self, *attendances):
|
|
"""
|
|
:param int attendances: Id obecności
|
|
:rtype: tuple[librus_tricks.classes.SynergiaAttendance]
|
|
:return: krotka z wszystkimi/wybranymi obecnościami
|
|
"""
|
|
if attendances.__len__() == 0:
|
|
return self.return_objects('Attendances', cls=SynergiaAttendance, extraction_key='Attendances')
|
|
ids_computed = self.assembly_path(*attendances, sep=',', suffix=',')[1:]
|
|
return self.return_objects('Attendances', ids_computed, cls=SynergiaAttendance, extraction_key='Attendances')
|
|
|
|
@property
|
|
def illegal_absences(self):
|
|
"""
|
|
:rtype: tuple[librus_tricks.classes.SynergiaAttendance]
|
|
:return: krotka z nieusprawiedliwionymi nieobecnościami
|
|
"""
|
|
|
|
def is_absence(k):
|
|
if k.type.uid == '1':
|
|
return True
|
|
return False
|
|
|
|
return tuple(filter(is_absence, self.attendances()))
|
|
|
|
@property
|
|
def all_absences(self):
|
|
"""
|
|
:rtype: tuple[librus_tricks.classes.SynergiaAttendance]
|
|
:return: krotka z wszystkimi nieobecnościami
|
|
"""
|
|
return tuple(filter(lambda k: not k.type.is_presence_kind, self.attendances()))
|
|
|
|
def exams(self, *exams):
|
|
"""
|
|
:param int exams: Id egzaminów
|
|
:rtype: tuple[librus_tricks.classes.SynergiaExam]
|
|
:return: krotka z wszystkimi egzaminami
|
|
"""
|
|
if exams.__len__() == 0:
|
|
return self.return_objects('HomeWorks', cls=SynergiaExam, extraction_key='HomeWorks')
|
|
ids_computed = self.assembly_path(*exams, sep=',', suffix=',')[1:]
|
|
return self.return_objects('HomeWorks', ids_computed, cls=SynergiaExam, extraction_key='HomeWorks')
|
|
|
|
def colors(self, *colors):
|
|
"""
|
|
:param int colors: Id kolorów
|
|
:rtype: tuple[librus_tricks.classes.SynergiaColors]
|
|
"""
|
|
if colors.__len__() == 0:
|
|
return self.return_objects('Colors', cls=SynergiaColor, extraction_key='Colors')
|
|
ids_computed = self.assembly_path(*colors, sep=',', suffix=',')[1:]
|
|
return self.return_objects('Colors', ids_computed, cls=SynergiaColor, extraction_key='Colors')
|
|
|
|
def timetable(self, for_date=datetime.now()):
|
|
"""
|
|
Plan lekcji na cały tydzień.
|
|
|
|
:param datetime.datetime for_date: Data dnia, który ma być w planie lekcji
|
|
:rtype: librus_tricks.classes.SynergiaTimetable
|
|
:return: obiekt tygodniowego planu lekcji
|
|
"""
|
|
monday = tools.get_actual_monday(for_date).isoformat()
|
|
matrix = self.get('Timetables', request_params={'weekStart': monday})
|
|
return SynergiaTimetable.assembly(matrix['Timetable'], self)
|
|
|
|
def timetable_day(self, for_date: datetime):
|
|
return self.timetable(for_date).days[for_date.date()]
|
|
|
|
@property
|
|
def today_timetable(self):
|
|
"""
|
|
Plan lekcji na dzisiejszy dzień.
|
|
|
|
:rtype: librus_tricks.classes.SynergiaTimetableDay
|
|
:return: Plan lekcji na dziś
|
|
"""
|
|
try:
|
|
return self.timetable().days[datetime.now().date()]
|
|
except KeyError:
|
|
return None
|
|
|
|
@property
|
|
def tomorrow_timetable(self):
|
|
"""
|
|
Plan lekcji na kolejny dzień.
|
|
|
|
:rtype: librus_tricks.classes.SynergiaTimetableDay
|
|
:return: Plan lekcji na jutro
|
|
"""
|
|
try:
|
|
return self.timetable(datetime.now() + timedelta(days=1)).days[(datetime.now() + timedelta(days=1)).date()]
|
|
except KeyError:
|
|
return None
|
|
|
|
def messages(self, *messages):
|
|
"""
|
|
Wymaga mobilnych dodatków.
|
|
|
|
:param int messages: Id wiadomości
|
|
:rtype: tuple[librus_tricks.classes.SynergiaNativeMessage]
|
|
"""
|
|
if messages.__len__() == 0:
|
|
return self.return_objects('Messages', cls=SynergiaNativeMessage, extraction_key='Messages')
|
|
ids_computed = self.assembly_path(*messages, sep=',', suffix=',')[1:]
|
|
return self.return_objects('Messages', ids_computed, cls=SynergiaNativeMessage, extraction_key='Messages')
|
|
|
|
def news_feed(self):
|
|
"""
|
|
:return: Wszystkie ogłoszenia szkolne
|
|
:rtype: tuple[librus_tricks.classes.SynergiaNews]
|
|
"""
|
|
return self.return_objects('SchoolNotices', cls=SynergiaNews, extraction_key='SchoolNotices')
|
|
|
|
def subjects(self, *subject):
|
|
"""
|
|
:return: Wszystkie/wybrane przedmioty lekcyjne
|
|
:param int subject: Id przedmiotów
|
|
:rtype: tuple[librus_tricks.classes.SynergiaSubject]
|
|
"""
|
|
if subject.__len__() == 0:
|
|
return self.return_objects('Subjects', cls=SynergiaSubject, extraction_key='Subjects')
|
|
ids_computed = self.assembly_path(*subject, sep=',', suffix=',')[1:]
|
|
return self.return_objects('Subjects', ids_computed, cls=SynergiaSubject, extraction_key='Subjects')
|
|
|
|
@property
|
|
def school(self):
|
|
"""
|
|
:return: Obiekt z informacjami o twojej szkole
|
|
:rtype: librus_tricks.classes.SynergiaSchool
|
|
"""
|
|
return self.return_objects('Schools', cls=SynergiaSchool, extraction_key='School')
|
|
|
|
@property
|
|
def lucky_number(self):
|
|
"""
|
|
:return: Szczęśliwy numerek
|
|
:rtype: int
|
|
"""
|
|
return self.get('LuckyNumbers')['LuckyNumber']['LuckyNumber']
|
|
|
|
@staticmethod
|
|
def __is_future(day):
|
|
"""
|
|
|
|
:type day: librus_tricks.classes.SynergiaTeacherFreeDays
|
|
:return:
|
|
"""
|
|
if day.ends >= datetime.now().date():
|
|
return True
|
|
return False
|
|
|
|
def teacher_free_days(self, *days_ids, only_future=True):
|
|
"""
|
|
Zwraca dane przedmioty.
|
|
|
|
:param int days_ids: Id zwolnień
|
|
:rtype: tuple[librus_tricks.classes.SynergiaTeacherFreeDays]
|
|
"""
|
|
if days_ids.__len__() == 0:
|
|
days = self.return_objects('Calendars', 'TeacherFreeDays', cls=SynergiaTeacherFreeDays)
|
|
else:
|
|
ids_computed = self.assembly_path(*days_ids, sep=',', suffix=',')[1:]
|
|
days = self.return_objects('Calendars', 'TeacherFreeDays', ids_computed, cls=SynergiaTeacherFreeDays)
|
|
|
|
days = tuple(sorted(days, key=lambda x: x.starts))
|
|
if only_future:
|
|
return tuple(filter(self.__is_future, days))
|
|
return days
|
|
|
|
def school_free_days(self, *days_ids, only_future=True):
|
|
if days_ids.__len__() == 0:
|
|
days = self.return_objects('Calendars', 'SchoolFreeDays', cls=SynergiaSchoolFreeDays)
|
|
else:
|
|
ids_computed = self.assembly_path(*days_ids, sep=',', suffix=',')[1:]
|
|
days = self.return_objects('Calendars', 'SchoolFreeDays', ids_computed, cls=SynergiaSchoolFreeDays)
|
|
|
|
days = tuple(sorted(days, key=lambda x: x.starts))
|
|
if only_future:
|
|
return tuple(filter(self.__is_future, days))
|
|
return days
|
|
|
|
def realizations(self, *realizations_ids):
|
|
if realizations_ids.__len__() == 0:
|
|
return self.return_objects('Realizations', cls=SynergiaRealization, extraction_key='Realizations')
|
|
ids_computed = self.assembly_path(*realizations_ids, sep=',', suffix=',')[1:]
|
|
return self.return_objects('Realizations', ids_computed, cls=SynergiaRealization, extraction_key='Realizations')
|
|
|
|
def substitutions(self):
|
|
pass
|
|
|
|
def preload_cache(self):
|
|
self.cache.clear_objects()
|
|
|
|
for thing in (*self.attendances(), *self.grades(), *self.subjects(), *self.school_free_days(only_future=False),
|
|
*self.teacher_free_days(only_future=False)):
|
|
self.cache.add_object(thing.uid, thing.__class__, thing.export_resource())
|
|
|
|
logging.info('Loaded %s objects into cache', self.cache.count_object())
|