diff --git a/app/api/etudiants.py b/app/api/etudiants.py index b1d07c5f..10fb562b 100755 --- a/app/api/etudiants.py +++ b/app/api/etudiants.py @@ -10,7 +10,7 @@ from datetime import datetime from operator import attrgetter -from flask import g, request +from flask import g, request, Response from flask_json import as_json from flask_login import current_user from flask_login import login_required @@ -18,7 +18,7 @@ from sqlalchemy import desc, func, or_ from sqlalchemy.dialects.postgresql import VARCHAR import app -from app import db +from app import db, log from app.api import api_bp as bp, api_web_bp from app.api import tools from app.but import bulletin_but_court @@ -26,6 +26,7 @@ from app.decorators import scodoc, permission_required from app.models import ( Admission, Departement, + EtudAnnotation, FormSemestreInscription, FormSemestre, Identite, @@ -54,6 +55,32 @@ import app.scodoc.sco_utils as scu # +def _get_etud_by_code( + code_type: str, code: str, dept: Departement +) -> tuple[bool, Response | Identite]: + """Get etud, using etudid, NIP or INE + Returns True, etud if ok, or False, error response. + """ + if code_type == "nip": + query = Identite.query.filter_by(code_nip=code) + elif code_type == "etudid": + try: + etudid = int(code) + except ValueError: + return False, json_error(404, "invalid etudid type") + query = Identite.query.filter_by(id=etudid) + elif code_type == "ine": + query = Identite.query.filter_by(code_ine=code) + else: + return False, json_error(404, "invalid code_type") + if dept: + query = query.filter_by(dept_id=dept.id) + etud = query.first() + if etud is None: + return False, json_error(404, message="etudiant inexistant") + return True, etud + + @bp.route("/etudiants/courants", defaults={"long": False}) @bp.route("/etudiants/courants/long", defaults={"long": True}) @api_web_bp.route("/etudiants/courants", defaults={"long": False}) @@ -389,28 +416,15 @@ def bulletin( pdf = True if version not in scu.BULLETINS_VERSIONS_BUT: return json_error(404, "version invalide") - # return f"{code_type}={code}, version={version}, pdf={pdf}" formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404() dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404() if g.scodoc_dept and dept.acronym != g.scodoc_dept: return json_error(404, "formsemestre inexistant") app.set_sco_dept(dept.acronym) - if code_type == "nip": - query = Identite.query.filter_by(code_nip=code, dept_id=dept.id) - elif code_type == "etudid": - try: - etudid = int(code) - except ValueError: - return json_error(404, "invalid etudid type") - query = Identite.query.filter_by(id=etudid) - elif code_type == "ine": - query = Identite.query.filter_by(code_ine=code, dept_id=dept.id) - else: - return json_error(404, "invalid code_type") - etud = query.first() - if etud is None: - return json_error(404, message="etudiant inexistant") + ok, etud = _get_etud_by_code(code_type, code, dept) + if not ok: + return etud # json error if version == "butcourt": if pdf: @@ -562,26 +576,15 @@ def etudiant_create(force=False): @api_web_bp.route("/etudiant///edit", methods=["POST"]) @scodoc @permission_required(Permission.EtudInscrit) +@as_json def etudiant_edit( code_type: str = "etudid", code: str = None, ): """Edition des données étudiant (identité, admission, adresses)""" - if code_type == "nip": - query = Identite.query.filter_by(code_nip=code) - elif code_type == "etudid": - try: - etudid = int(code) - except ValueError: - return json_error(404, "invalid etudid type") - query = Identite.query.filter_by(id=etudid) - elif code_type == "ine": - query = Identite.query.filter_by(code_ine=code) - else: - return json_error(404, "invalid code_type") - if g.scodoc_dept: - query = query.filter_by(dept_id=g.scodoc_dept_id) - etud: Identite = query.first() + ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept) + if not ok: + return etud # json error # args = request.get_json(force=True) # may raise 400 Bad Request etud.from_dict(args) @@ -604,3 +607,67 @@ def etudiant_edit( restrict = not current_user.has_permission(Permission.ViewEtudData) r = etud.to_dict_api(restrict=restrict) return r + + +@bp.route("/etudiant///annotation", methods=["POST"]) +@api_web_bp.route( + "/etudiant///annotation", methods=["POST"] +) +@scodoc +@permission_required(Permission.EtudInscrit) # il faut en plus ViewEtudData +@as_json +def etudiant_annotation( + code_type: str = "etudid", + code: str = None, +): + """Ajout d'une annotation sur un étudiant""" + if not current_user.has_permission(Permission.ViewEtudData): + return json_error(403, "non autorisé (manque ViewEtudData)") + ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept) + if not ok: + return etud # json error + # + args = request.get_json(force=True) # may raise 400 Bad Request + comment = args.get("comment", None) + if not isinstance(comment, str): + return json_error(404, "invalid comment (expected string)") + if len(comment) > scu.MAX_TEXT_LEN: + return json_error(404, "invalid comment (too large)") + annotation = EtudAnnotation(comment=comment, author=current_user.user_name) + etud.annotations.append(annotation) + db.session.add(etud) + db.session.commit() + log(f"etudiant_annotation/{etud.id}/{annotation.id}") + return annotation.to_dict() + + +@bp.route( + "/etudiant///annotation//delete", + methods=["POST"], +) +@api_web_bp.route( + "/etudiant///annotation//delete", + methods=["POST"], +) +@login_required +@scodoc +@as_json +@permission_required(Permission.EtudInscrit) +def etudiant_annotation_delete( + code_type: str = "etudid", code: str = None, annotation_id: int = None +): + """ + Suppression d'une annotation + """ + ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept) + if not ok: + return etud # json error + annotation = EtudAnnotation.query.filter_by( + etudid=etud.id, id=annotation_id + ).first() + if annotation is None: + return json_error(404, "annotation not found") + log(f"etudiant_annotation_delete/{etud.id}/{annotation.id}") + db.session.delete(annotation) + db.session.commit() + return "ok" diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 5e61d028..bd70e03c 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -101,7 +101,12 @@ class Identite(models.ScoDocModel): adresses = db.relationship( "Adresse", back_populates="etud", cascade="all,delete", lazy="dynamic" ) - + annotations = db.relationship( + "EtudAnnotation", + backref="etudiant", + cascade="all, delete-orphan", + lazy="dynamic", + ) billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic") # dispense_ues = db.relationship( @@ -477,9 +482,9 @@ class Identite(models.ScoDocModel): "civilite": self.civilite, "code_ine": self.code_ine or "", "code_nip": self.code_nip or "", - "date_naissance": self.date_naissance.strftime("%d/%m/%Y") - if self.date_naissance - else "", + "date_naissance": ( + self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else "" + ), "dept_acronym": self.departement.acronym, "dept_id": self.dept_id, "dept_naissance": self.dept_naissance or "", @@ -519,12 +524,16 @@ class Identite(models.ScoDocModel): e.pop("departement", None) e["sort_key"] = self.sort_key if with_annotations: - e["annotations"] = [ - annot.to_dict(restrict=restrict) - for annot in EtudAnnotation.query.filter_by(etudid=self.id).order_by( - desc(EtudAnnotation.date) - ) - ] + e["annotations"] = ( + [ + annot.to_dict() + for annot in EtudAnnotation.query.filter_by( + etudid=self.id + ).order_by(desc(EtudAnnotation.date)) + ] + if not restrict + else [] + ) if restrict: # Met à None les attributs protégés: for attr in self.protected_attrs: @@ -1079,18 +1088,14 @@ class EtudAnnotation(db.Model): id = db.Column(db.Integer, primary_key=True) date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) - etudid = db.Column(db.Integer) # sans contrainte (compat ScoDoc 7)) + etudid = db.Column(db.Integer, db.ForeignKey(Identite.id)) author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user comment = db.Column(db.Text) - protected_attrs = {"comment"} - - def to_dict(self, restrict=False): - """Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD).""" + def to_dict(self): + """Représentation dictionnaire.""" e = dict(self.__dict__) e.pop("_sa_instance_state", None) - if restrict: - e = {k: v for (k, v) in e.items() if k not in self.protected_attrs} return e diff --git a/tests/api/test_api_etudiants.py b/tests/api/test_api_etudiants.py index 8b1c82e9..58d380e4 100644 --- a/tests/api/test_api_etudiants.py +++ b/tests/api/test_api_etudiants.py @@ -204,8 +204,8 @@ def test_etudiants(api_headers): all_unique = True list_ids = [etud["id"] for etud in etud_nip] - for id in list_ids: - if list_ids.count(id) > 1: + for etudid in list_ids: + if list_ids.count(etudid) > 1: all_unique = False assert all_unique is True @@ -226,8 +226,8 @@ def test_etudiants(api_headers): all_unique = True list_ids = [etud["id"] for etud in etud_ine] - for id in list_ids: - if list_ids.count(id) > 1: + for etudid in list_ids: + if list_ids.count(etudid) > 1: all_unique = False assert all_unique is True @@ -267,6 +267,53 @@ def test_etudiants_by_name(api_headers): assert etuds[0]["nom"] == "RÉGNIER" +def test_etudiant_annotations(api_headers): + """ + Routes: + POST /etudiant/etudid//annotation + GET /etudiant/etudid/[/long] + """ + admin_header = get_auth_headers(API_USER_ADMIN, API_PASSWORD_ADMIN) + args = { + "prenom": "Annoté", + "nom": "Bach A", + "dept": DEPT_ACRONYM, + "civilite": "M", + } + etud = POST_JSON( + "/etudiant/create", + args, + headers=admin_header, + ) + assert etud["nom"] == args["nom"].upper() + etudid = etud["id"] + # récupère annotation (liste vide) + etud = GET(f"/etudiant/etudid/{etudid}", headers=api_headers) + assert etud["nom"] + assert etud["annotations"] == [] + # ajoute annotation + annotation = POST_JSON( + f"/etudiant/etudid/{etudid}/annotation", + {"comment": "annotation 1"}, + headers=admin_header, + ) + assert annotation + annotation_id = annotation["id"] + etud = GET(f"/etudiant/etudid/{etudid}", headers=api_headers) + assert len(etud["annotations"]) == 0 # pas le droit => cachée + etud = GET(f"/etudiant/etudid/{etudid}", headers=admin_header) + assert len(etud["annotations"]) == 1 # ok avec admin + assert etud["annotations"][0]["comment"] == "annotation 1" + assert etud["annotations"][0]["id"] == annotation_id + # Supprime annotation + POST_JSON( + f"/etudiant/etudid/{etudid}/annotation/{annotation_id}/delete", + headers=admin_header, + ) + etud = GET(f"/etudiant/etudid/{etudid}", headers=api_headers) + assert len(etud["annotations"]) == 0 + + def test_etudiant_photo(api_headers): """ Routes : /etudiant/etudid//photo en GET et en POST diff --git a/tests/unit/test_etudiants.py b/tests/unit/test_etudiants.py index f2d83d2e..d319360f 100644 --- a/tests/unit/test_etudiants.py +++ b/tests/unit/test_etudiants.py @@ -15,7 +15,14 @@ from flask import current_app import app from app import db -from app.models import Admission, Adresse, Departement, FormSemestre, Identite +from app.models import ( + Admission, + Adresse, + Departement, + EtudAnnotation, + FormSemestre, + Identite, +) from app.scodoc import sco_etud from app.scodoc import sco_find_etud, sco_import_etuds from config import TestConfig @@ -116,6 +123,32 @@ def test_etat_civil(test_client): assert e_d["ne"] == "(e)" +def test_etud_annotations(test_client): + "Test ajout/suppression annotations" + dept = Departement.query.first() + args = {"nom": "nom_a", "prenom": "prénom_a", "civilite": "M", "dept_id": dept.id} + etud = Identite.create_etud(**args) + db.session.flush() + # Ajout annotations + etud.annotations.append(EtudAnnotation(comment="annotation 1")) + etud.annotations.append(EtudAnnotation(comment="annotation 2")) + db.session.add(etud) + db.session.commit() + assert etud.annotations.count() == 2 + # Suppression de la première + a = etud.annotations.first() + assert a.comment == "annotation 1" + db.session.delete(a) + db.session.commit() + assert db.session.get(Identite, etud.id) + assert etud.annotations.count() == 1 + # Suppression de l'étudiant (cascade) + a2_id = etud.annotations.first().id + db.session.delete(etud) + db.session.commit() + assert db.session.get(EtudAnnotation, a2_id) is None + + def test_etud_legacy(test_client): "Test certaines fonctions scodoc7 (sco_etud)" dept = Departement.query.first()