Merge pull request 'fix api logo selon conventions + jeux de test + normalise des samples' (#476) from jmplace/ScoDoc-Lille:canonize into master

Reviewed-on: https://scodoc.org/git/ScoDoc/ScoDoc/pulls/476
This commit is contained in:
Emmanuel Viennet 2022-08-22 15:46:02 +02:00
commit fcb16a8af4
6 changed files with 268 additions and 60 deletions

View File

@ -46,23 +46,16 @@ from app.scodoc.sco_permissions import Permission
@bp.route("/logos") @bp.route("/logos")
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoSuperAdmin)
def api_get_glob_logos(): def api_get_glob_logos():
if not g.current_user.has_permission(Permission.ScoSuperAdmin, None):
return json_error(403, message="accès interdit")
required_format = requested_format() # json only
if required_format is None:
return json_error(400, "Illegal format")
logos = list_logos()[None] logos = list_logos()[None]
return jsonify(list(logos.keys())) return jsonify(list(logos.keys()))
@bp.route("/logos/<string:logoname>") @bp.route("/logo/<string:logoname>")
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoSuperAdmin)
def api_get_glob_logo(logoname): def api_get_glob_logo(logoname):
if not g.current_user.has_permission(Permission.ScoSuperAdmin, None):
return json_error(403, message="accès interdit")
logo = find_logo(logoname=logoname) logo = find_logo(logoname=logoname)
if logo is None: if logo is None:
return json_error(404, message="logo not found") return json_error(404, message="logo not found")
@ -74,25 +67,27 @@ def api_get_glob_logo(logoname):
) )
@bp.route("/departements/<string:departement>/logos") def core_get_logos(dept_id):
@scodoc
@permission_required(Permission.ScoView)
def api_get_local_logos(departement):
dept_id = Departement.from_acronym(departement).id
if not g.current_user.has_permission(Permission.ScoChangePreferences, departement):
return json_error(403, message="accès interdit")
logos = list_logos().get(dept_id, dict()) logos = list_logos().get(dept_id, dict())
return jsonify(list(logos.keys())) return jsonify(list(logos.keys()))
@bp.route("/departements/<string:departement>/logos/<string:logoname>") @bp.route("/departement/<string:departement>/logos")
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoSuperAdmin)
def api_get_local_logo(departement, logoname): def api_get_local_logos_by_acronym(departement):
# format = requested_format("jpg", ['png', 'jpg']) XXX ?
dept_id = Departement.from_acronym(departement).id dept_id = Departement.from_acronym(departement).id
if not g.current_user.has_permission(Permission.ScoChangePreferences, departement): return core_get_logos(dept_id)
return json_error(403, message="accès interdit")
@bp.route("/departement/id/<int:dept_id>/logos")
@scodoc
@permission_required(Permission.ScoSuperAdmin)
def api_get_local_logos_by_id(dept_id):
return core_get_logos(dept_id)
def core_get_logo(dept_id, logoname):
logo = find_logo(logoname=logoname, dept_id=dept_id) logo = find_logo(logoname=logoname, dept_id=dept_id)
if logo is None: if logo is None:
return json_error(404, message="logo not found") return json_error(404, message="logo not found")
@ -102,3 +97,18 @@ def api_get_local_logo(departement, logoname):
mimetype=f"image/{logo.suffix}", mimetype=f"image/{logo.suffix}",
last_modified=datetime.now(), last_modified=datetime.now(),
) )
@bp.route("/departement/<string:departement>/logo/<string:logoname>")
@scodoc
@permission_required(Permission.ScoSuperAdmin)
def api_get_local_logo_dept_by_acronym(departement, logoname):
dept_id = Departement.from_acronym(departement).id
return core_get_logo(dept_id, logoname)
@bp.route("/departement/id/<int:dept_id>/logo/<string:logoname>")
@scodoc
@permission_required(Permission.ScoSuperAdmin)
def api_get_local_logo_dept_by_id(dept_id, logoname):
return core_get_logo(dept_id, logoname)

View File

@ -6,8 +6,9 @@
Usage: Usage:
cd /opt/scodoc/tests/api cd /opt/scodoc/tests/api
python make_samples.py python make_samples.py [entry_names]
si entry_names est spécifié, la génération est restreints aux exemples cités. expl: `python make_samples departements departement-formsemestres`
doit être exécutée immédiatement apres une initialisation de la base pour test API! (car dépendant des identifiants générés lors de la création des objets) doit être exécutée immédiatement apres une initialisation de la base pour test API! (car dépendant des identifiants générés lors de la création des objets)
cd /opt/scodoc/tests/api cd /opt/scodoc/tests/api
tools/create_database.sh --drop SCODOC_TEST_API && flask db upgrade &&flask sco-db-init --erase && flask init-test-database tools/create_database.sh --drop SCODOC_TEST_API && flask db upgrade &&flask sco-db-init --erase && flask init-test-database
@ -41,6 +42,8 @@ TODO: ajouter un argument au script permettant de ne générer qu'un seul fichie
""" """
import os import os
import shutil import shutil
import sys
import re
from collections import defaultdict from collections import defaultdict
from pprint import pprint as pp from pprint import pprint as pp
from pprint import pformat as pf from pprint import pformat as pf
@ -69,6 +72,7 @@ class Sample:
self.url = url self.url = url
self.method = method self.method = method
self.result = None self.result = None
self.output = "json"
if permission == "ScoView": if permission == "ScoView":
HEADERS = get_auth_headers("test", "test") HEADERS = get_auth_headers("test", "test")
elif permission == "ScoSuperAdmin": elif permission == "ScoSuperAdmin":
@ -87,16 +91,16 @@ class Sample:
self.result = POST_JSON(self.url, json.loads(self.content), HEADERS) self.result = POST_JSON(self.url, json.loads(self.content), HEADERS)
elif self.method[0] != "#": elif self.method[0] != "#":
raise Exception(f"Bad method : {self.method}") raise Exception(f"Bad method : {self.method}")
else: # method begin with # => comment
print(" pass")
self.shorten() self.shorten()
file = open(f"sample_TEST.json.md", "tw") file = open(f"sample_TEST.json.md", "tw")
self.dump(file) self.dump(file)
file.close() file.close()
def _shorten(self, item): def _shorten(
self, item
): # abrege les longues listes (limite à 2 éléments et affiche "... etc. à la place"
if isinstance(item, list): if isinstance(item, list):
return [self._shorten(child) for child in item[:2]] return [self._shorten(child) for child in item[:2]] + ["... etc."]
return item return item
def shorten(self): def shorten(self):
@ -117,19 +121,36 @@ class Sample:
file.write(f"> `{self.content}`\n\n") file.write(f"> `{self.content}`\n\n")
file.write("```json\n") file.write("```json\n")
file.write(json.dumps(self.result, indent=4)) content = json.dumps(self.result, indent=4, sort_keys=True)
content = content.replace("... etc.", "...")
# regexp for date like: "2022-08-14T10:01:44.043869+02:00"
regexp = re.compile(
r'"(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9]):([0-5][0-9])(\.[0-9]+)?(Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?"'
)
content = regexp.sub('"2022-08-20T12:00:00.000000+02:00"', content)
file.write(content)
file.write("\n```\n\n") file.write("\n```\n\n")
class Samples: class Samples:
def __init__(self): def __init__(self, entry_names):
"""Entry_names: la liste des entrées à reconstruire.
si None, la totalité des lignes de samples.csv est prise en compte
"""
self.entries = defaultdict(lambda: set()) self.entries = defaultdict(lambda: set())
self.entry_names = entry_names
def add_sample(self, entry, url, method="GET", permission="ScoView", content=None): def add_sample(self, entry, url, method="GET", permission="ScoView", content=None):
show_content = "" if content == "" else f": '{content}'" if self.entry_names is None or entry in self.entry_names:
print(f"{entry:50} {method:5} {url:50} {show_content}") if method[0] == "#":
sample = Sample(url, method, permission, content) detail = "**ignored**"
self.entries[entry].add(sample) elif content == "":
detail = ""
else:
detail = f": {content}"
print(f"{entry:50} {method:5} {url:50} {detail}")
sample = Sample(url, method, permission, content)
self.entries[entry].add(sample)
def pp(self): def pp(self):
for entry, samples in self.entries.items(): for entry, samples in self.entries.items():
@ -141,12 +162,18 @@ class Samples:
for entry, samples in self.entries.items(): for entry, samples in self.entries.items():
file = open(f"{DATA_DIR}sample_{entry}.json.md", "tw") file = open(f"{DATA_DIR}sample_{entry}.json.md", "tw")
file.write(f"### {entry}\n\n") file.write(f"### {entry}\n\n")
for sample in samples: for sample in sorted(
samples, key=lambda s: s.url
): # sorted de façon à rendre le fichier résultat déterministe (i.e. indépendant de l ordre d arrivée des résultats)
sample.dump(file) sample.dump(file)
file.close() file.close()
def make_samples(): def make_samples():
if len(sys.argv) == 1:
entry_names = None
else:
entry_names = sys.argv[1:]
if os.path.exists(DATA_DIR): if os.path.exists(DATA_DIR):
if not os.path.isdir(DATA_DIR): if not os.path.isdir(DATA_DIR):
raise f"{DATA_DIR} existe déjà et n'est pas un répertoire" raise f"{DATA_DIR} existe déjà et n'est pas un répertoire"
@ -157,9 +184,9 @@ def make_samples():
else: else:
os.mkdir("/tmp/samples") os.mkdir("/tmp/samples")
samples = Samples() samples = Samples(entry_names)
# samples.pp() samples_file = os.path.dirname(__file__) + "/samples.csv"
with open("samples.csv") as f: with open(samples_file) as f:
L = [x[:-1].split("\t") for x in f] L = [x[:-1].split("\t") for x in f]
for line in L[1:]: for line in L[1:]:
entry_name = line[0] entry_name = line[0]

View File

@ -26,6 +26,7 @@ etudiant_formsemestres /etudiant/nip/11/formsemestres GET
etudiant-formsemestre-bulletin /etudiant/etudid/11/formsemestre/1/bulletin GET etudiant-formsemestre-bulletin /etudiant/etudid/11/formsemestre/1/bulletin GET
etudiant-formsemestre-bulletin /etudiant/ine/INE11/formsemestre/1/bulletin GET etudiant-formsemestre-bulletin /etudiant/ine/INE11/formsemestre/1/bulletin GET
etudiant-formsemestre-bulletin /etudiant/nip/11/formsemestre/1/bulletin GET etudiant-formsemestre-bulletin /etudiant/nip/11/formsemestre/1/bulletin GET
etudiant-formsemestre-bulletin /etudiant/nip/11/formsemestre/1/bulletin/short/pdf GET
etudiant-formsemestre-groups /etudiant/etudid/11/formsemestre/1/groups GET etudiant-formsemestre-groups /etudiant/etudid/11/formsemestre/1/groups GET
formations /formations GET formations /formations GET
formations_ids /formations_ids GET formations_ids /formations_ids GET
@ -67,11 +68,30 @@ partition-groups-order /partition/1/groups/order ScoSuperAdmin POST [ 1 ]
partition-delete /partition/2/delete ScoSuperAdmin POST partition-delete /partition/2/delete ScoSuperAdmin POST
user-create /user/create ScoSuperAdmin POST {"user_name": "alain", "dept": null, "nom": "alain", "prenom": "bruno", "active": true } user-create /user/create ScoSuperAdmin POST {"user_name": "alain", "dept": null, "nom": "alain", "prenom": "bruno", "active": true }
user-edit /user/10/edit ScoSuperAdmin POST { "dept": "TAPI", "nom": "alain2", "prenom": "bruno2", "active": false } user-edit /user/10/edit ScoSuperAdmin POST { "dept": "TAPI", "nom": "alain2", "prenom": "bruno2", "active": false }
user-password /user/3/password ScoSuperAdmin POST { "password": "rePlaCemeNT456averylongandcomplicated" }
user-password /user/3/password ScoSuperAdmin POST { "password": "too_simple" }
user-role-add /user/10/role/Observateur/add ScoSuperAdmin POST user-role-add /user/10/role/Observateur/add ScoSuperAdmin POST
user-role-remove /user/10/role/Observateur/remove ScoSuperAdmin POST user-role-remove /user/10/role/Observateur/remove ScoSuperAdmin POST
role-create /role/create/customRole ScoSuperAdmin POST {"permissions": ["ScoView", "ScoUsersView"]} role-create /role/create/customRole ScoSuperAdmin POST {"permissions": ["ScoView", "ScoUsersView"]}
role-remove_permission /role/customRole/remove_permission/ScoUsersView ScoSuperAdmin POST role-remove_permission /role/customRole/remove_permission/ScoUsersView ScoSuperAdmin POST
role-add_permission /role/customRole/add_permission/ScoUsersView ScoSuperAdmin POST role-add_permission /role/customRole/add_permission/ScoUsersView ScoSuperAdmin POST
role-edit /role/customRole/edit ScoSuperAdmin POST { "name" : "LaveurDeVitres", "permissions" : [ "ScoView", "APIView" ] } role-edit /role/customRole/edit ScoSuperAdmin POST { "name" : "LaveurDeVitres", "permissions" : [ "ScoView", "APIView" ] }
role-edit /role/customRole/edit ScoSuperAdmin POST { "name" : "LaveurDeVitres", "permissions" : [ "ScoView", "APIView" ] }
role-delete /role/customRole/delete ScoSuperAdmin POST role-delete /role/customRole/delete ScoSuperAdmin POST
logos /logos ScoSuperAdmin GET
logo /logo/demo ScoSuperAdmin GET
departement-logos /departement/TAPI/logos ScoSuperAdmin GET
departement-logos /departement/id/1/logos ScoSuperAdmin GET
departement-logo /departement/TAPI/logo/demo ScoSuperAdmin GET
departement-logo /departement/id/1/logo/demo ScoSuperAdmin GET
test-pdf /etudiant/nip/11/formsemestre/1/bulletin/pdf GET
test-pdf /etudiant/nip/11/formsemestre/1/bulletin/pdf GET
test-pdf /etudiant/etudid/11/formsemestre/1/bulletin/short/pdf GET
test-pdf /etudiant/ine/INE11/formsemestre/1/bulletin/short/pdf GET
test-pdf /etudiant/nip/11/formsemestre/1/bulletin/short/pdf GET
test-pdf /etudiant/etudid/11/formsemestre/1/bulletin/pdf GET
test-pdf /etudiant/etudid/11/formsemestre/1/bulletin/short GET
test-pdf /etudiant/ine/INE11/formsemestre/1/bulletin/short GET
test-pdf /etudiant/nip/11/formsemestre/1/bulletin/short GET
test-pdf /etudiant/etudid/11/formsemestre/1/bulletin GET
test-pdf /etudiant/ine/INE11/formsemestre/1/bulletin GET
test-pdf /etudiant/nip/11/formsemestre/1/bulletin GET

Can't render this file because it contains an unexpected character in line 12 and column 60.

View File

@ -65,7 +65,9 @@ def api_admin_headers() -> dict:
def GET(path: str, headers: dict = None, errmsg=None, dept=None): def GET(path: str, headers: dict = None, errmsg=None, dept=None):
"""Get and returns as JSON""" """Get and returns as JSON
Special case for non json result (image or pdf): return Content-Disposition string (inline or attachment)
"""
if dept: if dept:
url = SCODOC_URL + f"/ScoDoc/{dept}/api" + path url = SCODOC_URL + f"/ScoDoc/{dept}/api" + path
else: else:
@ -73,6 +75,23 @@ def GET(path: str, headers: dict = None, errmsg=None, dept=None):
r = requests.get(url, headers=headers or {}, verify=CHECK_CERTIFICATE) r = requests.get(url, headers=headers or {}, verify=CHECK_CERTIFICATE)
if r.status_code != 200: if r.status_code != 200:
raise APIError(errmsg or f"""erreur status={r.status_code} !""", r.json()) raise APIError(errmsg or f"""erreur status={r.status_code} !""", r.json())
if r.headers.get("Content-Type", None) == "application/json":
return r.json() # decode la reponse JSON
elif r.headers.get("Content-Type", None) in [
"image/jpg",
"image/png",
"application/pdf",
]:
retval = {
"Content-Type": r.headers.get("Content-Type", None),
"Content-Disposition": r.headers.get("Content-Disposition", None),
}
return retval
else:
raise APIError(
"Unknown returned content {r.headers.get('Content-Type', None} !\n"
)
return r.json() # decode la reponse JSON return r.json() # decode la reponse JSON

View File

@ -14,7 +14,7 @@ utilisation:
# Ce test a une logique très différente des autres : A UNIFIER # Ce test a une logique très différente des autres : A UNIFIER
from tests.api.setup_test_api import API_URL from tests.api.setup_test_api import API_URL, api_admin_headers, api_headers
from scodoc import app from scodoc import app
from tests.unit.config_test_logos import ( from tests.unit.config_test_logos import (
@ -26,48 +26,152 @@ from tests.unit.config_test_logos import (
) )
def test_super_access(create_super_token): def test_super_access(api_admin_headers):
""" """
Route: /logos Route: /logos
""" """
dept1, dept2, dept3, token = create_super_token headers = api_admin_headers
headers = {"Authorization": f"Bearer {token}"} with app.test_client(api_admin_headers) as client:
with app.test_client() as client:
response = client.get(API_URL + "/logos", headers=headers) response = client.get(API_URL + "/logos", headers=headers)
assert response.status_code == 200 assert response.status_code == 200
assert response.json is not None assert response.json is not None
def test_admin_access(create_admin_token): def test_admin_access(api_headers):
""" """
Route: Route: /logos
""" """
dept1, dept2, dept3, token = create_admin_token headers = api_headers
headers = {"Authorization": f"Bearer {token}"}
with app.test_client() as client: with app.test_client() as client:
response = client.get(API_URL + "/logos", headers=headers) response = client.get(API_URL + "/logos", headers=headers)
assert response.status_code == 403 assert response.status_code == 401
def test_lambda_access(create_lambda_token): def test_lambda_access(api_headers):
""" """
Route: Route: /logos
""" """
dept1, dept2, dept3, token = create_lambda_token headers = api_headers
headers = {"Authorization": f"Bearer {token}"}
with app.test_client() as client: with app.test_client() as client:
response = client.get(API_URL + "/logos", headers=headers) response = client.get(API_URL + "/logos", headers=headers)
assert response.status_code == 403 assert response.status_code == 401
def test_initial_with_header_and_footer(create_super_token): def test_global_logos(api_admin_headers):
""" """
Route: Route:
""" """
dept1, dept2, dept3, token = create_super_token headers = api_admin_headers
headers = {"Authorization": f"Bearer {token}"}
with app.test_client() as client: with app.test_client() as client:
response = client.get(API_URL + "/logos", headers=headers) response = client.get(API_URL + "/logos", headers=headers)
assert response.status_code == 200 assert response.status_code == 200
assert response.json is not None assert response.json is not None
assert len(response.json) == 7 assert (
len(response.json) == 4
) # 4 items in fakelogo context: ['header', 'footer', 'logo_B', 'logo_C']
def test_local_by_id_logos(api_admin_headers):
"""
Route: /departement/id/1/logos
"""
headers = api_admin_headers
with app.test_client() as client:
response = client.get(API_URL + "/departement/id/1/logos", headers=headers)
assert response.status_code == 200
assert response.json is not None
assert (
len(response.json) == 2
) # 2 items in dept(1, TAPI) fakelogo context: ['logo_A', 'logo_D']
def test_local_by_name_logos(api_admin_headers):
"""
Route: /departement/TAPI/logos
"""
headers = api_admin_headers
with app.test_client() as client:
response = client.get(API_URL + "/departement/TAPI/logos", headers=headers)
assert response.status_code == 200
assert response.json is not None
assert (
len(response.json) == 2
) # 2 items in dept(1, TAPI) fakelogo context: ['logo_A', 'logo_D']
def test_local_png_by_id_logo(api_admin_headers):
"""
Route: /departement/id/1/logo/D
"""
headers = api_admin_headers
with app.test_client() as client:
response = client.get(API_URL + "/departement/id/1/logo/D", headers=headers)
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/png"
assert response.headers["Content-Disposition"].startswith("inline")
assert "logo_D.png" in response.headers["Content-Disposition"]
def test_global_png_logo(api_admin_headers):
"""
Route: /logo/C
"""
headers = api_admin_headers
with app.test_client() as client:
response = client.get(API_URL + "/logo/C", headers=headers)
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/png"
assert response.headers["Content-Disposition"].startswith("inline")
assert "logo_C.png" in response.headers["Content-Disposition"]
def test_global_jpg_logo(api_admin_headers):
"""
Route: /logo/B
"""
headers = api_admin_headers
with app.test_client() as client:
response = client.get(API_URL + "/logo/B", headers=headers)
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/jpg"
assert response.headers["Content-Disposition"].startswith("inline")
assert "logo_B.jpg" in response.headers["Content-Disposition"]
def test_local_png_by_name_logo(api_admin_headers):
"""
Route: /departement/TAPI/logo/A
"""
headers = api_admin_headers
with app.test_client() as client:
response = client.get(API_URL + "/departement/TAPI/logo/D", headers=headers)
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/png"
assert response.headers["Content-Disposition"].startswith("inline")
assert "logo_D.png" in response.headers["Content-Disposition"]
def test_local_jpg_by_id_logo(api_admin_headers):
"""
Route: /departement/id/1/logo/D
"""
headers = api_admin_headers
with app.test_client() as client:
response = client.get(API_URL + "/departement/id/1/logo/A", headers=headers)
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/jpg"
assert response.headers["Content-Disposition"].startswith("inline")
assert "logo_A.jpg" in response.headers["Content-Disposition"]
def test_local_jpg_by_name_logo(api_admin_headers):
"""
Route: /departement/TAPI/logo/A
"""
headers = api_admin_headers
with app.test_client() as client:
response = client.get(API_URL + "/departement/TAPI/logo/A", headers=headers)
assert response.status_code == 200
assert response.headers["Content-Type"] == "image/jpg"
assert response.headers["Content-Disposition"].startswith("inline")
assert "logo_A.jpg" in response.headers["Content-Disposition"]

View File

@ -8,7 +8,9 @@
""" """
import datetime import datetime
import os
import random import random
import shutil
import time import time
import sys import sys
@ -45,6 +47,10 @@ REFCOMP_FILENAME = (
"ressources/referentiels/but2022/competences/but-RT-05012022-081735.xml" "ressources/referentiels/but2022/competences/but-RT-05012022-081735.xml"
) )
# la réserve de logos
LOGOS_STOCK = "/opt/scodoc/tests/ressources/test_logos/"
LOGOS_DIR = "/opt/scodoc-data/config/logos/"
def create_departements(acronyms: list[str]) -> list[Departement]: def create_departements(acronyms: list[str]) -> list[Departement]:
"Create depts" "Create depts"
@ -353,6 +359,27 @@ def create_etape_apo(formsemestre: FormSemestre):
db.session.commit() db.session.commit()
def create_logos():
if not os.path.exists(LOGOS_DIR + "logos_1"):
os.mkdir(LOGOS_DIR + "logos_1")
shutil.copy(
LOGOS_STOCK + "logo_A.jpg",
LOGOS_DIR + "logos_1/logo_A.jpg",
)
shutil.copy(
LOGOS_STOCK + "logo_D.png",
LOGOS_DIR + "logos_1/logo_D.png",
)
shutil.copy(
LOGOS_STOCK + "logo_A.jpg",
LOGOS_DIR + "logo_B.jpg",
)
shutil.copy(
LOGOS_STOCK + "logo_D.png",
LOGOS_DIR + "logo_C.png",
)
def init_test_database(): def init_test_database():
"""Appelé par la commande `flask init-test-database` """Appelé par la commande `flask init-test-database`
@ -373,6 +400,7 @@ def init_test_database():
saisie_notes_evaluations(formsemestre, user_lecteur) saisie_notes_evaluations(formsemestre, user_lecteur)
add_absences(formsemestre) add_absences(formsemestre)
create_etape_apo(formsemestre) create_etape_apo(formsemestre)
create_logos()
# à compléter # à compléter
# - groupes # - groupes
# - absences # - absences