schoolapp-api 2.0.0__tar.gz
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-2.0.0/LICENSE +21 -0
- schoolapp_api-2.0.0/PKG-INFO +91 -0
- schoolapp_api-2.0.0/README.md +79 -0
- schoolapp_api-2.0.0/pyproject.toml +20 -0
- schoolapp_api-2.0.0/schoolapp_api/__init__.py +10 -0
- schoolapp_api-2.0.0/schoolapp_api/auth.py +81 -0
- schoolapp_api-2.0.0/schoolapp_api/constants.py +38 -0
- schoolapp_api-2.0.0/schoolapp_api/http_client.py +67 -0
- schoolapp_api-2.0.0/schoolapp_api/managers/__init__.py +11 -0
- schoolapp_api-2.0.0/schoolapp_api/managers/attendance_manager.py +15 -0
- schoolapp_api-2.0.0/schoolapp_api/managers/base_manager.py +36 -0
- schoolapp_api-2.0.0/schoolapp_api/managers/course_manager.py +45 -0
- schoolapp_api-2.0.0/schoolapp_api/managers/grades_manager.py +35 -0
- schoolapp_api-2.0.0/schoolapp_api/managers/profile_manager.py +15 -0
- schoolapp_api-2.0.0/schoolapp_api/parsers/__init__.py +6 -0
- schoolapp_api-2.0.0/schoolapp_api/parsers/absences.py +45 -0
- schoolapp_api-2.0.0/schoolapp_api/parsers/annees.py +37 -0
- schoolapp_api-2.0.0/schoolapp_api/parsers/filieres.py +63 -0
- schoolapp_api-2.0.0/schoolapp_api/parsers/modules.py +55 -0
- schoolapp_api-2.0.0/schoolapp_api/parsers/note_elem.py +33 -0
- schoolapp_api-2.0.0/schoolapp_api/parsers/note_mod.py +33 -0
- schoolapp_api-2.0.0/schoolapp_api/parsers/profile.py +87 -0
- schoolapp_api-2.0.0/schoolapp_api/parsers/sanctions.py +42 -0
- schoolapp_api-2.0.0/schoolapp_api/parsers/semestres.py +37 -0
- schoolapp_api-2.0.0/schoolapp_api/parsers/stats.py +34 -0
- schoolapp_api-2.0.0/schoolapp_api/school_app_client.py +64 -0
- schoolapp_api-2.0.0/schoolapp_api/types/__init__.py +9 -0
- schoolapp_api-2.0.0/schoolapp_api/types/annee.py +23 -0
- schoolapp_api-2.0.0/schoolapp_api/types/base.py +34 -0
- schoolapp_api-2.0.0/schoolapp_api/types/element.py +42 -0
- schoolapp_api-2.0.0/schoolapp_api/types/module.py +22 -0
- schoolapp_api-2.0.0/schoolapp_api/types/semestre.py +24 -0
- schoolapp_api-2.0.0/schoolapp_api.egg-info/PKG-INFO +91 -0
- schoolapp_api-2.0.0/schoolapp_api.egg-info/SOURCES.txt +36 -0
- schoolapp_api-2.0.0/schoolapp_api.egg-info/dependency_links.txt +1 -0
- schoolapp_api-2.0.0/schoolapp_api.egg-info/requires.txt +1 -0
- schoolapp_api-2.0.0/schoolapp_api.egg-info/top_level.txt +1 -0
- schoolapp_api-2.0.0/setup.cfg +4 -0
|
@@ -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,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,79 @@
|
|
|
1
|
+
# School App Client - Refactored
|
|
2
|
+
|
|
3
|
+
Clean, organized structure for the School App client with proper separation of concerns.
|
|
4
|
+
|
|
5
|
+
## π Project Structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
βββ http_client.py # Base HTTP client (GET/POST requests, cookie management)
|
|
9
|
+
βββ auth.py # Authentication & CSRF token handling
|
|
10
|
+
βββ school_app_client.py # Main API client (combines everything)
|
|
11
|
+
βββ example_usage.py # Usage example
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## π― Architecture
|
|
15
|
+
|
|
16
|
+
### 1. **http_client.py** - HTTP Layer
|
|
17
|
+
- Handles raw HTTP operations (GET/POST)
|
|
18
|
+
- Cookie jar and session management
|
|
19
|
+
- Common headers configuration
|
|
20
|
+
- Error handling for network requests
|
|
21
|
+
|
|
22
|
+
### 2. **auth.py** - Authentication Layer
|
|
23
|
+
- Login flow management
|
|
24
|
+
- CSRF token extraction and updates
|
|
25
|
+
- Session state tracking
|
|
26
|
+
- Decoupled from HTTP implementation
|
|
27
|
+
|
|
28
|
+
### 3. **school_app_client.py** - API Layer
|
|
29
|
+
- High-level API methods (`get_filieres()`, `get_modules()`)
|
|
30
|
+
- Orchestrates HTTP client and auth manager
|
|
31
|
+
- Business logic for School App endpoints
|
|
32
|
+
|
|
33
|
+
## π Usage
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from school_app_client import SchoolAppClient
|
|
37
|
+
|
|
38
|
+
# Initialize
|
|
39
|
+
client = SchoolAppClient()
|
|
40
|
+
|
|
41
|
+
# Login
|
|
42
|
+
client.login("your.email@example.com", "password")
|
|
43
|
+
|
|
44
|
+
# Fetch data
|
|
45
|
+
filieres = client.get_filieres()
|
|
46
|
+
modules = client.get_modules(niveau="1A", filiere="API-MPT", semestre="S1")
|
|
47
|
+
|
|
48
|
+
# If you encounter 403 errors, force CSRF refresh
|
|
49
|
+
modules = client.get_modules(
|
|
50
|
+
niveau="1A",
|
|
51
|
+
filiere="API-MPT",
|
|
52
|
+
semestre="S1",
|
|
53
|
+
refresh_csrf=True
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## β¨ Benefits of This Structure
|
|
58
|
+
|
|
59
|
+
- **Separation of Concerns**: Each module has a single responsibility
|
|
60
|
+
- **Testability**: Easy to mock and unit test each layer
|
|
61
|
+
- **Maintainability**: Changes in one layer don't affect others
|
|
62
|
+
- **Extensibility**: Easy to add new endpoints or authentication methods
|
|
63
|
+
- **Reusability**: HTTP client can be used for other projects
|
|
64
|
+
|
|
65
|
+
## π― Key Features
|
|
66
|
+
|
|
67
|
+
- **Automatic CSRF Management**: CSRF tokens are automatically refreshed from page content
|
|
68
|
+
- **Session Persistence**: Cookie-based session management keeps you logged in
|
|
69
|
+
- **Smart Error Handling**: Detects 403 errors and suggests CSRF refresh
|
|
70
|
+
- **Optional CSRF Force Refresh**: Use `refresh_csrf=True` to force token refresh before requests
|
|
71
|
+
- **Clean Separation**: HTTP, Auth, and API layers are completely decoupled
|
|
72
|
+
|
|
73
|
+
## π§ Potential Extensions
|
|
74
|
+
|
|
75
|
+
- Add logging module
|
|
76
|
+
- Implement response parsers (HTML β structured data)
|
|
77
|
+
- Add caching layer
|
|
78
|
+
- Create async version using `aiohttp`
|
|
79
|
+
- Add retry logic with exponential backoff
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "schoolapp-api"
|
|
7
|
+
version = "2.0.0"
|
|
8
|
+
description = "Professional client for interacting with the School App API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.7"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Aferiad Kamal"}
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"beautifulsoup4>=4.9.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[tool.setuptools]
|
|
20
|
+
packages = ["schoolapp_api", "schoolapp_api.managers", "schoolapp_api.parsers", "schoolapp_api.types"]
|
|
@@ -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
|