diff --git a/app/api/__init__.py b/app/api/__init__.py index 8a2054249..a098d7e93 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -7,7 +7,7 @@ from flask_json import as_json from flask import Blueprint from flask import current_app, g, request from flask_login import current_user -from app import db +from app import db, log from app.decorators import permission_required from app.scodoc import sco_utils as scu from app.scodoc.sco_exceptions import AccessDenied, ScoException @@ -47,6 +47,7 @@ def api_permission_required(permission): @api_bp.errorhandler(404) def api_error_handler(e): "erreurs API => json" + log(f"api_error_handler: {e}") return scu.json_error(404, message=str(e)) diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 7dc8ccf17..7834c4cb1 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -41,6 +41,10 @@ from app.models import ( from app.models.formsemestre import GROUPS_AUTO_ASSIGNMENT_DATA_MAX from app.scodoc.sco_bulletins import get_formsemestre_bulletin_etud_json from app.scodoc import sco_edt_cal +from app.scodoc.sco_formsemestre_inscriptions import ( + do_formsemestre_inscription_with_modules, + do_formsemestre_desinscription, +) from app.scodoc import sco_groups from app.scodoc.sco_permissions import Permission from app.scodoc.sco_utils import ModuleType @@ -64,10 +68,7 @@ def formsemestre_get(formsemestre_id: int): ------- /formsemestre/1 """ - query = FormSemestre.query.filter_by(id=formsemestre_id) - if g.scodoc_dept: - query = query.filter_by(dept_id=g.scodoc_dept_id) - formsemestre: FormSemestre = query.first_or_404(formsemestre_id) + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) return formsemestre.to_dict_api() @@ -400,12 +401,7 @@ def bulletins(formsemestre_id: int, version: str = "long"): ------- /formsemestre/1/bulletins """ - query = FormSemestre.query.filter_by(id=formsemestre_id) - if g.scodoc_dept: - query = query.filter_by(dept_id=g.scodoc_dept_id) - formsemestre: FormSemestre = query.first() - if formsemestre is None: - return json_error(404, "formsemestre non trouve") + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) app.set_sco_dept(formsemestre.departement.acronym) data = [] @@ -432,10 +428,7 @@ def formsemestre_programme(formsemestre_id: int): ------- /formsemestre/1/programme """ - query = FormSemestre.query.filter_by(id=formsemestre_id) - if g.scodoc_dept: - query = query.filter_by(dept_id=g.scodoc_dept_id) - formsemestre: FormSemestre = query.first_or_404(formsemestre_id) + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) ues = formsemestre.get_ues() m_list = { ModuleType.RESSOURCE: [], @@ -508,10 +501,7 @@ def formsemestre_etudiants( /formsemestre/1/etudiants/query; """ - query = FormSemestre.query.filter_by(id=formsemestre_id) - if g.scodoc_dept: - query = query.filter_by(dept_id=g.scodoc_dept_id) - formsemestre: FormSemestre = query.first_or_404(formsemestre_id) + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) if with_query: etat = request.args.get("etat") if etat is not None: @@ -543,6 +533,63 @@ def formsemestre_etudiants( return sorted(etuds, key=itemgetter("sort_key")) +@bp.post("/formsemestre//etudid//inscrit") +@api_web_bp.post("/formsemestre//etudid//inscrit") +@login_required +@scodoc +@permission_required(Permission.EtudInscrit) +@as_json +def formsemestre_etud_inscrit(formsemestre_id: int, etudid: int): + """Inscrit l'étudiant à ce formsemestre et TOUS ses modules STANDARDS + (donc sauf les modules bonus sport). + + DATA + ---- + ```json + { + "dept_id" : int, # le département + "etape" : string, # optionnel: l'étape Apogée d'inscription + "group_ids" : [int], # optionnel: liste des groupes où inscrire l'étudiant (doivent exister) + } + ``` + """ + data = request.get_json(force=True) if request.data else {} + dept_id = data.get("dept_id", g.scodoc_dept_id) + formsemestre = FormSemestre.get_formsemestre(formsemestre_id, dept_id=dept_id) + app.set_sco_dept(formsemestre.departement.acronym) + etud = Identite.get_etud(etudid) + + group_ids = data.get("group_ids", []) + etape = data.get("etape", None) + do_formsemestre_inscription_with_modules( + formsemestre.id, etud.id, dept_id=dept_id, etape=etape, group_ids=group_ids + ) + app.log(f"formsemestre_etud_inscrit: {etud} inscrit à {formsemestre}") + return ( + FormSemestreInscription.query.filter_by( + formsemestre_id=formsemestre.id, etudid=etud.id + ) + .first() + .to_dict() + ) + + +@bp.post("/formsemestre//etudid//desinscrit") +@api_web_bp.post("/formsemestre//etudid//desinscrit") +@login_required +@scodoc +@permission_required(Permission.EtudInscrit) +@as_json +def formsemestre_etud_desinscrit(formsemestre_id: int, etudid: int): + """Désinscrit l'étudiant de ce formsemestre et TOUS ses modules""" + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + app.set_sco_dept(formsemestre.departement.acronym) + etud = Identite.get_etud(etudid) + do_formsemestre_desinscription(etud.id, formsemestre.id) + app.log(f"formsemestre_etud_desinscrit: {etud} désinscrit de {formsemestre}") + return {"status": "ok"} + + @bp.route("/formsemestre//etat_evals") @api_web_bp.route("/formsemestre//etat_evals") @login_required @@ -649,10 +696,7 @@ def formsemestre_resultat(formsemestre_id: int): return json_error(API_CLIENT_ERROR, "invalid format specification") convert_values = format_spec != "raw" - query = FormSemestre.query.filter_by(id=formsemestre_id) - if g.scodoc_dept: - query = query.filter_by(dept_id=g.scodoc_dept_id) - formsemestre: FormSemestre = query.first_or_404(formsemestre_id) + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) app.set_sco_dept(formsemestre.departement.acronym) res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) # Ajoute le groupe de chaque partition, @@ -690,10 +734,7 @@ def formsemestre_resultat(formsemestre_id: int): @as_json def groups_get_auto_assignment(formsemestre_id: int): """Rend les données stockées par `groups_save_auto_assignment`.""" - query = FormSemestre.query.filter_by(id=formsemestre_id) - if g.scodoc_dept: - query = query.filter_by(dept_id=g.scodoc_dept_id) - formsemestre: FormSemestre = query.first_or_404(formsemestre_id) + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) response = make_response(formsemestre.groups_auto_assignment_data or b"") response.headers["Content-Type"] = scu.JSON_MIMETYPE return response @@ -713,11 +754,7 @@ def groups_save_auto_assignment(formsemestre_id: int): """Enregistre les données, associées à ce formsemestre. Usage réservé aux fonctions de gestion des groupes, ne pas utiliser ailleurs. """ - query = FormSemestre.query.filter_by(id=formsemestre_id) - if g.scodoc_dept: - query = query.filter_by(dept_id=g.scodoc_dept_id) - formsemestre: FormSemestre = query.first_or_404(formsemestre_id) - + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) if not formsemestre.can_change_groups(): return json_error(403, "non autorisé (can_change_groups)") @@ -726,6 +763,7 @@ def groups_save_auto_assignment(formsemestre_id: int): formsemestre.groups_auto_assignment_data = request.data db.session.add(formsemestre) db.session.commit() + return {"status": "ok"} @bp.route("/formsemestre//edt") @@ -746,10 +784,7 @@ def formsemestre_edt(formsemestre_id: int): group_ids : string (optionnel) filtre sur les groupes ScoDoc. show_modules_titles: show_modules_titles affiche le titre complet du module (défaut), sinon juste le code. """ - query = FormSemestre.query.filter_by(id=formsemestre_id) - if g.scodoc_dept: - query = query.filter_by(dept_id=g.scodoc_dept_id) - formsemestre: FormSemestre = query.first_or_404(formsemestre_id) + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) group_ids = request.args.getlist("group_ids", int) show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False)) return sco_edt_cal.formsemestre_edt_dict( diff --git a/app/api/moduleimpl.py b/app/api/moduleimpl.py index b5dea29b8..0ee6b2261 100644 --- a/app/api/moduleimpl.py +++ b/app/api/moduleimpl.py @@ -16,12 +16,15 @@ from flask_json import as_json from flask_login import login_required import app +from app import db from app.api import api_bp as bp, api_web_bp from app.api import api_permission_required as permission_required from app.decorators import scodoc -from app.models import ModuleImpl -from app.scodoc import sco_liste_notes +from app.models import Identite, ModuleImpl, ModuleImplInscription +from app.scodoc import sco_cache, sco_liste_notes +from app.scodoc.sco_moduleimpl import do_moduleimpl_inscrit_etuds from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_utils import json_error @bp.route("/moduleimpl/") @@ -63,6 +66,60 @@ def moduleimpl_inscriptions(moduleimpl_id: int): return [i.to_dict() for i in modimpl.inscriptions] +@bp.post("/moduleimpl//etudid//inscrit") +@api_web_bp.post("/moduleimpl//etudid//inscrit") +@login_required +@scodoc +@permission_required(Permission.ScoView) +@as_json +def moduleimpl_etud_inscrit(moduleimpl_id: int, etudid: int): + """Inscrit l'étudiant à ce moduleimpl. + + SAMPLES + ------- + /moduleimpl/1/etudid/2/inscrit + """ + modimpl = ModuleImpl.get_modimpl(moduleimpl_id) + if not modimpl.can_change_inscriptions(): + return json_error(403, "opération non autorisée") + etud = Identite.get_etud(etudid) + do_moduleimpl_inscrit_etuds(modimpl.id, modimpl.formsemestre_id, [etud.id]) + app.log(f"moduleimpl_etud_inscrit: {etud} inscrit à {modimpl}") + return ( + ModuleImplInscription.query.filter_by(moduleimpl_id=modimpl.id, etudid=etud.id) + .first() + .to_dict() + ) + + +@bp.post("/moduleimpl//etudid//desinscrit") +@api_web_bp.post("/moduleimpl//etudid//desinscrit") +@login_required +@scodoc +@permission_required(Permission.ScoView) +@as_json +def moduleimpl_etud_desinscrit(moduleimpl_id: int, etudid: int): + """Désinscrit l'étudiant de ce moduleimpl. + + SAMPLES + ------- + /moduleimpl/1/etudid/2/desinscrit + """ + modimpl = ModuleImpl.get_modimpl(moduleimpl_id) + if not modimpl.can_change_inscriptions(): + return json_error(403, "opération non autorisée") + etud = Identite.get_etud(etudid) + inscription = ModuleImplInscription.query.filter_by( + etudid=etud.id, moduleimpl_id=modimpl.id + ).first() + if inscription: + db.session.delete(inscription) + db.session.commit() + sco_cache.invalidate_formsemestre(formsemestre_id=modimpl.formsemestre_id) + app.log(f"moduleimpl_etud_desinscrit: {etud} inscrit à {modimpl}") + return {"status": "ok"} + + @bp.route("/moduleimpl//notes") @api_web_bp.route("/moduleimpl//notes") @login_required diff --git a/app/models/__init__.py b/app/models/__init__.py index 433635708..0cf5498dd 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -111,6 +111,12 @@ class ScoDocModel(db.Model): db.session.add(self) return modified + def to_dict(self) -> dict: + "dict" + d = dict(self.__dict__) + d.pop("_sa_instance_state", None) + return d + def edit_from_form(self, form) -> bool: """Generic edit method for updating model instance. True if modification. diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 8ac11d39f..6be8fc618 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -1318,7 +1318,7 @@ notes_formsemestre_responsables = db.Table( ) -class FormSemestreEtape(db.Model): +class FormSemestreEtape(models.ScoDocModel): """Étape Apogée associée au semestre""" __tablename__ = "notes_formsemestre_etapes" @@ -1349,7 +1349,7 @@ class FormSemestreEtape(db.Model): return ApoEtapeVDI(self.etape_apo) -class FormationModalite(db.Model): +class FormationModalite(models.ScoDocModel): """Modalités de formation, utilisées pour la présentation (grouper les semestres, générer des codes, etc.) """ @@ -1400,7 +1400,7 @@ class FormationModalite(db.Model): raise -class FormSemestreUECoef(db.Model): +class FormSemestreUECoef(models.ScoDocModel): """Coef des UE capitalisees arrivant dans ce semestre""" __tablename__ = "notes_formsemestre_uecoef" @@ -1441,7 +1441,7 @@ class FormSemestreUEComputationExpr(db.Model): computation_expr = db.Column(db.Text()) -class FormSemestreCustomMenu(db.Model): +class FormSemestreCustomMenu(models.ScoDocModel): """Menu custom associe au semestre""" __tablename__ = "notes_formsemestre_custommenu" @@ -1457,7 +1457,7 @@ class FormSemestreCustomMenu(db.Model): idx = db.Column(db.Integer, default=0, server_default="0") # rang dans le menu -class FormSemestreInscription(db.Model): +class FormSemestreInscription(models.ScoDocModel): """Inscription à un semestre de formation""" __tablename__ = "notes_formsemestre_inscription" @@ -1503,7 +1503,7 @@ class FormSemestreInscription(db.Model): } {('etape="'+self.etape+'"') if self.etape else ''}>""" -class NotesSemSet(db.Model): +class NotesSemSet(models.ScoDocModel): """semsets: ensemble de formsemestres pour exports Apogée""" __tablename__ = "notes_semset" diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index cb4c8a3b6..281d28c3b 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -325,7 +325,7 @@ notes_modules_enseignants = db.Table( # XXX il manque probablement une relation pour gérer cela -class ModuleImplInscription(db.Model): +class ModuleImplInscription(ScoDocModel): """Inscription à un module (etudiants,moduleimpl)""" __tablename__ = "notes_moduleimpl_inscription" diff --git a/app/models/notes.py b/app/models/notes.py index 91401e8fa..63e3b23f5 100644 --- a/app/models/notes.py +++ b/app/models/notes.py @@ -56,7 +56,7 @@ class BulAppreciations(models.ScoDocModel): return safehtml.html_to_safe_html(self.comment or "") -class NotesNotes(db.Model): +class NotesNotes(models.ScoDocModel): """Une note""" __tablename__ = "notes_notes" @@ -75,12 +75,6 @@ class NotesNotes(db.Model): date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) uid = db.Column(db.Integer, db.ForeignKey("user.id")) - def to_dict(self) -> dict: - "dict" - d = dict(self.__dict__) - d.pop("_sa_instance_state", None) - return d - def __repr__(self): "pour debug" from app.models.evaluations import Evaluation diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index 217a096e5..1cae54727 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -287,6 +287,8 @@ def do_formsemestre_inscription_with_modules( group_ids = group_ids or [] if isinstance(group_ids, int): group_ids = [group_ids] + # Check that all groups exist before creating the inscription + groups = [GroupDescr.query.get_or_404(group_id) for group_id in group_ids] formsemestre = FormSemestre.get_formsemestre(formsemestre_id, dept_id=dept_id) # inscription au semestre args = {"formsemestre_id": formsemestre_id, "etudid": etudid} @@ -303,14 +305,13 @@ def do_formsemestre_inscription_with_modules( # 1- inscrit au groupe 'tous' group_id = sco_groups.get_default_group(formsemestre_id) sco_groups.set_group(etudid, group_id) - gdone = {group_id: 1} # empeche doublons + gdone = {group_id} # empeche doublons # 2- inscrit aux groupes - for group_id in group_ids: - if group_id and group_id not in gdone: - _ = GroupDescr.query.get_or_404(group_id) + for group in groups: + if group.id not in gdone: sco_groups.set_group(etudid, group_id) - gdone[group_id] = 1 + gdone.add(group_id) # Inscription à tous les modules de ce semestre for modimpl in formsemestre.modimpls: diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index 2e5968b69..f831793ed 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -133,14 +133,15 @@ def moduleimpl_inscriptions_edit( if (partitionIdx==-1) { for (var i =nb_inputs_to_skip; i < elems.length; i++) { - elems[i].checked=check; + elems[i].checked=check; } } else { for (var i =nb_inputs_to_skip; i < elems.length; i++) { - var cells = elems[i].parentNode.parentNode.getElementsByTagName("td")[partitionIdx].childNodes; - if (cells.length && cells[0].nodeValue == groupName) { - elems[i].checked=check; - } + let tds = elems[i].parentNode.parentNode.getElementsByTagName("td"); + var cells = tds[partitionIdx].childNodes; + if (cells.length && cells[0].nodeValue == groupName) { + elems[i].checked=check; + } } } } @@ -179,19 +180,19 @@ def moduleimpl_inscriptions_edit( else: checked = "" H.append( - f"""""" + f""" + + {etud['nomprenom']} + + + """ ) - H.append( - f"""{etud['nomprenom']}""" - ) - H.append("""""") - groups = sco_groups.get_etud_groups(etud["etudid"], formsemestre.id) for partition in partitions: if partition["partition_name"]: diff --git a/tests/api/setup_test_api.py b/tests/api/setup_test_api.py index 3d7ca4616..efc2b7798 100644 --- a/tests/api/setup_test_api.py +++ b/tests/api/setup_test_api.py @@ -56,6 +56,9 @@ class APIError(Exception): self.message = message self.payload = payload or {} + def __str__(self): + return f"APIError: {self.message} payload={self.payload}" + def get_auth_headers(user, password) -> dict: "Demande de jeton, dict à utiliser dans les en-têtes de requêtes http" @@ -130,11 +133,17 @@ def GET(path: str, headers: dict = None, errmsg=None, dept=None, raw=False): def POST( - path: str, data: dict = {}, headers: dict = None, errmsg=None, dept=None, raw=False + path: str, + data: dict = None, + headers: dict = None, + errmsg=None, + dept=None, + raw=False, ): """Post Decode réponse en json, sauf si raw. """ + data = data or {} if dept: url = SCODOC_URL + f"/ScoDoc/{dept}/api" + path else: @@ -147,7 +156,13 @@ def POST( timeout=SCO_TEST_API_TIMEOUT, ) if r.status_code != 200: - raise APIError(errmsg or f"erreur status={r.status_code} !", r.json()) + try: + payload = r.json() + except requests.exceptions.JSONDecodeError: + payload = r.text + raise APIError( + errmsg or f"erreur url={url} status={r.status_code} !", payload=payload + ) return r if raw else r.json() # decode la reponse JSON diff --git a/tests/api/test_api_exceptions.py b/tests/api/test_api_exceptions.py new file mode 100644 index 000000000..49f96da6b --- /dev/null +++ b/tests/api/test_api_exceptions.py @@ -0,0 +1,51 @@ +"""Test API exceptions +""" + +import json +import requests + +import pytest +from tests.api.setup_test_api import ( + API_URL, + CHECK_CERTIFICATE, + api_headers, +) +from app.scodoc import sco_utils as scu + + +def test_exceptions(api_headers): + """ + Vérifie que les exceptions de l'API sont toutes en JSON. + """ + # Une requete sur une url inexistante ne passe pas par les blueprints API + # et est donc en HTML + r = requests.get( + f"{API_URL}/mmm/non/existant/mmm", + headers=api_headers, + verify=CHECK_CERTIFICATE, + timeout=scu.SCO_TEST_API_TIMEOUT, + ) + assert r.status_code == 404 + assert r.headers["Content-Type"] == "text/html; charset=utf-8" + + # Une requete d'un objet non existant est en JSON + r = requests.get( + f"{API_URL}/formsemestre/999999", + headers=api_headers, + verify=CHECK_CERTIFICATE, + timeout=scu.SCO_TEST_API_TIMEOUT, + ) + assert r.status_code == 404 + assert r.headers["Content-Type"] == "application/json" + assert r.json() + + # Une requête API sans autorisation est en JSON + r = requests.post( + f"{API_URL}/formsemestre/1/etudid/1/inscrit", + headers=api_headers, + verify=CHECK_CERTIFICATE, + timeout=scu.SCO_TEST_API_TIMEOUT, + ) + assert r.status_code == 401 + assert r.headers["Content-Type"] == "application/json" + assert r.json() diff --git a/tests/api/test_api_formsemestre.py b/tests/api/test_api_formsemestre.py index 8b1dc0156..23d55ccca 100644 --- a/tests/api/test_api_formsemestre.py +++ b/tests/api/test_api_formsemestre.py @@ -29,6 +29,7 @@ from tests.api.setup_test_api import ( CHECK_CERTIFICATE, GET, api_headers, + api_admin_headers, ) from tests.api.tools_test_api import ( @@ -585,6 +586,46 @@ def test_formsemestre_etudiants(api_headers): assert r_error_defaillants.status_code == 404 +def test_formsemestre_inscriptions(api_admin_headers): + """ + Route: /formsemestre//etudid//inscrit + """ + dept_id = 1 + formsemestre_id = 1 + etudid = 20 # pas déjà inscrit au semestre 1 + # -- Inscription + r = requests.post( + f"{API_URL}/formsemestre/{formsemestre_id}/etudid/{etudid}/inscrit", + data=json.dumps({"dept_id": dept_id}), + headers=api_admin_headers, + verify=CHECK_CERTIFICATE, + timeout=scu.SCO_TEST_API_TIMEOUT, + ) + assert r.status_code == 200 + inscription = r.json() + assert inscription["formsemestre_id"] == formsemestre_id + assert inscription["etudid"] == etudid + assert inscription["etat"] == "I" + # -- Désincription + r = requests.post( + f"{API_URL}/formsemestre/{formsemestre_id}/etudid/{etudid}/desinscrit", + headers=api_admin_headers, + verify=CHECK_CERTIFICATE, + timeout=scu.SCO_TEST_API_TIMEOUT, + ) + assert r.status_code == 200 + + ### ERROR ### + etudid_inexistant = 165165165165165165165 + r_error = requests.post( + f"{API_URL}/formsemestre/{formsemestre_id}/etudid/{etudid_inexistant}/inscrit", + headers=api_admin_headers, + verify=CHECK_CERTIFICATE, + timeout=scu.SCO_TEST_API_TIMEOUT, + ) + assert r_error.status_code == 404 + + def test_formsemestre_programme(api_headers): """ Route: /formsemestre/1/programme diff --git a/tools/create_api_map.py b/tools/create_api_map.py index 924564206..2e68b79f3 100644 --- a/tools/create_api_map.py +++ b/tools/create_api_map.py @@ -968,6 +968,8 @@ def gen_api_doc(app, endpoint_start="api."): with open(fname, "w", encoding="utf-8") as f: f.write(mdpage) print( - "La documentation API a été générée avec succès. " - f"Vous pouvez la consulter à l'adresse suivante : {fname}" + f"""La documentation API a été générée avec succès. +Vous pouvez la consulter à l'adresse suivante : {fname}. +Vous pouvez maintenant générer les samples avec `tools/test_api.sh --make-samples`. +""" )