schoolapp-api 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ """
2
+ Professional School App API Client
3
+ """
4
+
5
+ from .school_app_client import SchoolAppClient
6
+ from .http_client import HTTPClient
7
+ from .auth import AuthManager
8
+
9
+ __version__ = "2.0.0"
10
+ __all__ = ["SchoolAppClient", "HTTPClient", "AuthManager"]
schoolapp_api/auth.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ Authentication and CSRF token management
3
+ """
4
+ import re
5
+ import logging
6
+ from schoolapp_api.constants import LOGIN_URL
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class AuthManager:
11
+ """Handles authentication and CSRF token management"""
12
+
13
+ def __init__(self, http_client, base_url):
14
+ self.http_client = http_client
15
+ self.base_url = base_url
16
+ self.login_url = LOGIN_URL
17
+ self.csrf_token = None
18
+ self.logged_in = False
19
+
20
+ @staticmethod
21
+ def extract_csrf_token(html_content):
22
+ """Extract CSRF token from HTML content"""
23
+ match = re.search(r'name="_csrf"\s+value="([^"]+)"', html_content)
24
+ return match.group(1) if match else None
25
+
26
+ def update_csrf_token(self, html_content):
27
+ """Update CSRF token from HTML content"""
28
+ new_csrf = self.extract_csrf_token(html_content)
29
+ if new_csrf:
30
+ self.csrf_token = new_csrf
31
+ return True
32
+ return False
33
+
34
+ def login(self, email, password):
35
+ """Login and maintain session"""
36
+ if self.logged_in:
37
+ logger.info("Already logged in!")
38
+ return True
39
+
40
+ logger.info("Fetching login page...")
41
+ code, url, content = self.http_client.get(self.login_url)
42
+
43
+ if not content or not self.update_csrf_token(content):
44
+ logger.error("Failed to fetch login page or retrieve CSRF token.")
45
+ return False
46
+
47
+ logger.info(f"Got CSRF token, logging in...")
48
+ login_data = {
49
+ '_csrf': self.csrf_token,
50
+ 'email': email,
51
+ 'password': password
52
+ }
53
+
54
+ code, response_url, content = self.http_client.post(
55
+ self.login_url,
56
+ login_data,
57
+ referer=self.login_url
58
+ )
59
+
60
+ if response_url and ("/login" in response_url and "error" in response_url):
61
+ logger.error("Login failed! Invalid credentials.")
62
+ return False
63
+
64
+ logger.info("Login successful!")
65
+ self.logged_in = True
66
+ return True
67
+
68
+ def is_logged_in(self):
69
+ """Check if currently logged in"""
70
+ return self.logged_in
71
+
72
+ def refresh_csrf_from_url(self, url):
73
+ """Fetch a fresh CSRF token from a specific page"""
74
+ try:
75
+ code, response_url, content = self.http_client.get(url)
76
+ if content and self.update_csrf_token(content):
77
+ return True
78
+ return False
79
+ except Exception as e:
80
+ logger.warning(f"CSRF refresh warning: {e}")
81
+ return False
@@ -0,0 +1,38 @@
1
+ """
2
+ Centralized constants for the School App API
3
+ """
4
+
5
+ BASE_URL = "https://schoolapp.ensam-umi.ac.ma"
6
+
7
+ # Endpoints
8
+ LOGIN_URL = f"{BASE_URL}/login"
9
+ INDEX_URL = f"{BASE_URL}/index"
10
+ MODULES_URL = f"{BASE_URL}/plan-etudes-view/modules"
11
+ FILIERES_URL = f"{BASE_URL}/plan-etudes-view/filieres"
12
+
13
+ # Student Grade Endpoints
14
+ CURRENT_ELEM_URL = f"{BASE_URL}/student/noteselem-encours"
15
+ CURRENT_MOD_URL = f"{BASE_URL}/student/notesmod-encours"
16
+ ELEM_URL = f"{BASE_URL}/student/noteselem"
17
+ MOD_URL = f"{BASE_URL}/student/notesmod"
18
+ ANNEES_URL = f"{BASE_URL}/student/notesannee"
19
+ SEMESTRES_URL = f"{BASE_URL}/student/notessem"
20
+
21
+ # Absence and Sanctions Endpoints
22
+ ABSENCES_URL = f"{BASE_URL}/student/absence/bilan"
23
+ SANCTIONS_URL = f"{BASE_URL}/student/absence/sanctions"
24
+
25
+ # Stats Endpoints
26
+ EVAL_STAT_URL = f"{BASE_URL}/notes-stat/elemevalsat"
27
+ MOD_STAT_URL = f"{BASE_URL}/notes-stat/modsat"
28
+ ANNEE_STAT_URL = f"{BASE_URL}/notes-stat/anneesat"
29
+ SEM_STAT_URL = f"{BASE_URL}/notes-stat/semsat"
30
+
31
+ # Default Headers
32
+ DEFAULT_HEADERS = {
33
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
34
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
35
+ 'Accept-Language': 'en-US,en;q=0.9,fr;q=0.8',
36
+ 'Connection': 'keep-alive',
37
+ 'Upgrade-Insecure-Requests': '1'
38
+ }
@@ -0,0 +1,67 @@
1
+ """
2
+ HTTP Client with session and cookie management
3
+ """
4
+ import urllib.request
5
+ import urllib.parse
6
+ import urllib.error
7
+ import http.cookiejar
8
+ import logging
9
+
10
+ from schoolapp_api.constants import DEFAULT_HEADERS
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ class HTTPClient:
15
+ """Base HTTP client for making authenticated requests"""
16
+
17
+ def __init__(self, base_url):
18
+ self.base_url = base_url
19
+ self.cookie_jar = http.cookiejar.CookieJar()
20
+ opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cookie_jar))
21
+ urllib.request.install_opener(opener)
22
+ self.headers = DEFAULT_HEADERS.copy()
23
+
24
+ def get(self, url, params=None):
25
+ """Perform GET request"""
26
+ try:
27
+ if params:
28
+ query_string = urllib.parse.urlencode(params, doseq=True)
29
+ separator = "&" if "?" in url else "?"
30
+ url = f"{url}{separator}{query_string}"
31
+
32
+ req = urllib.request.Request(url, headers=self.headers, method="GET")
33
+
34
+ with urllib.request.urlopen(req) as response:
35
+ code = response.getcode()
36
+ response_url = response.geturl()
37
+ content = response.read().decode("utf-8")
38
+ return code, response_url, content
39
+
40
+ except urllib.error.HTTPError as e:
41
+ logger.error(f"HTTP Error {e.code}: {e.reason}")
42
+ return e.code, None, None
43
+ except Exception as e:
44
+ logger.error(f"GET Error: {e}")
45
+ return None, None, None
46
+
47
+ def post(self, url, data, referer=None):
48
+ """Perform POST request"""
49
+ try:
50
+ encoded_data = urllib.parse.urlencode(data).encode('utf-8')
51
+ req = urllib.request.Request(url, data=encoded_data, headers=self.headers)
52
+ req.add_header('Content-Type', 'application/x-www-form-urlencoded')
53
+ req.add_header('Origin', self.base_url)
54
+ if referer:
55
+ req.add_header('Referer', referer)
56
+
57
+ with urllib.request.urlopen(req) as response:
58
+ code = response.getcode()
59
+ response_url = response.geturl()
60
+ content = response.read().decode('utf-8')
61
+ return code, response_url, content
62
+ except urllib.error.HTTPError as e:
63
+ logger.error(f"HTTP Error {e.code}: {e.reason}")
64
+ return e.code, None, None
65
+ except Exception as e:
66
+ logger.error(f"POST Error: {e}")
67
+ return None, None, None
@@ -0,0 +1,11 @@
1
+ from .grades_manager import GradesManager
2
+ from .attendance_manager import AttendanceManager
3
+ from .profile_manager import ProfileManager
4
+ from .course_manager import CourseManager
5
+
6
+ __all__ = [
7
+ 'GradesManager',
8
+ 'AttendanceManager',
9
+ 'ProfileManager',
10
+ 'CourseManager'
11
+ ]
@@ -0,0 +1,15 @@
1
+ """
2
+ Manager for attendance and sanctions
3
+ """
4
+ from schoolapp_api.managers.base_manager import BaseManager
5
+ from schoolapp_api.constants import ABSENCES_URL, SANCTIONS_URL
6
+ from schoolapp_api import parsers
7
+
8
+ class AttendanceManager(BaseManager):
9
+ """Handles fetching of absences and sanctions"""
10
+
11
+ def get_absences(self):
12
+ return self.get_json_or_parse(ABSENCES_URL, parsers.absences)
13
+
14
+ def get_sanctions(self):
15
+ return self.get_json_or_parse(SANCTIONS_URL, parsers.sanctions)
@@ -0,0 +1,36 @@
1
+ """
2
+ Base client for shared manager logic
3
+ """
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class BaseManager:
9
+ """Base class for all managers"""
10
+
11
+ def __init__(self, client):
12
+ self.client = client
13
+ self.http_client = client.http_client
14
+ self.auth = client.auth
15
+
16
+ def ensure_logged_in(self):
17
+ """Ensures the client is logged in before making requests"""
18
+ if not self.auth.is_logged_in():
19
+ logger.error("Not logged in! Call login() first.")
20
+ return False
21
+ return True
22
+
23
+ def get_json_or_parse(self, url, parser, params=None, refresh_csrf=True):
24
+ """Helper to fetch content, update CSRF, and parse"""
25
+ if not self.ensure_logged_in():
26
+ return None
27
+
28
+ code, url, content = self.http_client.get(url, params=params)
29
+
30
+ if not content:
31
+ return None
32
+
33
+ if refresh_csrf:
34
+ self.auth.update_csrf_token(content)
35
+
36
+ return parser.parse(content)
@@ -0,0 +1,45 @@
1
+ """
2
+ Manager for courses and study plans
3
+ """
4
+ import logging
5
+ from schoolapp_api.managers.base_manager import BaseManager
6
+ from schoolapp_api.constants import MODULES_URL
7
+ from schoolapp_api import parsers
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class CourseManager(BaseManager):
12
+ """Handles fetching of modules and study plans"""
13
+
14
+ def get_modules(self, niveau, filiere, semestre, refresh_csrf=False):
15
+ """Fetch modules for specific niveau/filiere/semestre"""
16
+ if not self.ensure_logged_in():
17
+ return None
18
+
19
+ # Always GET modules page first to refresh CSRF token
20
+ logger.info("Opening modules page to refresh state...")
21
+ code, url, content = self.http_client.get(MODULES_URL)
22
+
23
+ if not content:
24
+ return None
25
+
26
+ self.auth.update_csrf_token(content)
27
+
28
+ if refresh_csrf:
29
+ self.auth.refresh_csrf_from_url(MODULES_URL)
30
+
31
+ logger.info(f"Fetching modules for {niveau} {filiere} {semestre}...")
32
+ modules_data = {
33
+ '_csrf': self.auth.csrf_token,
34
+ 'niveau': niveau,
35
+ 'filiere': filiere,
36
+ 'semestre': semestre
37
+ }
38
+
39
+ code, response_url, content = self.http_client.post(
40
+ MODULES_URL,
41
+ modules_data,
42
+ referer=MODULES_URL
43
+ )
44
+
45
+ return parsers.modules.parse(content)
@@ -0,0 +1,35 @@
1
+ """
2
+ Manager for grades and academic results
3
+ """
4
+ from schoolapp_api.managers.base_manager import BaseManager
5
+ from schoolapp_api.constants import (
6
+ CURRENT_ELEM_URL, CURRENT_MOD_URL, ELEM_URL, MOD_URL,
7
+ ANNEES_URL, SEMESTRES_URL
8
+ )
9
+ from schoolapp_api import parsers, types
10
+
11
+ class GradesManager(BaseManager):
12
+ """Handles fetching of grades for elements, modules, years, and semesters"""
13
+
14
+ def get_element_notes(self, current=False):
15
+ url = CURRENT_ELEM_URL if current else ELEM_URL
16
+ data = self.get_json_or_parse(url, parsers.note_elem)
17
+ return [types.Element(self.client, item) for item in data] if data else []
18
+
19
+ def get_module_notes(self, current=False):
20
+ url = CURRENT_MOD_URL if current else MOD_URL
21
+ data = self.get_json_or_parse(url, parsers.note_mod)
22
+ # Note: In the original code, get_mod_note returned types.Element,
23
+ # but it should probably return types.Module if it's for modules.
24
+ # Looking at original school_app_client.py:L272 it used types.Element.
25
+ # I'll stick to what was there but maybe use Module if appropriate.
26
+ # types/Module.py exists.
27
+ return [types.Module(self.client, item) for item in data] if data else []
28
+
29
+ def get_years(self):
30
+ data = self.get_json_or_parse(ANNEES_URL, parsers.annees)
31
+ return [types.Annee(self.client, item) for item in data] if data else []
32
+
33
+ def get_semesters(self):
34
+ data = self.get_json_or_parse(SEMESTRES_URL, parsers.semestres)
35
+ return [types.Semestre(self.client, item) for item in data] if data else []
@@ -0,0 +1,15 @@
1
+ """
2
+ Manager for student profile and programs
3
+ """
4
+ from schoolapp_api.managers.base_manager import BaseManager
5
+ from schoolapp_api.constants import INDEX_URL, FILIERES_URL
6
+ from schoolapp_api import parsers
7
+
8
+ class ProfileManager(BaseManager):
9
+ """Handles fetching of student profile and filieres"""
10
+
11
+ def get_profile(self):
12
+ return self.get_json_or_parse(INDEX_URL, parsers.profile)
13
+
14
+ def get_filieres(self):
15
+ return self.get_json_or_parse(FILIERES_URL, parsers.filieres)
@@ -0,0 +1,6 @@
1
+ """
2
+ Parsers for School App API HTML content
3
+ """
4
+ from . import absences, annees, filieres, modules, note_elem, note_mod, profile, sanctions, semestres, stats
5
+
6
+ __all__ = ["absences", "annees", "filieres", "modules", "note_elem", "note_mod", "profile", "sanctions", "semestres", "stats"]
@@ -0,0 +1,45 @@
1
+ from bs4 import BeautifulSoup
2
+ import re
3
+
4
+ def parse(html_content):
5
+ """Parse HTML content containing absence details."""
6
+ soup = BeautifulSoup(html_content, 'html.parser')
7
+ result = {"summary": [], "details": []}
8
+
9
+ tables = soup.find_all('table', class_='table table-striped table-sm')
10
+ if len(tables) < 2:
11
+ return result
12
+
13
+ # Summary
14
+ for row in tables[0].find_all('tr')[1:]:
15
+ cells = row.find_all('td')
16
+ if len(cells) >= 4:
17
+ result["summary"].append({
18
+ "CodeElem": cells[0].get_text(strip=True),
19
+ "Intitule": cells[1].get_text(strip=True),
20
+ "Non_Justifiee": int(cells[2].get_text(strip=True) or 0),
21
+ "Justifiee": int(cells[3].get_text(strip=True) or 0)
22
+ })
23
+
24
+ # Details
25
+ for row in tables[1].find_all('tr')[1:]:
26
+ cells = row.find_all('td')
27
+ if len(cells) >= 5:
28
+ result["details"].append({
29
+ "Element": cells[0].get_text(strip=True),
30
+ "Date": cells[1].get_text(strip=True),
31
+ "Seance": cells[2].get_text(strip=True),
32
+ "Justif": cells[3].get_text(strip=True).lower() == "true",
33
+ "Remarques": cells[4].get_text(strip=True)
34
+ })
35
+
36
+ # Semester info
37
+ alerts = soup.find_all('div', class_='alert alert-info')
38
+ for alert in alerts:
39
+ text = alert.get_text()
40
+ if "semestre" in text.lower():
41
+ match = re.search(r'S\d', text)
42
+ if match:
43
+ result["semestre"] = match.group()
44
+
45
+ return result
@@ -0,0 +1,37 @@
1
+ from bs4 import BeautifulSoup
2
+
3
+ def parse(html_content):
4
+ """Parse HTML content containing annual averages."""
5
+ soup = BeautifulSoup(html_content, 'html.parser')
6
+ table = soup.find('table', class_='table table-striped table-sm') or soup.find('table')
7
+
8
+ if not table:
9
+ return []
10
+
11
+ headers = [th.get_text(strip=True) for th in table.find_all('th')]
12
+ if len(headers) < 9:
13
+ headers = ["Niveau", "Filiere", "AU", "Statut", "Moy Année", "PJ", "Decision", "Classement", "Releve de Notes"]
14
+
15
+ data = []
16
+ tbody = table.find('tbody') or table
17
+
18
+ for row in tbody.find_all('tr'):
19
+ cells = row.find_all('td')
20
+ if len(cells) >= 9:
21
+ row_data = {}
22
+ for i, cell in enumerate(cells[:9]):
23
+ if i == 8: # Download link
24
+ link = cell.find('a')
25
+ row_data[headers[i]] = link.get('href') if link else cell.get_text(strip=True)
26
+ else:
27
+ val = cell.get_text(strip=True)
28
+ if headers[i] in ["Moy Année", "PJ"]:
29
+ try:
30
+ row_data[headers[i]] = float(val) if val and val != "--" else None
31
+ except ValueError:
32
+ row_data[headers[i]] = val
33
+ else:
34
+ row_data[headers[i]] = val
35
+ data.append(row_data)
36
+
37
+ return data
@@ -0,0 +1,63 @@
1
+ from bs4 import BeautifulSoup
2
+
3
+ def parse(html_content):
4
+ """
5
+ Parse HTML content containing a table of academic programs and return structured data.
6
+
7
+ Args:
8
+ html_content (str): HTML content as a string
9
+
10
+ Returns:
11
+ list: List of dictionaries with program information
12
+ """
13
+ soup = BeautifulSoup(html_content, 'html.parser')
14
+
15
+ # Find the table - using multiple selectors to be safe
16
+ table = soup.find('table', class_='table table-striped table-sm mb-1 display')
17
+
18
+ if not table:
19
+ # Try alternative selector
20
+ table = soup.find('table', {'class': 'table'})
21
+
22
+ if not table:
23
+ return []
24
+
25
+ # Extract table headers
26
+ headers = []
27
+ thead = table.find('thead')
28
+ if thead:
29
+ header_row = thead.find('tr')
30
+ if header_row:
31
+ for th in header_row.find_all('th'):
32
+ headers.append(th.get_text(strip=True))
33
+
34
+ # If headers weren't found in thead, use hardcoded ones
35
+ if not headers:
36
+ headers = ["Code", "Intitule", "Departement", "Accreditation", "Descriptif", "Plan_Etudes"]
37
+
38
+ # Clean up header names (replace underscores with spaces, fix capitalization)
39
+ cleaned_headers = []
40
+ for header in headers:
41
+ if header == "Plan_Etudes":
42
+ cleaned_headers.append("Plan d'Etude")
43
+ else:
44
+ cleaned_headers.append(header)
45
+
46
+ # Extract table rows
47
+ data = []
48
+ tbody = table.find('tbody')
49
+
50
+ if not tbody:
51
+ tbody = table # If no tbody, search in table directly
52
+
53
+ for row in tbody.find_all('tr'):
54
+ cells = row.find_all('td')
55
+
56
+ if len(cells) >= len(cleaned_headers):
57
+ row_data = {}
58
+ for i, cell in enumerate(cells[:len(cleaned_headers)]):
59
+ cell_text = cell.get_text(strip=True)
60
+ row_data[cleaned_headers[i]] = cell_text
61
+ data.append(row_data)
62
+
63
+ return data
@@ -0,0 +1,55 @@
1
+ from bs4 import BeautifulSoup
2
+
3
+ def parse(html_content):
4
+ """Parse HTML content containing modules and their elements."""
5
+ soup = BeautifulSoup(html_content, "html.parser")
6
+ modules = {}
7
+
8
+ main_table = soup.find("table", class_="table table-striped table-sm mb-1 display")
9
+ if not main_table:
10
+ return {} # Return empty instead of raising error for robustness
11
+
12
+ tbody = main_table.find("tbody")
13
+ if not tbody:
14
+ return {}
15
+
16
+ rows = tbody.find_all("tr", recursive=False)
17
+ current_mod = None
18
+
19
+ for row in rows:
20
+ classes = row.get("class", [])
21
+
22
+ if "clickable" in classes:
23
+ cols = row.find_all("td")
24
+ if len(cols) >= 10:
25
+ current_mod = cols[1].text.strip()
26
+ modules[current_mod] = {
27
+ "intitule": cols[2].text.strip(),
28
+ "niveau": cols[4].text.strip(),
29
+ "semestre": cols[5].text.strip(),
30
+ "vhmod": int(cols[6].text.strip() or 0),
31
+ "coef": float(cols[7].text.strip() or 0),
32
+ "seuil": float(cols[8].text.strip() or 0),
33
+ "eliminatoire": float(cols[9].text.strip() or 0),
34
+ "elements": []
35
+ }
36
+ elif "collapse" in classes and current_mod:
37
+ inner_table = row.find("table")
38
+ if inner_table and inner_table.find("tbody"):
39
+ for erow in inner_table.find("tbody").find_all("tr"):
40
+ ecol = erow.find_all("td")
41
+ if len(ecol) >= 10:
42
+ modules[current_mod]["elements"].append({
43
+ "code": ecol[0].text.strip(),
44
+ "intitule": ecol[1].text.strip(),
45
+ "vh_ctd": int(ecol[2].text.strip() or 0),
46
+ "vh_tp": int(ecol[3].text.strip() or 0),
47
+ "vh_eval": int(ecol[4].text.strip() or 0),
48
+ "coef_cc": float(ecol[5].text.strip() or 0),
49
+ "coef_ex": float(ecol[6].text.strip() or 0),
50
+ "coef_ecrit": float(ecol[7].text.strip() or 0),
51
+ "coef_tp": float(ecol[8].text.strip() or 0),
52
+ "coef_elem": float(ecol[9].text.strip() or 0),
53
+ })
54
+
55
+ return modules
@@ -0,0 +1,33 @@
1
+ from bs4 import BeautifulSoup
2
+
3
+ def parse(html_content):
4
+ """Parse HTML content containing current element grades."""
5
+ soup = BeautifulSoup(html_content, 'html.parser')
6
+ table = soup.find('table', class_='table table-striped table-sm') or soup.find('table')
7
+
8
+ if not table:
9
+ return []
10
+
11
+ headers = [th.get_text(strip=True) for th in table.find_all('th')]
12
+ if len(headers) < 10:
13
+ headers = ["CodeElem", "AU", "CC", "EX", "TP", "MoySO", "RAT", "MoySR", "Moy", "Dec"]
14
+
15
+ data = []
16
+ tbody = table.find('tbody') or table
17
+
18
+ for row in tbody.find_all('tr'):
19
+ cells = row.find_all('td')
20
+ if len(cells) >= 10:
21
+ row_data = {}
22
+ for i, cell in enumerate(cells[:10]):
23
+ val = cell.get_text(strip=True)
24
+ if headers[i] in ["CC", "EX", "TP", "MoySO", "RAT", "MoySR", "Moy"]:
25
+ try:
26
+ row_data[headers[i]] = float(val) if val and val != "--" else None
27
+ except ValueError:
28
+ row_data[headers[i]] = val
29
+ else:
30
+ row_data[headers[i]] = val
31
+ data.append(row_data)
32
+
33
+ return data
@@ -0,0 +1,33 @@
1
+ from bs4 import BeautifulSoup
2
+
3
+ def parse(html_content):
4
+ """Parse HTML content containing module grades."""
5
+ soup = BeautifulSoup(html_content, 'html.parser')
6
+ table = soup.find('table', class_='table table-striped table-sm') or soup.find('table')
7
+
8
+ if not table:
9
+ return []
10
+
11
+ headers = [th.get_text(strip=True) for th in table.find_all('th')]
12
+ if len(headers) < 4:
13
+ headers = ["CodeMod", "AU", "Moy", "Dec"]
14
+
15
+ data = []
16
+ tbody = table.find('tbody') or table
17
+
18
+ for row in tbody.find_all('tr'):
19
+ cells = row.find_all('td')
20
+ if len(cells) >= 4:
21
+ row_data = {}
22
+ for i, cell in enumerate(cells[:4]):
23
+ val = cell.get_text(strip=True)
24
+ if headers[i] == "Moy":
25
+ try:
26
+ row_data[headers[i]] = float(val) if val and val != "--" else None
27
+ except ValueError:
28
+ row_data[headers[i]] = val
29
+ else:
30
+ row_data[headers[i]] = val
31
+ data.append(row_data)
32
+
33
+ return data
@@ -0,0 +1,87 @@
1
+ from bs4 import BeautifulSoup
2
+ import re
3
+
4
+ def parse(html_content):
5
+ """
6
+ Parse HTML content containing user profile and return detailed user data.
7
+ """
8
+ soup = BeautifulSoup(html_content, 'html.parser')
9
+
10
+ result = {
11
+ "basic_info": {},
12
+ "academic_info": {},
13
+ "administrative_info": {},
14
+ "personal_info": {},
15
+ "family_info": {},
16
+ "contact_info": {},
17
+ "download_links": {},
18
+ "sections": {}
19
+ }
20
+
21
+ # Extract from sidebar
22
+ user_panel = soup.find('div', class_='user-panel')
23
+ if user_panel:
24
+ img = user_panel.find('img', class_='img-circle')
25
+ if img and img.get('src'):
26
+ result["basic_info"]["photo_url"] = img.get('src')
27
+
28
+ info_div = user_panel.find('div', class_='info')
29
+ if info_div:
30
+ spans = info_div.find_all('span', class_='d-block')
31
+ if len(spans) >= 2:
32
+ result["basic_info"]["full_name"] = spans[0].get_text(strip=True)
33
+ result["basic_info"]["role"] = spans[1].get_text(strip=True)
34
+
35
+ # Extract welcome message
36
+ alert_div = soup.find('div', class_='alert alert-info')
37
+ if alert_div:
38
+ result["basic_info"]["welcome_message"] = alert_div.get_text(strip=True)
39
+
40
+ # Find all tables
41
+ tables = soup.find_all('table', class_='table table-striped table-sm')
42
+
43
+ if len(tables) >= 2:
44
+ # Administrative data table
45
+ for row in tables[0].find_all('tr'):
46
+ cells = row.find_all(['th', 'td'])
47
+ if len(cells) >= 2:
48
+ key = cells[0].get_text(strip=True).rstrip(':').strip()
49
+ val = cells[1].get_text(strip=True)
50
+ result["administrative_info"][key] = val
51
+
52
+ # Personal data table
53
+ for row in tables[1].find_all('tr'):
54
+ cells = row.find_all(['th', 'td'])
55
+ if len(cells) >= 2:
56
+ key = cells[0].get_text(strip=True).rstrip(':').strip()
57
+ val = cells[1].get_text(strip=True)
58
+
59
+ if key in ["Code", "CNE/Masar", "Nom", "Prénom", "Nom Arabe", "Prénom Arabe", "CIN", "Sexe", "Date Naissance", "Nationalité", "Lieu_Naissance"]:
60
+ result["personal_info"][key] = val
61
+ elif key in ["Email", "Téléphone", "Adr_Parents", "Ville", "Tel_Parents"]:
62
+ result["contact_info"][key] = val
63
+ elif key in ["Série BAC", "Année BAC", "Niveau Accès", "Annee Accès", "Voie Accès", "Académie"]:
64
+ result["academic_info"][key] = val
65
+ elif key in ["Prof_Père", "Prof_Mère"]:
66
+ result["family_info"][key] = val
67
+ else:
68
+ result["personal_info"][key] = val
69
+
70
+ # Extract download links
71
+ attestation = soup.find('a', href=re.compile(r'attestation-scolarite'))
72
+ if attestation:
73
+ result["download_links"]["attestation_scolarite"] = attestation.get('href', '')
74
+
75
+ main_img = soup.find('img', width='100')
76
+ if main_img and main_img.get('src'):
77
+ result["basic_info"]["large_photo_url"] = main_img.get('src')
78
+
79
+ # Extract section titles
80
+ for h5 in soup.find_all('h5'):
81
+ text = h5.get_text(strip=True)
82
+ if "Situation Administrative" in text:
83
+ result["sections"]["administrative"] = text
84
+ elif "Données personnelles" in text:
85
+ result["sections"]["personal"] = text
86
+
87
+ return result
@@ -0,0 +1,42 @@
1
+ from bs4 import BeautifulSoup
2
+ import re
3
+
4
+ def parse(html_content):
5
+ """Parse HTML content containing disciplinary sanctions."""
6
+ soup = BeautifulSoup(html_content, 'html.parser')
7
+ result = {
8
+ "Absences_non_justifiees": 0,
9
+ "Absences_justifiees": 0,
10
+ "Sanction": "",
11
+ "Message": "",
12
+ "Elements_non_autorises": []
13
+ }
14
+
15
+ table = soup.find('table', class_='table table-striped table-sm') or soup.find('table')
16
+ if table:
17
+ for row in table.find_all('tr'):
18
+ cells = row.find_all('td')
19
+ if len(cells) == 2:
20
+ key = cells[0].get_text(strip=True)
21
+ val_cell = cells[1]
22
+ val_text = val_cell.get_text(strip=True)
23
+
24
+ if "non justifiées" in key:
25
+ result["Absences_non_justifiees"] = int(val_text or 0)
26
+ elif "justifiées" in key:
27
+ result["Absences_justifiees"] = int(val_text or 0)
28
+ elif key == "Sanction":
29
+ btn = val_cell.find('button')
30
+ result["Sanction"] = btn.get_text(strip=True) if btn else val_text
31
+ elif key == "Message":
32
+ result["Message"] = val_text
33
+ elif "pas autorisé" in key:
34
+ result["Elements_non_autorises"] = [b.get_text(strip=True) for b in val_cell.find_all('button', class_='elem')]
35
+
36
+ alert = soup.find('div', class_='alert alert-info')
37
+ if alert:
38
+ match = re.search(r'S\d', alert.get_text())
39
+ if match:
40
+ result["Semestre"] = match.group()
41
+
42
+ return result
@@ -0,0 +1,37 @@
1
+ from bs4 import BeautifulSoup
2
+
3
+ def parse(html_content):
4
+ """Parse HTML content containing semester averages."""
5
+ soup = BeautifulSoup(html_content, 'html.parser')
6
+ table = soup.find('table', class_='table table-striped table-sm') or soup.find('table')
7
+
8
+ if not table:
9
+ return []
10
+
11
+ headers = [th.get_text(strip=True) for th in table.find_all('th')]
12
+ if len(headers) < 10:
13
+ headers = ["Niveau", "Filiere", "Semestre", "AU", "Statut", "Moy SEM", "PJ", "Decision", "Classement", "Releve de Notes"]
14
+
15
+ data = []
16
+ tbody = table.find('tbody') or table
17
+
18
+ for row in tbody.find_all('tr'):
19
+ cells = row.find_all('td')
20
+ if len(cells) >= 10:
21
+ row_data = {}
22
+ for i, cell in enumerate(cells[:10]):
23
+ if i == 9: # Download link
24
+ link = cell.find('a')
25
+ row_data[headers[i]] = link.get('href') if link else cell.get_text(strip=True)
26
+ else:
27
+ val = cell.get_text(strip=True)
28
+ if headers[i] in ["Moy SEM", "PJ"]:
29
+ try:
30
+ row_data[headers[i]] = float(val) if val and val != "--" else None
31
+ except ValueError:
32
+ row_data[headers[i]] = val
33
+ else:
34
+ row_data[headers[i]] = val
35
+ data.append(row_data)
36
+
37
+ return data
@@ -0,0 +1,34 @@
1
+ from bs4 import BeautifulSoup
2
+
3
+ def parse(html_content):
4
+ """Extract statistics from modal/popup HTML content."""
5
+ soup = BeautifulSoup(html_content, 'html.parser')
6
+ stat_divs = soup.find_all('div', class_=lambda x: x and 'stat' in x.lower())
7
+
8
+ expected_keys = ["Votre note", "Moyenne promo", "Max", "Min", "Ecart type", "Effectif", "Votre classement"]
9
+
10
+ for div in stat_divs:
11
+ for table in div.find_all('table'):
12
+ rows = table.find_all('tr')
13
+ if len(rows) < 7:
14
+ continue
15
+
16
+ stats = {}
17
+ for row in rows:
18
+ th, td = row.find('th'), row.find('td')
19
+ if th and td:
20
+ key, val = th.get_text(strip=True), td.get_text(strip=True)
21
+ try:
22
+ if key in ["Effectif", "Votre classement"]:
23
+ stats[key.replace(" ", "_")] = int(float(val))
24
+ elif key in ["Votre note", "Moyenne promo", "Max", "Min", "Ecart type"]:
25
+ stats[key.replace(" ", "_")] = float(val)
26
+ else:
27
+ stats[key.replace(" ", "_")] = val
28
+ except (ValueError, TypeError):
29
+ stats[key.replace(" ", "_")] = val
30
+
31
+ if all(k in stats for k in expected_keys):
32
+ return stats
33
+
34
+ return {}
@@ -0,0 +1,64 @@
1
+ """
2
+ Professional client for interacting with the School App
3
+ """
4
+ from schoolapp_api.http_client import HTTPClient
5
+ from schoolapp_api.auth import AuthManager
6
+ from schoolapp_api.constants import BASE_URL
7
+ from schoolapp_api.managers import (
8
+ GradesManager, AttendanceManager, ProfileManager, CourseManager
9
+ )
10
+
11
+ class SchoolAppClient:
12
+ """Main client for the School App API"""
13
+
14
+ def __init__(self, base_url=BASE_URL):
15
+ self.base_url = base_url
16
+ self.http_client = HTTPClient(self.base_url)
17
+ self.auth = AuthManager(self.http_client, self.base_url)
18
+
19
+ # Initialize Managers
20
+ self.grades = GradesManager(self)
21
+ self.attendance = AttendanceManager(self)
22
+ self.profile = ProfileManager(self)
23
+ self.courses = CourseManager(self)
24
+
25
+ def login(self, email, password):
26
+ """Authenticate user"""
27
+ return self.auth.login(email, password)
28
+
29
+ # ---------------------------------------------------------
30
+ # Legacy / Convenience methods for backward compatibility
31
+ # ---------------------------------------------------------
32
+
33
+ def get_profile(self):
34
+ return self.profile.get_profile()
35
+
36
+ def get_filieres(self):
37
+ return self.profile.get_filieres()
38
+
39
+ def get_absences(self):
40
+ return self.attendance.get_absences()
41
+
42
+ def get_sanctions(self):
43
+ return self.attendance.get_sanctions()
44
+
45
+ def get_elem_note(self):
46
+ return self.grades.get_element_notes()
47
+
48
+ def get_current_elem_note(self):
49
+ return self.grades.get_element_notes(current=True)
50
+
51
+ def get_mod_note(self):
52
+ return self.grades.get_module_notes()
53
+
54
+ def get_current_mod_note(self):
55
+ return self.grades.get_module_notes(current=True)
56
+
57
+ def get_annee(self):
58
+ return self.grades.get_years()
59
+
60
+ def get_semestre(self):
61
+ return self.grades.get_semesters()
62
+
63
+ def get_modules(self, niveau, filiere, semestre, refresh_csrf=False):
64
+ return self.courses.get_modules(niveau, filiere, semestre, refresh_csrf)
@@ -0,0 +1,9 @@
1
+ """
2
+ Domain models for School App API
3
+ """
4
+ from .annee import Annee
5
+ from .element import Element
6
+ from .module import Module
7
+ from .semestre import Semestre
8
+
9
+ __all__ = ["Annee", "Element", "Module", "Semestre"]
@@ -0,0 +1,23 @@
1
+ from .base import BaseType
2
+ from ..constants import ANNEE_STAT_URL
3
+
4
+ class Annee(BaseType):
5
+ """Represents an academic year result"""
6
+
7
+ def fetch_stats(self):
8
+ params = {
9
+ 'eval': "MoyAn",
10
+ 'niveau': getattr(self, "Niveau", None),
11
+ 'filiere': getattr(self, "Filiere", None),
12
+ 'au': getattr(self, "AU", None),
13
+ 'note': getattr(self, "Moy_Annee", None)
14
+ }
15
+
16
+ stats = self._get_stats(ANNEE_STAT_URL, params)
17
+ if stats:
18
+ self._stats["MoyAn"] = stats
19
+ return stats
20
+
21
+ @property
22
+ def stats(self):
23
+ return self._stats.get("MoyAn") or self.fetch_stats()
@@ -0,0 +1,34 @@
1
+ """
2
+ Base class for domain models
3
+ """
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class BaseType:
9
+ """Base class for all domain models with client access"""
10
+
11
+ def __init__(self, client, data):
12
+ self.client = client
13
+ for key, value in data.items():
14
+ # Clean up keys for valid python attributes
15
+ clean_key = key.replace(" ", "_").replace("é", 'e').replace("è", 'e')
16
+ setattr(self, clean_key, value)
17
+ self._stats = {}
18
+
19
+ def _ensure_logged_in(self):
20
+ if not self.client.auth.is_logged_in():
21
+ logger.error("Not logged in! Call login() first.")
22
+ return False
23
+ return True
24
+
25
+ def _get_stats(self, url, params):
26
+ if not self._ensure_logged_in():
27
+ return None
28
+
29
+ # Add CSRF token to params
30
+ params['_csrf'] = self.client.auth.csrf_token
31
+
32
+ code, url, content = self.client.http_client.get(url, params=params)
33
+ from schoolapp_api.parsers.stats import parse
34
+ return parse(content)
@@ -0,0 +1,42 @@
1
+ from .base import BaseType
2
+ from ..constants import EVAL_STAT_URL
3
+
4
+ class Element(BaseType):
5
+ """Represents a module element (subject) with grades"""
6
+
7
+ def fetch_stats(self, eval_type):
8
+ """Generic stat fetcher for element evaluations"""
9
+ note_map = {
10
+ "NoteCC": getattr(self, "CC", None),
11
+ "NoteEX": getattr(self, "EX", None),
12
+ "NoteTP": getattr(self, "TP", None),
13
+ "MoyElem": getattr(self, "Moy", None)
14
+ }
15
+
16
+ params = {
17
+ 'eval': eval_type,
18
+ 'codeelem': self.CodeElem,
19
+ 'note': note_map.get(eval_type),
20
+ 'au': self.AU
21
+ }
22
+
23
+ stats = self._get_stats(EVAL_STAT_URL, params)
24
+ if stats:
25
+ self._stats[eval_type] = stats
26
+ return stats
27
+
28
+ @property
29
+ def cc_stats(self):
30
+ return self._stats.get("NoteCC") or self.fetch_stats("NoteCC")
31
+
32
+ @property
33
+ def ex_stats(self):
34
+ return self._stats.get("NoteEX") or self.fetch_stats("NoteEX")
35
+
36
+ @property
37
+ def tp_stats(self):
38
+ return self._stats.get("NoteTP") or self.fetch_stats("NoteTP")
39
+
40
+ @property
41
+ def moy_stats(self):
42
+ return self._stats.get("MoyElem") or self.fetch_stats("MoyElem")
@@ -0,0 +1,22 @@
1
+ from .base import BaseType
2
+ from ..constants import MOD_STAT_URL
3
+
4
+ class Module(BaseType):
5
+ """Represents a module (collection of elements) with grades"""
6
+
7
+ def fetch_stats(self):
8
+ params = {
9
+ 'eval': "MOYMOD",
10
+ 'codemod': getattr(self, "CodeMod", None),
11
+ 'note': getattr(self, "Moy", None),
12
+ 'au': getattr(self, "AU", None)
13
+ }
14
+
15
+ stats = self._get_stats(MOD_STAT_URL, params)
16
+ if stats:
17
+ self._stats["MOYMOD"] = stats
18
+ return stats
19
+
20
+ @property
21
+ def stats(self):
22
+ return self._stats.get("MOYMOD") or self.fetch_stats()
@@ -0,0 +1,24 @@
1
+ from .base import BaseType
2
+ from ..constants import SEM_STAT_URL
3
+
4
+ class Semestre(BaseType):
5
+ """Represents a semester result"""
6
+
7
+ def fetch_stats(self):
8
+ params = {
9
+ 'eval': "MoySem",
10
+ 'niveau': getattr(self, "Niveau", None),
11
+ 'filiere': getattr(self, "Filiere", None),
12
+ 'semestre': getattr(self, "Semestre", None),
13
+ 'au': getattr(self, "AU", None),
14
+ 'note': getattr(self, "Moy_SEM", None)
15
+ }
16
+
17
+ stats = self._get_stats(SEM_STAT_URL, params)
18
+ if stats:
19
+ self._stats["MoySem"] = stats
20
+ return stats
21
+
22
+ @property
23
+ def stats(self):
24
+ return self._stats.get("MoySem") or self.fetch_stats()
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: schoolapp-api
3
+ Version: 2.0.0
4
+ Summary: Professional client for interacting with the School App API
5
+ Author: Aferiad Kamal
6
+ License: MIT
7
+ Requires-Python: >=3.7
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: beautifulsoup4>=4.9.0
11
+ Dynamic: license-file
12
+
13
+ # School App Client - Refactored
14
+
15
+ Clean, organized structure for the School App client with proper separation of concerns.
16
+
17
+ ## 📁 Project Structure
18
+
19
+ ```
20
+ ├── http_client.py # Base HTTP client (GET/POST requests, cookie management)
21
+ ├── auth.py # Authentication & CSRF token handling
22
+ ├── school_app_client.py # Main API client (combines everything)
23
+ └── example_usage.py # Usage example
24
+ ```
25
+
26
+ ## 🎯 Architecture
27
+
28
+ ### 1. **http_client.py** - HTTP Layer
29
+ - Handles raw HTTP operations (GET/POST)
30
+ - Cookie jar and session management
31
+ - Common headers configuration
32
+ - Error handling for network requests
33
+
34
+ ### 2. **auth.py** - Authentication Layer
35
+ - Login flow management
36
+ - CSRF token extraction and updates
37
+ - Session state tracking
38
+ - Decoupled from HTTP implementation
39
+
40
+ ### 3. **school_app_client.py** - API Layer
41
+ - High-level API methods (`get_filieres()`, `get_modules()`)
42
+ - Orchestrates HTTP client and auth manager
43
+ - Business logic for School App endpoints
44
+
45
+ ## 🚀 Usage
46
+
47
+ ```python
48
+ from school_app_client import SchoolAppClient
49
+
50
+ # Initialize
51
+ client = SchoolAppClient()
52
+
53
+ # Login
54
+ client.login("your.email@example.com", "password")
55
+
56
+ # Fetch data
57
+ filieres = client.get_filieres()
58
+ modules = client.get_modules(niveau="1A", filiere="API-MPT", semestre="S1")
59
+
60
+ # If you encounter 403 errors, force CSRF refresh
61
+ modules = client.get_modules(
62
+ niveau="1A",
63
+ filiere="API-MPT",
64
+ semestre="S1",
65
+ refresh_csrf=True
66
+ )
67
+ ```
68
+
69
+ ## ✨ Benefits of This Structure
70
+
71
+ - **Separation of Concerns**: Each module has a single responsibility
72
+ - **Testability**: Easy to mock and unit test each layer
73
+ - **Maintainability**: Changes in one layer don't affect others
74
+ - **Extensibility**: Easy to add new endpoints or authentication methods
75
+ - **Reusability**: HTTP client can be used for other projects
76
+
77
+ ## 🎯 Key Features
78
+
79
+ - **Automatic CSRF Management**: CSRF tokens are automatically refreshed from page content
80
+ - **Session Persistence**: Cookie-based session management keeps you logged in
81
+ - **Smart Error Handling**: Detects 403 errors and suggests CSRF refresh
82
+ - **Optional CSRF Force Refresh**: Use `refresh_csrf=True` to force token refresh before requests
83
+ - **Clean Separation**: HTTP, Auth, and API layers are completely decoupled
84
+
85
+ ## 🔧 Potential Extensions
86
+
87
+ - Add logging module
88
+ - Implement response parsers (HTML → structured data)
89
+ - Add caching layer
90
+ - Create async version using `aiohttp`
91
+ - Add retry logic with exponential backoff
@@ -0,0 +1,33 @@
1
+ schoolapp_api/__init__.py,sha256=V-fCvFtmzWjTsi_skYgpiNBIgpGezCZ1B9j-8kUkF18,239
2
+ schoolapp_api/auth.py,sha256=UrYiaH0pWbXYmdtGhApI7DTlbG2V7qDIIwWzGawpCY4,2605
3
+ schoolapp_api/constants.py,sha256=dywePcMH-4OwlcUQjed0yWCzSUm-5alj3VbbpJoMaE0,1449
4
+ schoolapp_api/http_client.py,sha256=yrP7x2DTr0MAp9_0IlIuw7SZxUq4ZAcmMBcpYVi-4vk,2524
5
+ schoolapp_api/school_app_client.py,sha256=RKrD-rKgQEr5wwqGz_tsmeBwYNmkkJTRxuPFMU3CFXs,2090
6
+ schoolapp_api/managers/__init__.py,sha256=WJPekNC25Q-KiMgfb9jsy4hJlkUu7EEcApDameOlARM,292
7
+ schoolapp_api/managers/attendance_manager.py,sha256=sFaCORN_-zMtGUxr-NGoyV7FpFsxZ9LKXELB_PHcbSg,511
8
+ schoolapp_api/managers/base_manager.py,sha256=VgpzQWZta0SRAE8qjTAZvoEsN6fvteZVhMu_yJNeR9g,1069
9
+ schoolapp_api/managers/course_manager.py,sha256=sUfC0MxhJ6K-zCLsyHd_RIoY-ixa5WTUiqTm7WfgYh0,1455
10
+ schoolapp_api/managers/grades_manager.py,sha256=tSZpH8CIjKYgqbPUz9cc8FGsEPXKTQUbZMlFlUjcqgE,1622
11
+ schoolapp_api/managers/profile_manager.py,sha256=qcfeqj214thzGT6dLGj9254oIn1X76qejjZ3ztmd2Jg,506
12
+ schoolapp_api/parsers/__init__.py,sha256=7ISBuK6HAJVqBMIK-tmkg3DZm4Dt2jS-tHAfVxRQHuE,291
13
+ schoolapp_api/parsers/absences.py,sha256=13FtJwSk0r96PnROj-l_U6dGPi2aPrfmHOu2VOP073o,1655
14
+ schoolapp_api/parsers/annees.py,sha256=UPV_ZEqNUiGFCZNgkPea-KAazqVYumvXpbhvNTnWFvo,1476
15
+ schoolapp_api/parsers/filieres.py,sha256=EzFB-k3_Hbs0BbLVaisA3DZPSt6jGMDBoQcAkksxwwE,2014
16
+ schoolapp_api/parsers/modules.py,sha256=BVGLwYM3Y2qp1jjeUCo_XEbAqy-vtA_JtSUA7F5ylsE,2442
17
+ schoolapp_api/parsers/note_elem.py,sha256=uRSHnUfqLlrG3NbEEyFFfe15LqmIgiwVG_kNlHGdMI4,1247
18
+ schoolapp_api/parsers/note_mod.py,sha256=daKEdxpVgdn0pcjhb5pk_OiIkdw0kupvzVCLcZrS9yg,1146
19
+ schoolapp_api/parsers/profile.py,sha256=RObf52YjSuYjvQPKaWgtCeTkPiLZww9FSI-tDKc9TaU,3564
20
+ schoolapp_api/parsers/sanctions.py,sha256=BvmcQ3QtX7l6XAsrUKEyKQ0UCJjeMPSJ7QA824IZY2Y,1681
21
+ schoolapp_api/parsers/semestres.py,sha256=h7rWfpYT12ngBU__Mj9NBxt1h9IvygL-TvNEAiQh2pA,1487
22
+ schoolapp_api/parsers/stats.py,sha256=XWAa16GcaSwDzPCVjp12z_iPfHKYycqAlK8Id35rTQI,1472
23
+ schoolapp_api/types/__init__.py,sha256=K6jiJoAJqKB_Om5t6h3A-u_-IWDz8EoCI5dtqeL1a_8,216
24
+ schoolapp_api/types/annee.py,sha256=ujiRymGg6_CRv7md_gVz2WvWgQVer_bnEQ_0T-sWrSc,703
25
+ schoolapp_api/types/base.py,sha256=dcL2MIeKKdWi89Z5YvQRrL5uDWa4AIXOPdY_zVgNpVk,1087
26
+ schoolapp_api/types/element.py,sha256=mLMY40l9M3D9uetC_XZMrfQ2owp1e7pHHT77GUPyrW8,1303
27
+ schoolapp_api/types/module.py,sha256=xQw8DuOgAjxsOekH4Uml5BelV_g4CD7kkOY22eoflEA,657
28
+ schoolapp_api/types/semestre.py,sha256=5ecxLWSO4keDVVz8qK6rmNVg2m268yeGSLhWStXDLsc,755
29
+ schoolapp_api-2.0.0.dist-info/licenses/LICENSE,sha256=3UxAU5kFcqKpWRRnrXsCqiggKMDeADpqlq341CITRp0,1095
30
+ schoolapp_api-2.0.0.dist-info/METADATA,sha256=qyAH4n8vVZATg5ywky5k62ukjigdK2eNO0culf-HNqA,2884
31
+ schoolapp_api-2.0.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
32
+ schoolapp_api-2.0.0.dist-info/top_level.txt,sha256=bz-Z6plU3kx5xtaxm1nnpwKvrs3hQWUai0aKGbV0UH4,14
33
+ schoolapp_api-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) [2026] [Aferiad Kamal]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ schoolapp_api