From 9302a173aa86670cef60817a5cee205d185dabd5 Mon Sep 17 00:00:00 2001 From: ilona Date: Tue, 6 Aug 2024 09:22:27 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Modifie=20test=5Fapi.sh=20pour=20g=C3=A9n?= =?UTF-8?q?=C3=A9rer=20les=20exemples=20de=20la=20documentation=20(samples?= =?UTF-8?q?).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/templates/doc/ScoDoc9API.j2 | 3 +++ tools/test_api.sh | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/templates/doc/ScoDoc9API.j2 b/app/templates/doc/ScoDoc9API.j2 index 032c2280..e8fa5311 100644 --- a/app/templates/doc/ScoDoc9API.j2 +++ b/app/templates/doc/ScoDoc9API.j2 @@ -270,6 +270,9 @@ Pour uniformiser les résultats des exemples, ceux sont soumis à quelques post- Voir exemples d'utilisation de l'API en Python, dans `tests/api/`. +!!! info + Cette page a été générée par la commande `flask gen-api-doc`, et les exemples de résultats + sont créés par `tools/test_api.sh --make-samples`. !!! note "Voir aussi" diff --git a/tools/test_api.sh b/tools/test_api.sh index 4a915d33..609c9e78 100755 --- a/tools/test_api.sh +++ b/tools/test_api.sh @@ -11,13 +11,17 @@ # # Toutes les autres options sont passées telles qu'elles à pytest # +# Utilisation pour générer des exemples de documentation: +# tools/test_api.sh --make-samples +# # Exemples: # - lancer tous les tests API: tools/test_api.sh # - lancer tous les tests, en mode debug (arrêt pdb sur le 1er): # tools/test_api.sh -x --pdb tests/api # - lancer un module de test, en utilisant un server dev existant: # tools/test_api.sh --dont-start-server -x --pdb tests/api/test_api_evaluations.py -# +# - Générer les samples pour la doc: +# tools/test_api.sh --make-samples # # E. Viennet, Fev 2023 @@ -71,8 +75,14 @@ then echo "Starting pytest tests/api" pytest tests/api else - echo "Starting pytest $@" - pytest "$@" + if [ "$1" = "--make-samples" ] + then + echo "Generating API documentation samples" + python tests/api/make_samples.py -i /tmp/samples.csv + else + echo "Starting pytest $@" + pytest "$@" + fi fi # ------------------ From 0533ad59fdbe66d1cc2186a067fb2a01ae7b4925 Mon Sep 17 00:00:00 2001 From: ilona Date: Tue, 6 Aug 2024 09:23:53 +0200 Subject: [PATCH 2/3] =?UTF-8?q?API:=20code=20http=20403=20et=20non=20401?= =?UTF-8?q?=20si=20permission=20non=20accord=C3=A9e.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/justificatifs.py | 2 +- app/api/partitions.py | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index 4a7f3649..d9e66a52 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -765,7 +765,7 @@ def justif_export(justif_id: int | None = None, filename: str | None = None): current_user.has_permission(Permission.AbsJustifView) or justificatif_unique.user_id == current_user.id ): - return json_error(401, "non autorisé à voir ce fichier") + return json_error(403, "non autorisé à voir ce fichier") # On récupère l'archive concernée archive_name: str = justificatif_unique.fichier diff --git a/app/api/partitions.py b/app/api/partitions.py index 51561501..d9ac1cef 100644 --- a/app/api/partitions.py +++ b/app/api/partitions.py @@ -169,7 +169,7 @@ def group_set_etudiant(group_id: int, etudid: int): if not group.partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not group.partition.formsemestre.can_change_groups(): - return json_error(401, "opération non autorisée") + return json_error(403, "opération non autorisée") if etud.id not in {e.id for e in group.partition.formsemestre.etuds}: return json_error(404, "etud non inscrit au formsemestre du groupe") @@ -202,7 +202,7 @@ def group_remove_etud(group_id: int, etudid: int): if not group.partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not group.partition.formsemestre.can_change_groups(): - return json_error(401, "opération non autorisée") + return json_error(403, "opération non autorisée") group.remove_etud(etud) @@ -232,7 +232,7 @@ def partition_remove_etud(partition_id: int, etudid: int): if not partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not partition.formsemestre.can_change_groups(): - return json_error(401, "opération non autorisée") + return json_error(403, "opération non autorisée") db.session.execute( sa.text( """DELETE FROM group_membership @@ -289,7 +289,7 @@ def group_create(partition_id: int): # partition-group-create if not partition.groups_editable: return json_error(403, "partition non editable") if not partition.formsemestre.can_change_groups(): - return json_error(401, "opération non autorisée") + return json_error(403, "opération non autorisée") args = request.get_json(force=True) # may raise 400 Bad Request group_name = args.get("group_name") @@ -337,7 +337,7 @@ def group_delete(group_id: int): if not group.partition.groups_editable: return json_error(403, "partition non editable") if not group.partition.formsemestre.can_change_groups(): - return json_error(401, "opération non autorisée") + return json_error(403, "opération non autorisée") formsemestre_id = group.partition.formsemestre_id log(f"deleting {group}") db.session.delete(group) @@ -378,7 +378,7 @@ def group_edit(group_id: int): if not group.partition.groups_editable: return json_error(403, "partition non editable") if not group.partition.formsemestre.can_change_groups(): - return json_error(401, "opération non autorisée") + return json_error(403, "opération non autorisée") args = request.get_json(force=True) # may raise 400 Bad Request if "group_name" in args: @@ -423,7 +423,7 @@ def group_set_edt_id(group_id: int, edt_id: str): ) group: GroupDescr = query.first_or_404() if not group.partition.formsemestre.can_change_groups(): - return json_error(401, "opération non autorisée") + return json_error(403, "opération non autorisée") log(f"group_set_edt_id( {group_id}, '{edt_id}' )") group.edt_id = edt_id db.session.add(group) @@ -461,7 +461,7 @@ def partition_create(formsemestre_id: int): if not formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not formsemestre.can_change_groups(): - return json_error(401, "opération non autorisée") + return json_error(403, "opération non autorisée") data = request.get_json(force=True) # may raise 400 Bad Request partition_name = data.get("partition_name") if partition_name is None: @@ -523,7 +523,7 @@ def formsemestre_set_partitions_order(formsemestre_id: int): if not formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not formsemestre.can_change_groups(): - return json_error(401, "opération non autorisée") + return json_error(403, "opération non autorisée") partition_ids = request.get_json(force=True) # may raise 400 Bad Request if not isinstance(partition_ids, list) and not all( isinstance(x, int) for x in partition_ids @@ -569,7 +569,7 @@ def partition_order_groups(partition_id: int): if not partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not partition.formsemestre.can_change_groups(): - return json_error(401, "opération non autorisée") + return json_error(403, "opération non autorisée") group_ids = request.get_json(force=True) # may raise 400 Bad Request if not isinstance(group_ids, list) and not all( isinstance(x, int) for x in group_ids @@ -623,7 +623,7 @@ def partition_edit(partition_id: int): if not partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not partition.formsemestre.can_change_groups(): - return json_error(401, "opération non autorisée") + return json_error(403, "opération non autorisée") data = request.get_json(force=True) # may raise 400 Bad Request modified = False partition_name = data.get("partition_name") @@ -689,7 +689,7 @@ def partition_delete(partition_id: int): if not partition.formsemestre.etat: return json_error(403, "formsemestre verrouillé") if not partition.formsemestre.can_change_groups(): - return json_error(401, "opération non autorisée") + return json_error(403, "opération non autorisée") if not partition.partition_name: return json_error( API_CLIENT_ERROR, "ne peut pas supprimer la partition par défaut" From 03ba057a877f1389a0e64a1e325867cb5a6f20ce Mon Sep 17 00:00:00 2001 From: ilona Date: Tue, 6 Aug 2024 22:30:30 +0200 Subject: [PATCH 3/3] API: modimpl and formsemestre inscription/desinscription --- app/api/__init__.py | 3 +- app/api/formsemestres.py | 105 +++++++++++++------- app/api/moduleimpl.py | 61 +++++++++++- app/models/__init__.py | 6 ++ app/models/formsemestre.py | 12 +-- app/models/moduleimpls.py | 2 +- app/models/notes.py | 8 +- app/scodoc/sco_formsemestre_inscriptions.py | 11 +- app/scodoc/sco_moduleimpl_inscriptions.py | 35 +++---- tests/api/setup_test_api.py | 19 +++- tests/api/test_api_exceptions.py | 51 ++++++++++ tests/api/test_api_formsemestre.py | 41 ++++++++ tools/create_api_map.py | 6 +- 13 files changed, 282 insertions(+), 78 deletions(-) create mode 100644 tests/api/test_api_exceptions.py diff --git a/app/api/__init__.py b/app/api/__init__.py index 8a205424..a098d7e9 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 7dc8ccf1..7834c4cb 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 b5dea29b..0ee6b226 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 43363570..0cf5498d 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 8ac11d39..6be8fc61 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 cb4c8a3b..281d28c3 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 91401e8f..63e3b23f 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 217a096e..1cae5472 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 2e5968b6..f831793e 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 3d7ca461..efc2b779 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 00000000..49f96da6 --- /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 8b1dc015..23d55ccc 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 92456420..2e68b79f 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`. +""" )