Annotations étudiants: API et tests

This commit is contained in:
Emmanuel Viennet 2024-02-11 12:05:43 +01:00
parent 052fb3c7b9
commit b30ea5f5fd
4 changed files with 207 additions and 55 deletions

View File

@ -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/<string:code_type>/<string:code>/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/<string:code_type>/<string:code>/annotation", methods=["POST"])
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/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/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/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"

View File

@ -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

View File

@ -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/<int:etudid>/annotation
GET /etudiant/etudid/<int: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/<int:etudid>/photo en GET et en POST

View File

@ -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()