gmao/app_new/lib_ext/pronotepy/clients.py
2026-08-14 16:02:25 +00:00

1116 lines
37 KiB
Python

import datetime
import logging
from time import time
from typing import (
List,
Callable,
Optional,
Union,
TypeVar,
Type,
TYPE_CHECKING,
Tuple,
)
from Crypto.Hash import SHA256
import re
from urllib.parse import urlparse, urlunparse
from . import dataClasses
from .exceptions import *
from .pronoteAPI import (
_Communication,
_Encryption,
_KeepAlive,
_enleverAlea,
_prepare_onglets,
log,
)
import json
if TYPE_CHECKING:
from requests.cookies import RequestsCookieJar
from typing_extensions import Protocol
class ENTFunction(Protocol):
def __call__(self, u: str, p: str, **kwargs: str) -> RequestsCookieJar: ...
__all__ = ("ClientBase", "Client", "ParentClient", "VieScolaireClient")
T = TypeVar("T", bound="ClientBase")
class ClientBase:
"""Base for every PRONOTE client. Provides login.
Args:
pronote_url (str): URL of the server
username (str)
password (str)
ent (Optional[Callable]): Cookies for ENT connections
mode (bool): internal option
uuid (str): Your application UUID (any unique string)
account_pin (Optional[str]): 2FA PIN to the account.
Consider deleting it after logging in.
.. code-block:: python
del client.account_pin
client_identifier (Optional[str]):
Identificator of this client provided by PRONOTE. PRONOTE uses this
to remember a browser / client.
device_name (Optional[str]): A name for registering this client as a device.
Attributes:
start_day (datetime.datetime): The first day of the school year
week (int): The current week of the school year
logged_in (bool): If the user is successfully logged in
username (str)
password (str)
pronote_url (str)
info (ClientInfo): Provides information about the current client. Name etc...
last_connection (datetime.datetime)
client_identifier (str): Identificator of this client provided by PRONOTE
"""
def __init__(
self,
pronote_url: str,
username: str = "",
password: str = "",
ent: Optional["ENTFunction"] = None,
mode: str = "normal",
uuid: str = "",
account_pin: Optional[str] = None,
client_identifier: Optional[str] = None,
device_name: Optional[str] = None,
) -> None:
log.info("INIT")
# start communication session
if not len(password) + len(username):
raise PronoteAPIError(
"Please provide login credentials. Cookies are None, and username and password are empty."
)
self.ent = ent
if ent:
pronote_url = pronote_url.replace("login=true", "")
cookies = ent(username, password, pronote_url=pronote_url)
else:
cookies = None
if mode != "normal" and not uuid:
raise PronoteAPIError("UUID must not be empty")
self.uuid = uuid
self.login_mode = mode
self.username = username
self.password = password
self.pronote_url = pronote_url
self.communication = _Communication(pronote_url, cookies)
self.account_pin = account_pin
self.client_identifier = client_identifier
self.device_name = device_name
self.attributes, self.func_options = self.communication.initialise(
self.client_identifier
)
if not self.client_identifier:
self.client_identifier = self.func_options["dataSec"]["data"][
"identifiantNav"
]
# set up encryption
self.encryption = _Encryption()
self.encryption.aes_iv = self.communication.encryption.aes_iv
# some other attribute creation
self._last_ping = time()
self.parametres_utilisateur: dict = {}
self.auth_cookie: dict = {}
self.info: dataClasses.ClientInfo
self.start_day = datetime.datetime.strptime(
self.func_options["dataSec"]["data"]["General"]["PremierLundi"]["V"],
"%d/%m/%Y",
).date()
self.week = self.get_week(datetime.date.today())
self._refreshing = False
self.periods_: Optional[List[dataClasses.Period]]
self.periods_ = self.periods
self.logged_in = self._login()
self._expired = False
self.last_connection: Optional[datetime.datetime]
@classmethod
def qrcode_login(
cls: Type[T],
qr_code: dict,
pin: str,
uuid: str,
account_pin: Optional[str] = None,
client_identifier: Optional[str] = None,
device_name: Optional[str] = None,
skip_2fa: bool = False,
) -> T:
"""Login with QR code
The created client instance will have its username and password
attributes set to the credentials for the next login using
:meth:`.token_login`.
Args:
qr_code (dict): JSON contained in the QR code. Must have ``login``, ``jeton`` and ``url`` keys.
pin (str): 4-digit confirmation code created during QR code setup
uuid (str): Unique ID for your application. Must not change between logins.
account_pin (Optional[str]): 2FA PIN to the account
client_identifier (Optional[str]): Identificator of this client provided by PRONOTE
device_name (Optional[str]): A name for registering this client as a device.
skip_2fa (bool): Skip 2FA. PRONOTE will require it when connecting using the generated token (:meth:`.token_login`).
"""
encryption = _Encryption()
encryption.aes_set_key(pin.encode())
short_token = bytes.fromhex(qr_code["login"])
long_token = bytes.fromhex(qr_code["jeton"])
try:
login = encryption.aes_decrypt(short_token).decode()
jeton = encryption.aes_decrypt(long_token).decode()
except CryptoError as ex:
raise QRCodeDecryptError("invalid confirmation code") from ex
url = urlparse(qr_code["url"])
# The app would query the server for all the available spaces and find
# a space by checking if the last part of the URL matches one of the
# space URLs. eg. "/pronote/parent.html" would match "mobile.parent.html"
# (info url: <pronote root>/InfoMobileApp.json?id=0D264427-EEFC-4810-A9E9-346942A862A4)
# We're gonna try the shorter route of just prepending "mobile." if it
# isn't there already
parts = url.path.split("/")
if not parts[-1].startswith("mobile."):
parts[-1] = "mobile." + parts[-1]
# Reconstruct the url and add magic parameters at the end of the URL.
# You can find them in a file called "ObjetCommMessage.js" in the
# connection method when you decompile the mobile APK.
fixed_url = url._replace(
path="/".join(parts),
query="fd=1&bydlg=A6ABB224-12DD-4E31-AD3E-8A39A1C2C335&login=true",
fragment="",
)
client = cls(
urlunparse(fixed_url),
login,
jeton,
mode="qr_code",
uuid=uuid,
account_pin=account_pin,
client_identifier=client_identifier,
device_name=device_name,
)
client.login_mode = "token" # for subsequent refreshes
if not skip_2fa:
# check if the account has 2FA enabled
resp = client.post("PageInfosPerso", onglet=49)
mode = resp["dataSec"]["data"]["securisation"].get("mode", 0)
if mode == 0:
log.warning("couldn't get account security mode, ignoring...")
return client
return cls.token_login(
**client.export_credentials(),
account_pin=account_pin,
device_name=device_name,
)
return client
@classmethod
def token_login(
cls: Type[T],
pronote_url: str,
username: str,
password: str,
uuid: str,
account_pin: Optional[str] = None,
client_identifier: Optional[str] = None,
device_name: Optional[str] = None,
) -> T:
"""
Login with a password token. Used for logins after :meth:`.qrcode_login`.
The created client instance will have its username and password
attributes set to the credentials for the next login using
:meth:`.token_login`.
Args:
pronote_url (str): URL of the server
username (str)
password (str): Password token received from the previous login
uuid (str): Unique ID for your application. Must not change between logins.
account_pin (Optional[str]): 2FA PIN to the account
client_identifier (str): Identificator of this client provided by PRONOTE
device_name (str): Identificator of this client provided by PRONOTE
"""
return cls(
pronote_url,
username,
password,
mode="token",
uuid=uuid,
account_pin=account_pin,
client_identifier=client_identifier,
device_name=device_name,
)
def _login(self) -> bool:
"""Logs in the user.
Returns:
bool: True if logged in, False if not
"""
if self.ent:
username = self.attributes["e"]
password = self.attributes["f"]
else:
username = self.username
password = self.password
# identification phase
ident_json = {
"genreConnexion": 0,
"genreEspace": int(self.attributes["a"]),
"identifiant": username,
"pourENT": True if self.ent else False,
"enConnexionAuto": False,
"demandeConnexionAuto": False,
"demandeConnexionAppliMobile": self.login_mode == "qr_code",
"demandeConnexionAppliMobileJeton": self.login_mode == "qr_code",
"enConnexionAppliMobile": self.login_mode == "token",
"uuidAppliMobile": (
self.uuid if self.login_mode in ("qr_code", "token") else ""
),
"loginTokenSAV": "",
}
idr = self.post("Identification", data=ident_json)
log.debug("indentification")
# creating the authentification data
log.debug(str(idr))
challenge = idr["dataSec"]["data"]["challenge"]
e = _Encryption()
e.aes_set_iv(self.communication.encryption.aes_iv)
# key gen
if self.ent:
motdepasse = SHA256.new(str(password).encode()).hexdigest().upper()
e.aes_set_key(motdepasse.encode())
else:
if idr["dataSec"]["data"]["modeCompLog"]:
username = username.lower()
if idr["dataSec"]["data"]["modeCompMdp"]:
password = password.lower()
alea = idr["dataSec"]["data"].get("alea", "")
motdepasse = SHA256.new((alea + password).encode()).hexdigest().upper()
e.aes_set_key((username + motdepasse).encode())
# challenge
try:
dec = e.aes_decrypt(bytes.fromhex(challenge))
dec_no_alea = _enleverAlea(dec.decode())
ch = e.aes_encrypt(dec_no_alea.encode()).hex()
except CryptoError as ex:
if self.login_mode == "qr_code":
ex.args += (
"exception happened during login -> probably the qr code has expired (qr code is valid during 10 minutes)",
)
else:
ex.args += (
"exception happened during login -> probably bad username/password",
)
raise
# send
auth_json = {
"connexion": 0,
"challenge": ch,
"espace": int(self.attributes["a"]),
}
auth_response = self.post("Authentification", data=auth_json)
if "cle" in auth_response["dataSec"]["data"]:
self.communication.after_auth(auth_response, e.aes_key)
self.encryption.aes_key = e.aes_key
actionsDoubleAuth = auth_response["dataSec"]["data"].get(
"actionsDoubleAuth"
)
if actionsDoubleAuth:
actions = json.loads(actionsDoubleAuth["V"])
doRegisterDevice = 5 in actions or 3 in actions
doVerifyPin = 3 in actions
self._do_2fa(
doVerifyPin,
doRegisterDevice,
self.account_pin,
self.device_name,
)
log.info(f"successfully logged in as {self.username}")
last_conn = auth_response["dataSec"]["data"].get("derniereConnexion")
self.last_connection = (
dataClasses.Util.datetime_parse(last_conn["V"]) if last_conn else None
)
if self.login_mode in ("qr_code", "token") and auth_response["dataSec"][
"data"
].get("jetonConnexionAppliMobile"):
self.password = auth_response["dataSec"]["data"][
"jetonConnexionAppliMobile"
]
# getting listeOnglets separately because of pronote API change
self.parametres_utilisateur = self.post("ParametresUtilisateur")
self.info = dataClasses.ClientInfo(
self, self.parametres_utilisateur["dataSec"]["data"]["ressource"]
)
self.communication.authorized_onglets = _prepare_onglets(
self.parametres_utilisateur["dataSec"]["data"]["listeOnglets"]
)
log.info("got onglets data.")
return True
else:
log.info("login failed")
return False
def _do_2fa(
self,
doVerifyPin: bool = False,
doRegisterDevice: bool = False,
pin: Optional[str] = None,
identifier: Optional[str] = None,
) -> None:
log.debug(
"doing 2fa doPin=%s, doRegister=%s, pin=%s (redacted), identifier=%s",
doVerifyPin,
doRegisterDevice,
bool(pin),
identifier,
)
encryptedPin = None
if doVerifyPin:
log.debug("verifying pin")
if pin is None:
raise MFAError("PIN is required for this account")
encryptedPin = self.communication.encryption.aes_encrypt(pin.encode()).hex()
resp = self.post(
"SecurisationCompteDoubleAuth",
data={
"action": 0,
"codePin": encryptedPin,
},
)
if not resp["dataSec"]["data"].get("result", False):
raise MFAError("Invalid PIN")
if doRegisterDevice:
log.debug("registering device")
if identifier is None:
raise MFAError("A device identifier is required for this account")
data = {
"action": 3,
"avecIdentification": True,
"strIdentification": identifier,
}
if encryptedPin:
data["codePin"] = encryptedPin
self.post("SecurisationCompteDoubleAuth", data=data)
def export_credentials(self) -> dict:
return {
"pronote_url": self.pronote_url,
"username": self.username,
"password": self.password,
"client_identifier": self.client_identifier,
"uuid": self.uuid,
}
def get_week(self, date: Union[datetime.date, datetime.datetime]) -> int:
if isinstance(date, datetime.datetime):
return 1 + int((date.date() - self.start_day).days / 7)
return 1 + int((date - self.start_day).days / 7)
@property
def periods(self) -> List[dataClasses.Period]:
"""Get all of the periods of the year.
Returns:
List[Period]: All the periods of the year
"""
if hasattr(self, "periods_") and self.periods_:
return self.periods_
json = self.func_options["dataSec"]["data"]["General"]["ListePeriodes"]
return [dataClasses.Period(self, j) for j in json]
def keep_alive(self) -> _KeepAlive:
"""
Returns a context manager to keep the connection alive. When inside the context manager,
it sends a "Navigation" request to the server after 5 minutes of inactivity from another thread.
"""
return _KeepAlive(self)
def refresh(self) -> None:
"""
Now this is the true jank part of this program. It refreshes the connection if something went wrong.
This is the classical procedure if something is broken.
"""
logging.debug("Reinitialisation")
self.communication.session.close()
if self.ent:
cookies = self.ent(
self.username, self.password, pronote_url=self.pronote_url
)
else:
cookies = None
self.communication = _Communication(self.pronote_url, cookies)
self.attributes, self.func_options = self.communication.initialise(
self.client_identifier
)
# set up encryption
self.encryption = _Encryption()
self.encryption.aes_iv = self.communication.encryption.aes_iv
self._login()
self.periods_ = None
self.periods_ = self.periods
self.week = self.get_week(datetime.date.today())
self._expired = True
def session_check(self) -> bool:
"""Checks if the session has expired and refreshes it if it had (returns bool signifying if it was expired)"""
self.post("Navigation", 7, {"onglet": 7, "ongletPrec": 7})
if self._expired:
self._expired = False
return True
return False
def post(
self,
function_name: str,
onglet: Optional[int] = None,
data: Optional[dict] = None,
) -> dict:
"""Preforms a raw post to the PRONOTE server. Adds signature, then passes it to _Communication.post
Args:
function_name (str)
onglet (int)
data (dict)
Returns:
dict: Raw JSON
"""
post_data = {}
if onglet:
post_data["Signature"] = {"onglet": onglet}
if data:
post_data["data"] = data
try:
return self.communication.post(function_name, post_data)
except PronoteAPIError as e:
if isinstance(e, ExpiredObject):
raise e
log.info(
f"Have you tried turning it off and on again? ERROR: {e.pronote_error_code} | {e.pronote_error_msg}"
)
# prevent refresh recursion
if self._refreshing:
raise e
else:
self._refreshing = True
self.refresh()
self._refreshing = False
return self.communication.post(function_name, post_data)
def request_qr_code_data(self, pin: str) -> dict:
"""
Requests data for a new login QR code. This data can be then used with :meth:`.qrcode_login`.
Args:
pin (str): Four digit pin to use for the QR code
"""
req = self.post("JetonAppliMobile", 7, {"code": pin})
return {
# ugly way to add the mobile prefix to the url
"url": re.sub(
r"/(?:mobile.){,1}(\w+).html$", r"/mobile.\1.html", self.pronote_url
),
**req["dataSec"]["data"],
}
class Client(ClientBase):
"""
A PRONOTE client.
Args:
pronote_url (str): URL of the server
username (str)
password (str)
ent (Optional[Callable]): Cookies for ENT connections
mode (bool): internal option
uuid (str): Your application UUID (any unique string)
account_pin (Optional[str]): 2FA PIN to the account.
Consider deleting it after logging in.
.. code-block:: python
del client.account_pin
client_identifier (Optional[str]):
Identificator of this client provided by PRONOTE. PRONOTE uses this
to remember a browser / client.
device_name (Optional[str]): A name for registering this client as a device.
"""
def lessons(
self,
date_from: Union[datetime.date, datetime.datetime],
date_to: Optional[Union[datetime.date, datetime.datetime]] = None,
) -> List[dataClasses.Lesson]:
"""Gets all lessons in a given timespan.
Args:
date_from (Union[datetime.date, datetime.datetime]): first date
date_to (Union[datetime.date, datetime.datetime]): second date,
if None, then to the end of day_from
Returns:
List[Lesson]: List of lessons
"""
user = self.parametres_utilisateur["dataSec"]["data"]["ressource"]
data = {
"ressource": user,
"avecAbsencesEleve": False,
"avecConseilDeClasse": True,
"estEDTPermanence": False,
"avecAbsencesRessource": True,
"avecDisponibilites": True,
"avecInfosPrefsGrille": True,
"Ressource": user,
}
output = []
# convert date to datetime
if isinstance(date_from, datetime.date):
date_from = datetime.datetime.combine(
date_from, datetime.datetime.min.time()
)
if isinstance(date_to, datetime.date):
date_to = datetime.datetime.combine(date_to, datetime.datetime.min.time())
if not date_to:
date_to = datetime.datetime.combine(date_from, datetime.datetime.max.time())
first_week = self.get_week(date_from)
last_week = self.get_week(date_to)
# getting lessons for all the weeks.
for week in range(first_week, last_week + 1):
data["NumeroSemaine"] = data["numeroSemaine"] = week
response = self.post("PageEmploiDuTemps", 16, data)
l_list = response["dataSec"]["data"]["ListeCours"]
for lesson in l_list:
output.append(dataClasses.Lesson(self, lesson))
# since we only have week precision, we need to make it more precise on our own
return [lesson for lesson in output if date_from <= lesson.start <= date_to]
def export_ical(self) -> str:
"""Constructs the client's ICal URL"""
response = self.post("PageInfosPerso", 16, None)
try:
ical_params = response["dataSec"]["data"]["iCal"]["liste"]["V"][0]
ical = ical_params["paramICal"]
suppl = ical_params["paramSuppl"]
except KeyError as e:
raise ParsingError(
"Could not parse ICal params",
response,
("dataSec", "data", "iCal", "liste", "V"),
) from e
ver = self.func_options["dataSec"]["data"]["General"]["versionPN"]
return f"{self.communication.root_site}/ical/mesinformations.ics?icalsecurise={ical}&version={ver}&param={suppl}"
def homework(
self, date_from: datetime.date, date_to: Optional[datetime.date] = None
) -> List[dataClasses.Homework]:
"""Get homework between two given points.
Args:
date_from (datetime): The first date
date_to (datetime): The second date. If unspecified to the end of the year.
Returns:
List[Homework]: Homework between two given points
"""
if not date_to:
date_to = datetime.datetime.strptime(
self.func_options["dataSec"]["data"]["General"]["DerniereDate"]["V"],
"%d/%m/%Y",
).date()
json_data = {
"domaine": {
"_T": 8,
"V": f"[{self.get_week(date_from)}..{self.get_week(date_to)}]",
}
}
response = self.post("PageCahierDeTexte", 88, json_data)
h_list = response["dataSec"]["data"]["ListeTravauxAFaire"]["V"]
out = []
for h in h_list:
hw = dataClasses.Homework(self, h)
if date_from <= hw.date <= date_to:
out.append(hw)
return out
def generate_timetable_pdf(
self,
day: Optional[datetime.date] = None,
portrait: bool = False,
overflow: int = 0,
font_size: Tuple[int, int] = (8, 3),
) -> str:
"""Generate a PDF timetable.
Args:
day (Optional[datetime.date]): the day for which to create the timetable, the whole week is always generated
portrait (bool): switches the timetable to portrait mode
overflow (int): Controls overflow / formatting of lesson names.
* ``0``: no overflow
* ``1``: overflow to the same page, long names printed on the bottom
* ``2``: overflow to a separate page
font_size (Tuple[int, int]): font size restrictions, first element is the *preferred* font size, and second is the *minimum*
"""
user = {
"G": 4,
"N": self.parametres_utilisateur["dataSec"]["data"]["ressource"]["N"],
}
data = {
"options": {
"portrait": portrait,
"taillePolice": font_size[0],
"taillePoliceMin": font_size[1],
"couleur": 1,
"renvoi": overflow,
"uneGrilleParSemaine": False,
"inversionGrille": False,
"ignorerLesPlagesSansCours": False,
"estEDTAnnuel": day is None,
},
"genreGenerationPDF": 0,
"estPlanning": False,
"estPlanningParRessource": False,
"estPlanningOngletParJour": False,
"estPlanningParJour": False,
"indiceJour": 0,
"ressource": user,
"ressources": [user],
"domaine": {"_T": 8, "V": f"[{self.get_week(day) if day else 0}]"},
"avecCoursAnnules": True,
"grilleInverse": False,
}
response = self.post("GenerationPDF", 16, data)
return (
self.communication.root_site + "/" + response["dataSec"]["data"]["url"]["V"]
)
def get_recipients(self) -> List[dataClasses.Recipient]:
"""Get recipients for new discussion
Returns:
List[Recipient]: list of available recipients
"""
# add teacher
data = {"onglet": {"N": 0, "G": 3}}
recipients = self.post("ListeRessourcesPourCommunication", 131, data)[
"dataSec"
]["data"]["listeRessourcesPourCommunication"]["V"]
# add staff
data = {"onglet": {"N": 0, "G": 34}}
recipients += self.post("ListeRessourcesPourCommunication", 131, data)[
"dataSec"
]["data"]["listeRessourcesPourCommunication"]["V"]
return [dataClasses.Recipient(self, r) for r in recipients]
def get_teaching_staff(self) -> List[dataClasses.TeachingStaff]:
"""Get the teacher list
Returns:
List[TeachingStaff]: list of teachers and other staff
"""
# add teacher
teachers = self.post("PageEquipePedagogique", 37)["dataSec"]["data"]["liste"][
"V"
]
return [dataClasses.TeachingStaff(t) for t in teachers]
# TODO: change to "subject"
def new_discussion(
self, subjet: str, message: str, recipients: List[dataClasses.Recipient]
) -> None:
"""Create a new discussion
Args:
subjet (str): subject of the message
message (str): content of the message
recipients (List[Recipient])
"""
recipients_json = [{"N": r.id, "G": r._type, "L": r.name} for r in recipients]
data = {
"objet": subjet,
"contenu": message,
"listeDestinataires": recipients_json,
}
self.post("SaisieMessage", 131, data)
def discussions(self, only_unread: bool = False) -> List[dataClasses.Discussion]:
"""Gets all the discussions in the discussions tab"""
discussions = self.post(
"ListeMessagerie", 131, {"avecMessage": True, "avecLu": not only_unread}
)
labels = {
l["N"]: l["G"]
for l in discussions["dataSec"]["data"]["listeEtiquettes"]["V"]
}
return [
dataClasses.Discussion(self, d, labels)
for d in discussions["dataSec"]["data"]["listeMessagerie"]["V"]
if d.get("estUneDiscussion") and d.get("profondeur", 1) == 0
]
def information_and_surveys(
self,
date_from: Optional[datetime.datetime] = None,
date_to: Optional[datetime.datetime] = None,
only_unread: bool = False,
) -> List[dataClasses.Information]:
"""Gets all the information and surveys in the information and surveys tab.
Args:
only_unread (bool): Return only unread information
date_from (datetime.datetime): Since datetime (included)
date_to (datetime.datetime): Until datetime (excluded)
"""
response = self.post(
"PageActualites", 8, {"modesAffActus": {"_T": 26, "V": "[0..3]"}}
)
info = []
for liste in response["dataSec"]["data"]["listeModesAff"]:
info += [
dataClasses.Information(self, info)
for info in liste["listeActualites"]["V"]
]
if only_unread:
info = [i for i in info if not i.read]
if date_from is not None:
info = list(
filter(
lambda i: i.start_date is not None and date_from <= i.start_date, # type: ignore
info,
)
)
if date_to is not None:
info = list(
filter(
lambda i: i.start_date is not None and i.start_date < date_to, # type: ignore
info,
)
)
return info
def menus(
self, date_from: datetime.date, date_to: Optional[datetime.date] = None
) -> List[dataClasses.Menu]:
"""Get menus between two given points.
Args:
date_from (datetime): The first date
date_to (datetime): The second date. If unspecified to the end of the year.
Returns:
List[Menu]: Menu between two given points
"""
output = []
if not date_to:
date_to = date_from
first_day = date_from - datetime.timedelta(days=date_from.weekday())
# getting menus for all the weeks.
while first_day <= date_to:
data = {"date": {"_T": 7, "V": first_day.strftime("%d/%m/%Y") + " 0:0:0"}}
response = self.post("PageMenus", 10, data)
l_list = response["dataSec"]["data"]["ListeJours"]["V"]
for day in l_list:
for menu in day["ListeRepas"]["V"]:
menu["Date"] = day["Date"]
output.append(dataClasses.Menu(self, menu))
first_day += datetime.timedelta(days=7)
# since we only have week precision, we need to make it more precise on our own
return [menu for menu in output if date_from <= menu.date <= date_to]
@property
def current_period(self) -> dataClasses.Period:
"""the current period"""
onglets = self.parametres_utilisateur["dataSec"]["data"]["ressource"][
"listeOngletsPourPeriodes"
]["V"]
# get onglet with number 198 (mes notes), otherwise fallback to the
# first one in the list
onglet = next(filter(lambda x: x.get("G") == 198, onglets), onglets[0])
id_period = onglet["periodeParDefaut"]["V"]["N"]
return dataClasses.Util.get(self.periods, id=id_period)[0]
class ParentClient(Client):
"""
A parent PRONOTE client.
Args:
pronote_url (str): URL of the server
username (str)
password (str)
ent (Optional[Callable]): Cookies for ENT connections
mode (bool): internal option
uuid (str): Your application UUID (any unique string)
account_pin (Optional[str]): 2FA PIN to the account.
Consider deleting it after logging in.
.. code-block:: python
del client.account_pin
client_identifier (Optional[str]):
Identificator of this client provided by PRONOTE. PRONOTE uses this
to remember a browser / client.
device_name (Optional[str]): A name for registering this client as a device.
Attributes:
children (List[ClientInfo]): List of sub-clients representing all the
children connected to the main parent account.
"""
def __init__(
self,
pronote_url: str,
username: str = "",
password: str = "",
ent: Optional[Callable] = None,
mode: str = "normal",
uuid: str = "",
account_pin: Optional[str] = None,
client_identifier: Optional[str] = None,
device_name: Optional[str] = None,
) -> None:
super().__init__(
pronote_url,
username,
password,
ent,
mode,
uuid,
account_pin,
client_identifier,
device_name,
)
self.children: List[dataClasses.ClientInfo] = []
for c in self.parametres_utilisateur["dataSec"]["data"]["ressource"][
"listeRessources"
]:
self.children.append(dataClasses.ClientInfo(self, c))
if not self.children:
raise ChildNotFound("No children were found.")
self._selected_child: dataClasses.ClientInfo = self.children[0]
self.parametres_utilisateur["dataSec"]["data"][
"ressource"
] = self._selected_child.raw_resource
def set_child(self, child: Union[str, dataClasses.ClientInfo]) -> None:
"""Select a child
Args:
child (Union[str, ClientInfo]): Name or ClientInfo of a child.
"""
if not isinstance(child, dataClasses.ClientInfo):
candidates = dataClasses.Util.get(self.children, name=child)
c = candidates[0] if candidates else None
else:
c = child
if not c:
raise ChildNotFound(f"A child with the name {child} was not found.")
self._selected_child = c
self.parametres_utilisateur["dataSec"]["data"][
"ressource"
] = self._selected_child.raw_resource
def post(
self,
function_name: str,
onglet: Optional[int] = None,
data: Optional[dict] = None,
) -> dict:
"""Preforms a raw post to the PRONOTE server.
Adds signature, then passes it to _Communication.post
Args:
function_name (str)
onglet (int)
data (dict)
Returns:
Raw JSON
"""
post_data = {}
if onglet:
post_data["Signature"] = {
"onglet": onglet,
"membre": {"N": self._selected_child.id, "G": 4},
}
if data:
post_data["data"] = data
try:
return self.communication.post(function_name, post_data)
except PronoteAPIError as e:
if isinstance(e, ExpiredObject):
raise e
log.info(
f"Have you tried turning it off and on again? ERROR: {e.pronote_error_code} | {e.pronote_error_msg}"
)
self.refresh()
return self.communication.post(function_name, post_data)
class VieScolaireClient(ClientBase):
"""A PRONOTE client for Vie Scolaire accounts.
Args:
pronote_url (str): URL of the server
username (str)
password (str)
ent (Optional[Callable]): Cookies for ENT connections
mode (bool): internal option
uuid (str): Your application UUID (any unique string)
account_pin (Optional[str]): 2FA PIN to the account.
Consider deleting it after logging in.
.. code-block:: python
del client.account_pin
client_identifier (Optional[str]):
Identificator of this client provided by PRONOTE. PRONOTE uses this
to remember a browser / client.
device_name (Optional[str]): A name for registering this client as a device.
Attributes:
classes (List[StudentClass]): List of all classes this account has access to.
"""
def __init__(
self,
pronote_url: str,
username: str = "",
password: str = "",
ent: Optional[Callable] = None,
mode: str = "normal",
uuid: str = "",
account_pin: Optional[str] = None,
client_identifier: Optional[str] = None,
device_name: Optional[str] = None,
) -> None:
super().__init__(
pronote_url,
username,
password,
ent,
mode,
uuid,
account_pin,
client_identifier,
device_name,
)
self.classes = [
dataClasses.StudentClass(self, json)
for json in self.parametres_utilisateur["dataSec"]["data"]["listeClasses"][
"V"
]
]