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.
- schoolapp_api/__init__.py +10 -0
- schoolapp_api/auth.py +81 -0
- schoolapp_api/constants.py +38 -0
- schoolapp_api/http_client.py +67 -0
- schoolapp_api/managers/__init__.py +11 -0
- schoolapp_api/managers/attendance_manager.py +15 -0
- schoolapp_api/managers/base_manager.py +36 -0
- schoolapp_api/managers/course_manager.py +45 -0
- schoolapp_api/managers/grades_manager.py +35 -0
- schoolapp_api/managers/profile_manager.py +15 -0
- schoolapp_api/parsers/__init__.py +6 -0
- schoolapp_api/parsers/absences.py +45 -0
- schoolapp_api/parsers/annees.py +37 -0
- schoolapp_api/parsers/filieres.py +63 -0
- schoolapp_api/parsers/modules.py +55 -0
- schoolapp_api/parsers/note_elem.py +33 -0
- schoolapp_api/parsers/note_mod.py +33 -0
- schoolapp_api/parsers/profile.py +87 -0
- schoolapp_api/parsers/sanctions.py +42 -0
- schoolapp_api/parsers/semestres.py +37 -0
- schoolapp_api/parsers/stats.py +34 -0
- schoolapp_api/school_app_client.py +64 -0
- schoolapp_api/types/__init__.py +9 -0
- schoolapp_api/types/annee.py +23 -0
- schoolapp_api/types/base.py +34 -0
- schoolapp_api/types/element.py +42 -0
- schoolapp_api/types/module.py +22 -0
- schoolapp_api/types/semestre.py +24 -0
- schoolapp_api-2.0.0.dist-info/METADATA +91 -0
- schoolapp_api-2.0.0.dist-info/RECORD +33 -0
- schoolapp_api-2.0.0.dist-info/WHEEL +5 -0
- schoolapp_api-2.0.0.dist-info/licenses/LICENSE +21 -0
- schoolapp_api-2.0.0.dist-info/top_level.txt +1 -0
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,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,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
|